Files
2026-07-19 23:52:25 +04:00

732 lines
24 KiB
Python

"""
maven dataset QA server
serves audio + text for review, writes keep/delete decisions back to txt file.
usage:
pip install fastapi uvicorn
python qa_server.py --wavs dataset/test --txt data/neutral-voice-dataset-list-clean.txt
open http://localhost:8765
"""
import csv
import json
import argparse
import shutil
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import uvicorn
# ── args ──────────────────────────────────────────────────────────────────────
parser = argparse.ArgumentParser()
parser.add_argument("--wavs", default="dataset/test", help="folder with wav files")
parser.add_argument("--txt", required=True, help="txt file with sentences (one per line)")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
WAV_DIR = Path(args.wavs)
TXT_FILE = Path(args.txt)
STATE_FILE = TXT_FILE.parent / (TXT_FILE.stem + "-qa-state.json")
META_FILE = Path("tts/dataset/metadata.csv")
# ── state ─────────────────────────────────────────────────────────────────────
def load_sentences() -> list[str]:
return [l.strip() for l in TXT_FILE.read_text(encoding="utf-8").splitlines() if l.strip()]
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {} # index → "keep" | "delete"
def save_state(state: dict):
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
def _build_text_to_wav() -> dict[str, str]:
"""Read metadata.csv (read-only) and build text → absolute wav path mapping."""
mapping = {}
if not META_FILE.exists():
return mapping
with open(META_FILE, "r", encoding="utf-8") as f:
for row in csv.DictReader(f):
text = row["text"].strip()
file_path = row["file_path"].strip()
# resolve to absolute path
p = Path(file_path)
if not p.is_absolute():
# file_path is like "tts/dataset/neutral/0000.wav"
p = Path(__file__).resolve().parent.parent / file_path
if p.exists():
mapping[text] = str(p)
return mapping
# cache at startup
_TEXT_TO_WAV = _build_text_to_wav()
def wav_for(idx: int) -> Path | None:
"""Find wav file for sentence at txt line index via metadata.csv lookup."""
sentences = load_sentences()
if idx >= len(sentences):
return None
text = sentences[idx]
wav_path = _TEXT_TO_WAV.get(text)
if wav_path and Path(wav_path).exists():
return Path(wav_path)
return None
def get_deleted_indices() -> list[int]:
"""Get indices of deleted lines — from state first, then fallback to deleted/ folder."""
state = load_state()
indices = [int(k) for k, v in state.items() if v == "delete"]
if indices:
return sorted(indices)
# fallback: scan deleted/ folder for numeric wav filenames
deleted_dir = WAV_DIR / "deleted"
if not deleted_dir.exists():
return []
for p in deleted_dir.glob("*.wav"):
stem = p.stem.lstrip("0") or "0"
if stem.isdigit():
indices.append(int(stem))
return sorted(indices)
# ── app ───────────────────────────────────────────────────────────────────────
app = FastAPI()
@app.get("/api/info")
def info():
sentences = load_sentences()
state = load_state()
wavs = sorted(int(p.stem) for p in WAV_DIR.glob("*.wav") if p.stem.isdigit())
reviewed = len(state)
kept = sum(1 for v in state.values() if v == "keep")
deleted = sum(1 for v in state.values() if v == "delete")
missing = [i for i in range(len(sentences)) if wav_for(i) is None]
return {
"total": len(sentences),
"reviewed": reviewed,
"kept": kept,
"deleted": deleted,
"missing": missing,
"wavs": wavs,
}
@app.get("/api/next")
def next_unreviewed(after: int = -1):
sentences = load_sentences()
state = load_state()
for i in range(after + 1, len(sentences)):
if str(i) not in state and wav_for(i) is not None:
return {
"index": i,
"text": sentences[i],
"total": len(sentences),
"decision": state.get(str(i)),
}
return {"index": -1, "text": None, "total": len(sentences)}
@app.get("/api/sentence/{idx}")
def get_sentence(idx: int):
sentences = load_sentences()
state = load_state()
if idx >= len(sentences):
raise HTTPException(404)
return {
"index": idx,
"text": sentences[idx],
"total": len(sentences),
"decision": state.get(str(idx)),
"has_wav": wav_for(idx) is not None,
}
@app.get("/api/audio/{idx}")
def get_audio(idx: int):
wav = wav_for(idx)
if wav is None:
raise HTTPException(404, "wav not found")
return FileResponse(wav, media_type="audio/wav")
class Decision(BaseModel):
decision: str # "keep" | "delete"
@app.post("/api/decide/{idx}")
def decide(idx: int, body: Decision):
if body.decision not in ("keep", "delete"):
raise HTTPException(400, "decision must be keep or delete")
sentences = load_sentences()
if idx >= len(sentences):
raise HTTPException(404)
state = load_state()
state[str(idx)] = body.decision
save_state(state)
return {"ok": True}
@app.post("/api/export")
def export():
"""write final txt with only kept sentences + list of missing wavs."""
sentences = load_sentences()
state = load_state()
kept = []
missing = []
for i, s in enumerate(sentences):
decision = state.get(str(i))
if decision == "keep":
kept.append(s)
elif decision is None and wav_for(i) is None:
missing.append(i)
# deleted → skip
out_path = TXT_FILE.parent / (TXT_FILE.stem + "-final.txt")
out_path.write_text("\n".join(kept), encoding="utf-8")
missing_path = TXT_FILE.parent / (TXT_FILE.stem + "-missing.json")
missing_path.write_text(json.dumps(missing), encoding="utf-8")
return {
"kept": len(kept),
"missing": missing,
"out": str(out_path),
}
# ── ui ────────────────────────────────────────────────────────────────────────
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maven QA</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: #111118;
--border: #1e1e2e;
--text: #c8c8d8;
--dim: #555570;
--keep: #3ddc84;
--delete: #ff4d6d;
--accent: #7c6af7;
--font: 'JetBrains Mono', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
header {
position: fixed;
top: 0; left: 0; right: 0;
padding: 1rem 2rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 10;
}
.logo { font-size: 0.75rem; color: var(--accent); letter-spacing: 0.2em; text-transform: uppercase; }
.stats {
display: flex;
gap: 1.5rem;
font-size: 0.7rem;
color: var(--dim);
}
.stats span b { color: var(--text); }
.card {
width: 100%;
max-width: 680px;
margin-top: 4rem;
}
.index {
font-size: 0.65rem;
color: var(--dim);
letter-spacing: 0.1em;
margin-bottom: 0.75rem;
}
.sentence {
font-size: 1.1rem;
font-weight: 400;
line-height: 1.6;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
min-height: 80px;
display: flex;
align-items: center;
}
.player {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.play-btn {
width: 44px; height: 44px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
flex-shrink: 0;
transition: border-color 0.15s, background 0.15s;
}
.play-btn:hover { border-color: var(--accent); background: #1a1a2e; }
.play-btn.playing { border-color: var(--accent); color: var(--accent); }
.waveform {
flex: 1;
height: 44px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
display: flex;
align-items: center;
padding: 0 0.75rem;
gap: 2px;
overflow: hidden;
cursor: pointer;
}
.bar {
width: 2px;
border-radius: 1px;
background: var(--border);
transition: background 0.1s;
flex-shrink: 0;
}
.bar.active { background: var(--accent); }
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
.btn {
padding: 0.875rem;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-family: var(--font);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
}
.btn-keep { border-color: var(--keep); color: var(--keep); }
.btn-keep:hover { background: rgba(61,220,132,0.08); }
.btn-keep.active { background: rgba(61,220,132,0.15); }
.btn-delete { border-color: var(--delete); color: var(--delete); }
.btn-delete:hover { background: rgba(255,77,109,0.08); }
.btn-delete.active { background: rgba(255,77,109,0.15); }
.btn-nav {
width: 100%;
margin-top: 0.5rem;
color: var(--dim);
font-size: 0.72rem;
}
.btn-nav:hover { color: var(--text); border-color: var(--dim); }
.btn-export {
width: 100%;
margin-top: 2rem;
color: var(--accent);
border-color: var(--accent);
font-size: 0.72rem;
}
.btn-export:hover { background: rgba(124,106,247,0.08); }
.progress-bar {
height: 2px;
background: var(--border);
border-radius: 1px;
margin-bottom: 2rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 1px;
transition: width 0.3s ease;
}
.decision-badge {
display: inline-block;
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 0.2rem 0.5rem;
border-radius: 2px;
margin-bottom: 0.75rem;
}
.decision-badge.keep { background: rgba(61,220,132,0.15); color: var(--keep); }
.decision-badge.delete { background: rgba(255,77,109,0.15); color: var(--delete); }
.decision-badge.none { display: none; }
.no-wav {
font-size: 0.7rem;
color: var(--delete);
letter-spacing: 0.1em;
margin-bottom: 0.75rem;
}
.export-result {
margin-top: 1rem;
font-size: 0.72rem;
color: var(--keep);
text-align: center;
}
kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 0.65rem;
color: var(--dim);
margin-left: 0.25rem;
}
</style>
</head>
<body>
<header>
<div class="logo">Maven // Dataset QA</div>
<div class="stats">
<span><b id="s-reviewed">0</b> reviewed</span>
<span><b id="s-kept" style="color:var(--keep)">0</b> kept</span>
<span><b id="s-deleted" style="color:var(--delete)">0</b> deleted</span>
<span><b id="s-total">0</b> total</span>
</div>
</header>
<div class="card">
<div class="progress-bar"><div class="progress-fill" id="progress" style="width:0%"></div></div>
<div class="index" id="idx">— / —</div>
<div class="decision-badge none" id="badge"></div>
<div class="no-wav" id="no-wav" style="display:none">⚠ no audio file</div>
<div class="sentence" id="sentence">loading...</div>
<div class="player">
<button class="play-btn" id="play-btn" onclick="togglePlay()">▶</button>
<div class="waveform" id="waveform" onclick="seekClick(event)"></div>
</div>
<div class="actions">
<button class="btn btn-keep" id="btn-keep" onclick="decide('keep')">✓ keep<kbd>k</kbd></button>
<button class="btn btn-delete" id="btn-delete" onclick="decide('delete')">✕ delete<kbd>d</kbd></button>
</div>
<button class="btn btn-nav" onclick="navigate(-1)">← prev<kbd>←</kbd></button>
<button class="btn btn-nav" style="margin-top:0.5rem" onclick="navigate(1)">next →<kbd>→</kbd></button>
<button class="btn btn-export" onclick="doExport()">⬇ export final dataset</button>
<button class="btn btn-export" style="margin-top:0.5rem;color:var(--delete);border-color:var(--delete)" onclick="doCleanup()">⊗ move deleted wavs to deleted/</button>
<button class="btn btn-export" style="margin-top:0.5rem;color:var(--dim);border-color:var(--dim)" onclick="doResetDeleted()">↺ reset deleted → unreviewed (if wav exists)</button>
<div class="export-result" id="export-result"></div>
</div>
<audio id="audio"></audio>
<script>
let currentIdx = 0;
let totalSentences = 0;
let audio = document.getElementById('audio');
let bars = [];
let animFrame = null;
// ── waveform ──────────────────────────────────────────────────────────────────
function buildWaveform() {
const wf = document.getElementById('waveform');
wf.innerHTML = '';
bars = [];
const n = Math.floor(wf.offsetWidth / 4);
for (let i = 0; i < n; i++) {
const b = document.createElement('div');
b.className = 'bar';
const h = 6 + Math.random() * 28;
b.style.height = h + 'px';
wf.appendChild(b);
bars.push(b);
}
}
function updateWaveform() {
if (!bars.length) return;
const pct = audio.duration ? audio.currentTime / audio.duration : 0;
const active = Math.floor(pct * bars.length);
bars.forEach((b, i) => b.classList.toggle('active', i < active));
if (!audio.paused) animFrame = requestAnimationFrame(updateWaveform);
}
function seekClick(e) {
if (!audio.src || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
updateWaveform();
}
// ── playback ──────────────────────────────────────────────────────────────────
function togglePlay() {
if (audio.paused) { audio.play(); }
else { audio.pause(); }
}
audio.addEventListener('play', () => {
document.getElementById('play-btn').classList.add('playing');
document.getElementById('play-btn').textContent = '';
animFrame = requestAnimationFrame(updateWaveform);
});
audio.addEventListener('pause', () => {
document.getElementById('play-btn').classList.remove('playing');
document.getElementById('play-btn').textContent = '';
cancelAnimationFrame(animFrame);
});
audio.addEventListener('ended', () => {
document.getElementById('play-btn').textContent = '';
bars.forEach(b => b.classList.remove('active'));
});
// ── load sentence ─────────────────────────────────────────────────────────────
async function loadSentence(idx) {
const res = await fetch(`/api/sentence/${idx}`);
if (!res.ok) return;
const data = await res.json();
currentIdx = idx;
totalSentences = data.total;
document.getElementById('idx').textContent = `${idx + 1} / ${data.total}`;
document.getElementById('sentence').textContent = data.text || '';
// decision badge
const badge = document.getElementById('badge');
badge.className = 'decision-badge ' + (data.decision || 'none');
badge.textContent = data.decision || '';
// button active states
document.getElementById('btn-keep').classList.toggle('active', data.decision === 'keep');
document.getElementById('btn-delete').classList.toggle('active', data.decision === 'delete');
// wav
const noWav = document.getElementById('no-wav');
if (data.has_wav) {
noWav.style.display = 'none';
audio.src = `/api/audio/${idx}?t=${Date.now()}`;
audio.load();
buildWaveform();
// autoplay
try { await audio.play(); } catch {}
} else {
noWav.style.display = 'block';
audio.src = '';
}
await refreshStats();
}
// ── stats ─────────────────────────────────────────────────────────────────────
async function refreshStats() {
const res = await fetch('/api/info');
const data = await res.json();
document.getElementById('s-reviewed').textContent = data.reviewed;
document.getElementById('s-kept').textContent = data.kept;
document.getElementById('s-deleted').textContent = data.deleted;
document.getElementById('s-total').textContent = data.total;
const pct = data.total ? (data.reviewed / data.total * 100) : 0;
document.getElementById('progress').style.width = pct + '%';
}
// ── decisions ─────────────────────────────────────────────────────────────────
async function decide(decision) {
await fetch(`/api/decide/${currentIdx}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({decision}),
});
// auto-advance on decide
navigate(1);
}
// ── navigation ────────────────────────────────────────────────────────────────
async function navigate(dir) {
audio.pause();
if (dir === 1) {
// find next with wav
const res = await fetch(`/api/next?after=${currentIdx}`);
const data = await res.json();
if (data.index >= 0) loadSentence(data.index);
} else {
if (currentIdx > 0) loadSentence(currentIdx - 1);
}
}
// ── export ────────────────────────────────────────────────────────────────────
async function doExport() {
const res = await fetch('/api/export', {method: 'POST'});
const data = await res.json();
const el = document.getElementById('export-result');
el.textContent = `✓ exported ${data.kept} sentences. missing wavs: ${data.missing.length}`;
}
// ── cleanup ───────────────────────────────────────────────────────────────────
async function doCleanup() {
if (!confirm('Move all deleted wavs to deleted/ subfolder?')) return;
const res = await fetch('/api/cleanup', {method: 'POST'});
const data = await res.json();
const el = document.getElementById('export-result');
el.textContent = `⊗ moved ${data.moved} wavs to deleted/. errors: ${data.errors.length}`;
el.style.color = 'var(--delete)';
}
async function doResetDeleted() {
if (!confirm('Reset all deleted entries? This will clear them from the qa-state.json so they become unreviewed again.')) return;
const res = await fetch('/api/reset_deleted', {method: 'POST'});
const data = await res.json();
const el = document.getElementById('export-result');
el.style.color = 'var(--text)';
el.textContent = `↺ reset ${data.cleared} entries — ${data.lines_removed} lines removed from txt, state cleaned`;
await refreshStats();
loadSentence(currentIdx);
}
// ── keyboard ──────────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'k') decide('keep');
else if (e.key === 'd') decide('delete');
else if (e.key === 'ArrowRight') navigate(1);
else if (e.key === 'ArrowLeft') navigate(-1);
else if (e.key === ' ') { e.preventDefault(); togglePlay(); }
});
// ── init ──────────────────────────────────────────────────────────────────────
(async () => {
const res = await fetch('/api/next?after=-1');
const data = await res.json();
const start = data.index >= 0 ? data.index : 0;
loadSentence(start);
})();
</script>
</body>
</html>"""
@app.post("/api/reset_deleted")
def reset_deleted():
"""clear all delete decisions from the qa-state.json and remove corresponding lines from the txt file."""
deleted_indices = get_deleted_indices()
if deleted_indices:
# Remove lines from txt file by index (descending to preserve indices)
sentences = load_sentences()
for idx in sorted(deleted_indices, reverse=True):
if idx < len(sentences):
del sentences[idx]
TXT_FILE.write_text("\n".join(sentences) + "\n" if sentences else "", encoding="utf-8")
# Clear all delete entries from state
state = load_state()
cleared = []
for idx_str, decision in list(state.items()):
if decision == "delete":
del state[idx_str]
cleared.append(int(idx_str))
save_state(state)
return {"cleared": len(cleared), "lines_removed": len(deleted_indices), "indices": deleted_indices}
@app.post("/api/cleanup")
def cleanup():
"""move deleted wavs to deleted/ subfolder."""
state = load_state()
deleted_dir = WAV_DIR / "deleted"
deleted_dir.mkdir(exist_ok=True)
moved = []
errors = []
for idx_str, decision in state.items():
if decision != "delete":
continue
wav = wav_for(int(idx_str))
if wav is None:
continue
dest = deleted_dir / wav.name
try:
shutil.move(str(wav), str(dest))
moved.append(int(idx_str))
except Exception as e:
errors.append({"idx": int(idx_str), "error": str(e)})
return {"moved": len(moved), "errors": errors, "dest": str(deleted_dir)}
@app.get("/", response_class=HTMLResponse)
def ui():
return HTML
# ── run ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print(f"[*] wavs: {WAV_DIR}")
print(f"[*] txt: {TXT_FILE}")
print(f"[*] state: {STATE_FILE}")
print(f"[*] open http://localhost:{args.port}")
uvicorn.run(app, host="0.0.0.0", port=args.port, log_level="debug")