1 line
77 KiB
JSON
1 line
77 KiB
JSON
{"repo": "5uck1ess/tts-bench", "files": {"report.py": {"e965ff34baa1dcb356925d7854eb6f19337e1cd9": {"t": 1785674302.3937914, "n": 72546, "h": "9cbf20b2388b3795", "c": "\"\"\"Generate a self-contained HTML report from a bench.py results directory.\n\nShows TTFA cold/warm + RTFx cold/warm per (model, device, prompt), with an\ninline <audio> player for the cold run of each cell so you can click-play\neach wav in the browser without leaving the page.\n\nFeatures:\n- dark/light theme toggle (persisted to localStorage)\n- sortable columns (click any header — cycles asc / desc / unsorted)\n- live text filter across all visible rows\n\nAll inline — no external CSS, JS, or fonts; works offline.\n\nAlso builds results/index.html — a top-level page listing every dated run.\n\nUsage:\n python report.py results/2026-05-23_1409 # one report\n python report.py results/2026-05-23_1409 --open # ... and open it\n python report.py --index # results/index.html only\n python report.py --all # regenerate everything\n\"\"\"\n\nimport argparse\nimport csv\nimport json\nimport sys\nimport webbrowser\nfrom collections import defaultdict\nfrom html import escape\nfrom pathlib import Path\n\nREPO = Path(__file__).parent\nRESULTS = REPO / \"results\"\n\n# NAQ left the harness — it's now private lab-only R&D (see naq_lab/, gitignored).\n# The bench no longer computes it and results.csv carries no naq columns, so the\n# report has no quality lens / NAQ column. All NAQ scoring happens post-hoc in\n# naq_lab over the saved wavs.\n\ntry:\n from bench import PROMPTS as _BENCH_PROMPTS\n PROMPT_INFO = {str(pid): (lang, text) for pid, lang, text in _BENCH_PROMPTS}\nexcept Exception:\n PROMPT_INFO = {}\n\n\nSTYLE = \"\"\"<style>\n :root {\n color-scheme: dark light;\n --bg: #1a1a1a;\n --panel: #242424;\n --text: #e0e0e0;\n --muted: #888;\n --accent: #6cf;\n --num: #cfc;\n --fail: #f88;\n --border: #333;\n --row-hover: #2a2a2a;\n --th-bg: #2c2c2c;\n --th-hover: #353535;\n --input-bg: #2a2a2a;\n --input-border: #444;\n --controls-border: #2a2a2a;\n --dev-cpu: #ddd;\n --dev-cuda: #9cf;\n --dev-mps: #fc9;\n --lang: #6cf;\n --prompt-text: #9c9;\n --code-bg: #2a2a2a;\n }\n [data-theme=\"light\"] {\n color-scheme: light;\n --bg: #f7f8fa;\n --panel: #ffffff;\n --text: #1a1d22;\n --muted: #6b7280;\n --accent: #1a73e8;\n --num: #137333;\n --fail: #c5221f;\n --border: #e2e6eb;\n --row-hover: #f0f4f8;\n --th-bg: #eef1f4;\n --th-hover: #e2e6eb;\n --input-bg: #ffffff;\n --input-border: #cdd3da;\n --controls-border: #e2e6eb;\n --dev-cpu: #555;\n --dev-cuda: #1a73e8;\n --dev-mps: #c2410c;\n --lang: #1a73e8;\n --prompt-text: #2d6a4f;\n --code-bg: #eef1f4;\n }\n * { box-sizing: border-box; }\n body { font-family: ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n background: var(--bg); color: var(--text);\n margin: 0; padding: 1.5rem 2rem 3rem;\n max-width: 1400px; margin-inline: auto;\n transition: background 0.15s, color 0.15s; }\n h1, h2 { color: var(--text); margin-top: 0; }\n h1 { font-size: 1.5em; }\n h2 { font-size: 1.05em; }\n .meta { color: var(--muted); font-size: 0.88em; margin-bottom: 0.4rem; }\n .prompt { background: var(--panel); padding: 1rem 1.2rem;\n border-radius: 10px; margin-bottom: 1.4rem;\n border: 1px solid var(--border); }\n .prompt-text { color: var(--prompt-text); font-style: italic;\n display: block; margin: 0.2rem 0 0.8rem 0; }\n .lang { color: var(--lang); font-style: normal; margin-right: 0.4rem;\n font-weight: 500; }\n table { border-collapse: collapse; width: 100%; font-size: 0.92em; }\n th, td { padding: 7px 10px; text-align: left;\n border-bottom: 1px solid var(--border); }\n th { background: var(--th-bg); color: var(--text);\n cursor: pointer; user-select: none; white-space: nowrap;\n font-weight: 600; }\n th:hover { background: var(--th-hover); }\n td.num { text-align: right; font-variant-numeric: tabular-nums;\n color: var(--num); }\n th.num { text-align: right; }\n td.fail { color: var(--fail); font-style: italic; }\n td.dev-cuda { color: var(--dev-cuda); }\n td.dev-mps { color: var(--dev-mps); }\n td.dev-cpu { color: var(--dev-cpu); }\n .muted { color: var(--muted); }\n audio { width: 220px; height: 30px; vertical-align: middle; }\n tr:hover td { background: var(--row-hover); }\n code { background: var(--code-bg); padding: 1px 6px; border-radius: 3px;\n font-size: 0.92em; }\n a { color: var(--accent); text-decoration: none; }\n a:hover { text-decoration: underline; }\n .nav { margin-bottom: 0.6rem; }\n .controls { position: sticky; top: 0; background: var(--bg); z-index: 10;\n padding: 0.7rem 0 0.7rem; margin-bottom: 1rem;\n display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap;\n border-bottom: 1px solid var(--controls-border); }\n .controls input { background: var(--input-bg); color: var(--text);\n border: 1px solid var(--input-border);\n border-radius: 6px; padding: 7px 11px; font: inherit;\n min-width: 320px; }\n .controls input:focus { outline: none; border-color: var(--accent); }\n .controls .hint { color: var(--muted); font-size: 0.85em; }\n .controls button { background: var(--input-bg); color: var(--text);\n border: 1px solid var(--input-border);\n border-radius: 6px; padding: 6px 12px; font: inherit;\n cursor: pointer; transition: border-color 0.15s; }\n .controls button:hover { border-color: var(--accent); }\n .spacer { flex: 1; }\n\n /* Lens picker grid */\n .lens-grid { display: grid; grid-template-columns: repeat(3, 1fr);\n gap: 1rem; margin: 1.5rem 0; }\n @media (max-width: 760px) { .lens-grid { grid-template-columns: 1fr; } }\n .lens-card { background: var(--panel); border: 1px solid var(--border);\n border-radius: 10px; padding: 1.2rem 1.4rem;\n transition: border-color 0.15s; }\n .lens-card:hover { border-color: var(--accent); }\n .lens-card a { display: block; text-decoration: none; color: var(--text); }\n .lens-card h3 { margin: 0 0 0.4rem 0; font-size: 1.1em; color: var(--accent); }\n .lens-card .desc { color: var(--muted); font-size: 0.9em; }\n\n /* TLDR callout used by speed.html */\n .tldr { background: var(--panel); border-left: 3px solid var(--accent);\n padding: 0.8rem 1.1rem; margin: 1rem 0 1.4rem; border-radius: 4px; }\n .tldr h2 { margin: 0 0 0.4rem 0; font-size: 1em; }\n .tldr p { margin: 0.2rem 0; color: var(--text); }\n .tldr strong { color: var(--accent); }\n\n /* Secondary-axis \"badge\" pill cells */\n td.pill { font-size: 0.82em; color: var(--muted); }\n td.pill[data-sort] { font-variant-numeric: tabular-nums; }\n td.pill a { color: inherit; }\n\n /* Prompt jumper for samples.html */\n .prompt-jumper { position: sticky; top: 3rem; background: var(--bg);\n padding: 0.4rem 0; margin-bottom: 0.8rem;\n border-bottom: 1px solid var(--border); font-size: 0.9em; }\n .prompt-jumper a { margin-right: 0.6rem; }\n\n /* Lens-to-lens nav tabs at top of each lens page */\n .lens-tabs { display: inline-flex; gap: 0.3rem; margin-right: 0.6rem;\n align-items: center; }\n .lens-tab { display: inline-block; padding: 4px 12px; border-radius: 6px;\n border: 1px solid var(--border); color: var(--text);\n text-decoration: none; font-size: 0.92em;\n transition: border-color 0.15s, background 0.15s; }\n .lens-tab:hover { border-color: var(--accent); text-decoration: none; }\n .lens-tab.active { background: var(--accent); color: var(--bg);\n border-color: var(--accent); }\n .lens-arrow { display: inline-block; padding: 4px 10px; border-radius: 6px;\n border: 1px solid var(--border); color: var(--muted);\n text-decoration: none; font-size: 0.92em;\n transition: border-color 0.15s, color 0.15s; }\n .lens-arrow:hover { border-color: var(--accent); color: var(--accent);\n text-decoration: none; }\n .lens-arrow-disabled { opacity: 0.35; cursor: default;\n border-style: dashed; }\n .lens-arrow-disabled:hover { border-color: var(--border); color: var(--muted); }\n\n /* \"Reading this report\" explainer above tables */\n .reading-guide { background: var(--panel); border-left: 3px solid var(--muted);\n padding: 0.55rem 0.9rem; margin: 0.7rem 0 1rem;\n border-radius: 4px; font-size: 0.88em; color: var(--text); }\n .reading-guide strong { color: var(--accent); }\n .reading-guide a { color: var(--accent); }\n</style>\"\"\"\n\n\nCONTROLS = '''<div class=\"controls\">\n<input id=\"filter\" type=\"search\" placeholder=\"filter rows (model, device, value)…\" autocomplete=\"off\">\n<button type=\"button\" id=\"reset-sort\">reset sort</button>\n<span class=\"hint\">click any column header to sort</span>\n<span class=\"spacer\"></span>\n<button type=\"button\" id=\"theme-toggle\" title=\"Toggle theme\">☾ dark</button>\n</div>'''\n\n\nSCRIPT = r'''<script>\n(function(){\n // ---------- theme toggle ----------\n const themeBtn = document.getElementById('theme-toggle');\n function applyTheme(name) {\n document.documentElement.setAttribute('data-theme', name);\n themeBtn.textContent = name === 'light' ? '☀ light' : '☾ dark';\n try { localStorage.setItem('tts-bench-theme', name); } catch (e) {}\n }\n let stored = null;\n try { stored = localStorage.getItem('tts-bench-theme'); } catch (e) {}\n applyTheme(stored === 'light' ? 'light' : 'dark');\n themeBtn.addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'dark';\n applyTheme(cur === 'dark' ? 'light' : 'dark');\n });\n\n // ---------- sort + filter ----------\n const tables = document.querySelectorAll('table');\n const filterInput = document.getElementById('filter');\n const resetBtn = document.getElementById('reset-sort');\n const sortState = { col: -1, dir: 0 };\n\n tables.forEach(table => {\n table.querySelectorAll('thead th').forEach(th => { th.dataset.label = th.textContent; });\n table.querySelectorAll('tbody tr').forEach((row, i) => { row.dataset.origIdx = String(i); });\n });\n\n function applyFilter() {\n const q = filterInput.value.toLowerCase().trim();\n tables.forEach(t => {\n t.querySelectorAll('tbody tr').forEach(row => {\n const text = row.textContent.toLowerCase();\n row.style.display = (!q || text.includes(q)) ? '' : 'none';\n });\n });\n }\n filterInput.addEventListener('input', applyFilter);\n\n function cellSortValue(row, colIdx) {\n const cell = row.children[colIdx];\n if (!cell) return Number.POSITIVE_INFINITY;\n if (cell.hasAttribute('data-sort')) {\n const raw = cell.getAttribute('data-sort');\n if (raw === '' || raw === null) return Number.POSITIVE_INFINITY;\n const v = parseFloat(raw);\n return Number.isNaN(v) ? Number.POSITIVE_INFINITY : v;\n }\n return cell.textContent.toLowerCase();\n }\n\n function renderArrows() {\n tables.forEach(t => {\n t.querySelectorAll('thead th').forEach((th, i) => {\n const arrow = (i === sortState.col && sortState.dir !== 0)\n ? (sortState.dir === 1 ? ' ▲' : ' ▼') : '';\n th.textContent = th.dataset.label + arrow;\n });\n });\n }\n\n function restoreOrder() {\n tables.forEach(table => {\n const tbody = table.querySelector('tbody');\n const rows = Array.from(tbody.querySelectorAll('tr'));\n rows.sort((a, b) => parseInt(a.dataset.origIdx) - parseInt(b.dataset.origIdx));\n rows.forEach(r => tbody.appendChild(r));\n });\n }\n\n function sortAll(colIdx, explicitDir) {\n if (explicitDir === 1 || explicitDir === -1) {\n sortState.col = colIdx;\n sortState.dir = explicitDir;\n } else if (sortState.col === colIdx) {\n sortState.dir = sortState.dir === 1 ? -1 : (sortState.dir === -1 ? 0 : 1);\n } else {\n sortState.col = colIdx;\n sortState.dir = 1;\n }\n renderArrows();\n\n if (sortState.dir === 0) {\n restoreOrder();\n return;\n }\n\n const dir = sortState.dir;\n tables.forEach(table => {\n const tbody = table.querySelector('tbody');\n const rows = Array.from(tbody.querySelectorAll('tr'));\n rows.sort((a, b) => {\n const aSpan = a.querySelector('td[colspan]') !== null;\n const bSpan = b.querySelector('td[colspan]') !== null;\n if (aSpan !== bSpan) return aSpan ? 1 : -1;\n const av = cellSortValue(a, colIdx);\n const bv = cellSortValue(b, colIdx);\n if (av < bv) return -dir;\n if (av > bv) return dir;\n return 0;\n });\n rows.forEach(r => tbody.appendChild(r));\n });\n }\n\n tables.forEach(table => {\n table.querySelectorAll('thead th').forEach((th, idx) => {\n th.addEventListener('click', () => sortAll(idx));\n });\n });\n\n resetBtn.addEventListener('click', () => {\n sortState.col = -1; sortState.dir = 0;\n renderArrows();\n restoreOrder();\n filterInput.value = '';\n applyFilter();\n });\n\n // Page-load default sort: pages can set window.__defaultSort = {colIdx, dir}\n // (set in a tiny inline <script> earlier in the page).\n if (window.__defaultSort &&\n typeof window.__defaultSort.colIdx === 'number' &&\n (window.__defaultSort.dir === 1 || window.__defaultSort.dir === -1)) {\n sortAll(window.__defaultSort.colIdx, window.__defaultSort.dir);\n }\n})();\n</script>'''\n\n\ndef _fmt_ttfa(ms):\n if ms is None:\n return \"—\"\n if ms >= 1000:\n return f\"{ms/1000:.2f}s\"\n return f\"{ms:.0f}ms\"\n\n\ndef _fmt_rtf(x):\n if x is None:\n return \"—\"\n return f\"{x:.2f}×\"\n\n\ndef _fmt_mb(x):\n \"\"\"Format MiB compactly: <1000 MB shows as 'NNN MB', >=1024 as 'X.XX GB'.\"\"\"\n if x is None:\n return \"—\"\n if x >= 1024:\n return f\"{x/1024:.2f} GB\"\n return f\"{x:.0f} MB\"\n\n\ndef _humanize_error(err):\n \"\"\"Turn a raw runner error into a reader-friendly reason so a failed cell\n doesn't read like a hardware fault. An OOM means the model is too big for\n this device's memory, not that the rig broke.\"\"\"\n e = (err or \"\").strip()\n low = e.lower()\n if not e or e == \"no successful run\":\n return \"no successful run\"\n # \"invalid buffer size\" is how MPS reports an allocation it can't satisfy —\n # an out-of-memory by another name.\n if \"outofmemory\" in low or \"out of memory\" in low or \"invalid buffer size\" in low:\n if \"cuda\" in low or \"gpu\" in low:\n return \"Skipped — out of GPU memory (model exceeds this GPU's VRAM)\"\n return \"Skipped — out of memory (model exceeds available RAM)\"\n if \"timeout\" in low and \"s\" in low:\n # \"timeout 600s\" → 600\n import re\n m = re.search(r\"timeout\\s+(\\d+)s\", low)\n if m:\n secs = int(m.group(1))\n return f\"Timed out after {secs // 60} min — model too slow at this prompt length\"\n return \"Timed out — model too slow at this prompt length\"\n if \"torchcodec\" in low or \"libtorchcodec\" in low:\n return \"Audio loader needs FFmpeg DLLs (not present on this platform)\"\n if \"flash_attn\" in low and (\"not installed\" in low or \"no module\" in low):\n return \"Needs flash_attn (no wheel for this Python/torch/OS combo)\"\n if \"no module named\" in low and \"luxtts\" in low or \"no module named 'zipvoice'\" in low:\n return \"LuxTTS install failed (piper-phonemize has no Windows wheels)\"\n if \"no module named\" in low:\n import re\n m = re.search(r\"no module named ['\\\"]?([^'\\\"\\s]+)['\\\"]?\", low)\n if m:\n return f\"Missing dependency '{m.group(1)}' — install incomplete on this platform\"\n return \"Missing dependency — install incomplete on this platform\"\n if \"repo id must use alphanumeric\" in low:\n return \"transformers/Windows repo-id path bug (workaround pending)\"\n if \"mutable default\" in low and \"override\" in low:\n return \"hydra-core/omegaconf too old for Python 3.11 (deps need bump)\"\n if (\"typeerror\" in low and \"'nonetype' object cannot be interpreted as an integer\" in low):\n return \"Upstream config field missing — dep version mismatch\"\n if \"cuda error\" in low or \"cuda runtime\" in low:\n return \"CUDA runtime error — likely driver/version mismatch\"\n if \"cuda requested but not available\" in low:\n return \"CUDA not available on this rig\"\n if \"mps requested but not available\" in low:\n return \"MPS not available on this rig\"\n if \"cuda only\" in low or \"cuda-only\" in low:\n return \"Model is GPU-only — CPU/MPS not supported\"\n if \"reference\" in low and (\"not found\" in low or \"missing\" in low):\n return \"Reference wav missing for voice cloning\"\n # Fallback: surface the first line, trimmed.\n return f\"Failed: {e.splitlines()[0][:140]}\"\n\n\n# Display sizes for the Size column. Numbers are the model's weight count\n# (params), not weights-on-disk. Hand-curated — keep in sync with README.\n# Display name shown in the rendered HTML for each venv-keyed model.\n# CSV column still uses the raw venv key; this is presentation-only.\n# Keys not present here fall through to the raw key (Title-cased by convention).\nMODEL_DISPLAY_NAMES = {\n \"sesame\": \"Sesame CSM-1B\",\n \"miso\": \"Miso TTS 8B\",\n \"longcat_1b\": \"LongCat-AudioDiT 1B\",\n \"longcat_3p5b\": \"LongCat-AudioDiT 3.5B\",\n \"orpheus\": \"Orpheus-TTS 3B\",\n \"cosyvoice\": \"CosyVoice 3 0.5B\",\n \"lfm2_audio\": \"LFM2.5-Audio 1.5B\",\n \"miotts_01b\": \"MioTTS 0.1B\",\n \"miotts_06b\": \"MioTTS 0.6B\",\n \"wavtts\": \"WavTTS 0.67B\",\n \"coqui\": \"Coqui XTTS-v2\",\n \"vibevoice\": \"VibeVoice Realtime 0.5B\",\n \"vibevoice_15b\": \"VibeVoice 1.5B\",\n \"qwentts\": \"Qwen3-TTS 1.7B Base\",\n \"qwentts_06b\": \"Qwen3-TTS 0.6B Base\",\n \"qwentts_06b_custom\": \"Qwen3-TTS 0.6B CustomVoice\",\n \"qwentts_fast\": \"Qwen3-TTS 1.7B (CUDA-graph)\",\n \"neutts_air\": \"NeuTTS Air\",\n \"neutts_nano\": \"NeuTTS Nano\",\n \"voxcpm\": \"VoxCPM2 2B\",\n \"mars5\": \"Mars5-TTS\",\n \"pocket\": \"Pocket-TTS\",\n \"magpie\": \"Magpie-TTS\",\n \"indextts\": \"IndexTTS-2\",\n \"f5tts\": \"F5-TTS v1\",\n \"chatterbox\": \"Chatterbox\",\n \"chatterbox_turbo\": \"Chatterbox Turbo\",\n \"dia\": \"Dia 1.6B-0626\",\n \"omnivoice\": \"OmniVoice\",\n \"zipvoice\": \"ZipVoice 123M\",\n \"piper\": \"Piper\",\n \"kokoro\": \"Kokoro\",\n \"kokoro_mlx\": \"Kokoro (MLX)\",\n \"kittentts\": \"KittenTTS Nano 0.1\",\n \"scyllasband\": \"Scylla's Band\",\n \"inflect_nano\": \"Inflect-Nano v2\",\n \"inflect_micro\": \"Inflect-Micro v2\",\n \"soprano\": \"Soprano 1.1 80M\",\n \"moss_tts_nano\": \"MOSS-TTS-Nano\",\n \"moss_tts\": \"MOSS-TTS v1.0\",\n \"moss_tts_v15\": \"MOSS-TTS v1.5\",\n \"supertonic\": \"Supertonic 3\",\n \"luxtts\": \"LuxTTS\",\n \"maya1\": \"Maya1\",\n \"voxtral\": \"Voxtral 4B TTS\",\n \"fish_15\": \"Fish Speech 1.5\",\n \"fish_s2\": \"Fish Speech S2-Pro\",\n \"zonos\": \"Zonos v0.1\",\n \"zonos2\": \"Zonos2\",\n \"openvoice\": \"OpenVoice v2\",\n \"styletts2\": \"StyleTTS 2\",\n \"vibevoice_7b\": \"VibeVoice 7B\",\n \"metavoice\": \"MetaVoice-1B\",\n \"step_editx\": \"Step-Audio-EditX\",\n \"echo\": \"Echo-TTS\",\n \"miratts\": \"MiraTTS\",\n \"outetts\": \"OuteTTS 1.0 1B\",\n \"parler\": \"Parler-TTS Mini v1\",\n \"melotts\": \"MeloTTS\",\n \"higgs_v3\": \"Higgs Audio v3 TTS\",\n \"dramabox\": \"DramaBox\",\n \"dots_tts\": \"dots.tts (soar)\",\n}\n\n\ndef _display_name(model):\n \"\"\"Return display name for a model venv key; falls back to raw key.\"\"\"\n return MODEL_DISPLAY_NAMES.get(model, model)\n\n\nMODEL_SIZE = {\n \"pocket\": \"100M\",\n \"neutts_air\": \"748M\",\n \"neutts_nano\": \"229M\",\n \"luxtts\": \"123M\",\n \"chatterbox\": \"1.2B\",\n \"chatterbox_turbo\": \"744M\",\n \"f5tts\": \"330M\",\n \"coqui\": \"750M\",\n \"vibevoice\": \"0.5B\",\n \"vibevoice_15b\": \"1.5B\",\n \"omnivoice\": \"~1B\",\n \"zipvoice\": \"123M\",\n \"voxcpm\": \"2B\",\n \"magpie\": \"357M\",\n \"qwentts\": \"1.7B\",\n \"qwentts_06b\": \"0.6B\",\n \"qwentts_06b_custom\": \"0.6B\",\n \"qwentts_fast\": \"1.7B\",\n \"indextts\": \"1.5B\",\n \"sesame\": \"1B\",\n \"miso\": \"8.2B\",\n \"longcat_1b\": \"1.42B\",\n \"longcat_3p5b\": \"3.83B\",\n \"orpheus\": \"3.3B\", # Llama-3.2-3B backbone + SNAC decoder\n \"cosyvoice\": \"0.5B\", # Fun-CosyVoice3-0.5B-2512\n \"lfm2_audio\": \"1.5B\", # 1.2B LFM2.5 LM + 115M FastConformer encoder\n \"miotts_01b\": \"0.1B\", # Falcon-H1-Tiny-Multilingual-100M backbone\n \"miotts_06b\": \"0.6B\", # Qwen3-0.6B-Base backbone\n \"wavtts\": \"0.67B\", # WavTTS_Large DiT (1152x28), 673M from checkpoint\n \"mars5\": \"1.2B\",\n \"dia\": \"1.6B\",\n \"kokoro\": \"82M\",\n \"kokoro_mlx\": \"82M\",\n \"kittentts\": \"<100M\",\n \"piper\": \"~25MB\",\n \"scyllasband\": \"~103M\",\n # Complete text-to-waveform totals from the model cards (the integrated 24 kHz\n # decoder is inside these numbers, not bolted on as a separate vocoder).\n \"inflect_nano\": \"3.96M\",\n \"inflect_micro\": \"9.36M\",\n \"soprano\": \"80M\",\n \"moss_tts_nano\": \"100M\",\n \"moss_tts\": \"8B\",\n \"moss_tts_v15\": \"8B\",\n \"supertonic\": \"99M\",\n \"maya1\": \"3B\",\n \"voxtral\": \"4B\",\n \"fish_15\": \"~500M\",\n \"fish_s2\": \"4B\",\n \"zonos\": \"1.6B\",\n \"zonos2\": \"8B (MoE, ~900M active)\",\n \"openvoice\": \"~100M\",\n \"styletts2\": \"~148M\",\n \"vibevoice_7b\": \"7B\",\n \"metavoice\": \"1.2B\",\n \"step_editx\": \"3B\",\n \"echo\": \"2.8B\", # DiT generative model (safetensors count); +695M S1-DAC codec not counted (matches fish_15 convention)\n \"miratts\": \"0.5B\", # HF card \"Model size 0.5B params\" (BF16); FastBiCodec + FlashSR not counted\n \"outetts\": \"1B\", # Llama-3.2-1B backbone\n \"parler\": \"878M\", # parler-tts-mini-v1 safetensors total; large variant = 2.33B\n \"melotts\": \"~52M\", # MeloTTS-English checkpoint.pth (~208 MB fp32 / 4)\n \"higgs_v3\": \"4B\", # Qwen3 ~4B backbone (HF card); Higgs audio tokenizer not counted\n \"dramabox\": \"3.3B\", # LTX-2.3 audio-only DiT (IC-LoRA merged); 12B 4-bit text encoder not counted\n \"dots_tts\": \"2B\", # semantic encoder + LLM + flow-matching acoustic head (soar/SCA checkpoint)\n}\n\n# Canonical URL of the EXACT checkpoint each runner loads (HF model card, or the\n# GitHub repo when that's the pinned source). Extracted from runners/*.py and the\n# installed venv packages — keep in sync with the README model tables, which carry\n# the same links. Consumed by arena/build_manifest.py for the post-vote reveal.\n_HF = \"https://huggingface.co/\"\nMODEL_URL = {\n \"pocket\": \"https://github.com/kyutai-labs/pocket-tts\",\n \"neutts_air\": _HF + \"neuphonic/neutts-air\",\n \"neutts_nano\": _HF + \"neuphonic/neutts-nano-q4-gguf\",\n \"luxtts\": \"https://github.com/ysharma3501/LuxTTS\",\n \"chatterbox\": _HF + \"ResembleAI/chatterbox\",\n \"chatterbox_turbo\": _HF + \"ResembleAI/chatterbox-turbo\",\n \"f5tts\": _HF + \"SWivid/F5-TTS\",\n \"coqui\": _HF + \"coqui/XTTS-v2\",\n \"vibevoice\": _HF + \"microsoft/VibeVoice-Realtime-0.5B\",\n \"vibevoice_15b\": _HF + \"microsoft/VibeVoice-1.5B\",\n \"vibevoice_7b\": _HF + \"vibevoice/VibeVoice-7B\",\n \"omnivoice\": _HF + \"k2-fsa/OmniVoice\",\n \"zipvoice\": _HF + \"k2-fsa/ZipVoice\",\n \"voxcpm\": _HF + \"openbmb/VoxCPM2\",\n \"magpie\": _HF + \"nvidia/magpie_tts_multilingual_357m\",\n \"qwentts\": _HF + \"Qwen/Qwen3-TTS-12Hz-1.7B-Base\",\n \"qwentts_06b\": _HF + \"Qwen/Qwen3-TTS-12Hz-0.6B-Base\",\n \"qwentts_06b_custom\": _HF + \"Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice\",\n \"qwentts_fast\": _HF + \"Qwen/Qwen3-TTS-12Hz-1.7B-Base\",\n \"indextts\": _HF + \"IndexTeam/IndexTTS-2\",\n \"sesame\": _HF + \"sesame/csm-1b\",\n \"miso\": _HF + \"MisoLabs/MisoTTS\",\n \"longcat_1b\": _HF + \"meituan-longcat/LongCat-AudioDiT-1B\",\n \"longcat_3p5b\": _HF + \"meituan-longcat/LongCat-AudioDiT-3.5B\",\n \"orpheus\": _HF + \"canopylabs/orpheus-3b-0.1-ft\",\n \"cosyvoice\": _HF + \"FunAudioLLM/Fun-CosyVoice3-0.5B-2512\",\n \"lfm2_audio\": _HF + \"LiquidAI/LFM2.5-Audio-1.5B\",\n \"miotts_01b\": _HF + \"Aratako/MioTTS-0.1B\",\n \"miotts_06b\": _HF + \"Aratako/MioTTS-0.6B\",\n \"mars5\": _HF + \"Camb-ai/mars5-tts\",\n \"dia\": _HF + \"nari-labs/Dia-1.6B-0626\",\n \"kokoro\": _HF + \"hexgrad/Kokoro-82M\",\n \"kokoro_mlx\": _HF + \"prince-canuma/Kokoro-82M\",\n \"kittentts\": _HF + \"KittenML/kitten-tts-nano-0.1\",\n \"piper\": \"https://github.com/OHF-Voice/piper1-gpl\",\n \"scyllasband\": _HF + \"spybyscript/scyllasband\",\n \"inflect_nano\": _HF + \"owensong/Inflect-Nano-v2\",\n \"inflect_micro\": _HF + \"owensong/Inflect-Micro-v2\",\n \"soprano\": _HF + \"ekwek/Soprano-1.1-80M\",\n \"moss_tts_nano\": _HF + \"OpenMOSS-Team/MOSS-TTS-Nano\",\n \"moss_tts\": _HF + \"OpenMOSS-Team/MOSS-TTS\",\n \"moss_tts_v15\": _HF + \"OpenMOSS-Team/MOSS-TTS-v1.5\",\n \"supertonic\": _HF + \"Supertone/supertonic-3\",\n \"maya1\": _HF + \"maya-research/maya1\",\n \"voxtral\": _HF + \"mistralai/Voxtral-4B-TTS-2603\",\n \"fish_15\": _HF + \"fishaudio/fish-speech-1.5\",\n \"fish_s2\": _HF + \"fishaudio/s2-pro\",\n \"echo\": _HF + \"jordand/echo-tts-base\",\n \"zonos\": _HF + \"Zyphra/Zonos-v0.1-transformer\",\n \"zonos2\": _HF + \"Zyphra/Zonos2\",\n \"openvoice\": _HF + \"myshell-ai/OpenVoiceV2\",\n \"styletts2\": \"https://github.com/yl4579/StyleTTS2\",\n \"metavoice\": _HF + \"metavoiceio/metavoice-1B-v0.1\",\n \"step_editx\": _HF + \"stepfun-ai/Step-Audio-EditX\",\n \"miratts\": _HF + \"YatharthS/MiraTTS\",\n \"outetts\": _HF + \"OuteAI/Llama-OuteTTS-1.0-1B\",\n \"parler\": _HF + \"parler-tts/parler-tts-mini-v1\",\n \"melotts\": _HF + \"myshell-ai/MeloTTS-English\",\n \"higgs_v3\": _HF + \"bosonai/higgs-audio-v3-tts-4b\",\n \"dramabox\": \"https://github.com/resemble-ai/DramaBox\",\n \"dots_tts\": _HF + \"rednote-hilab/dots.tts-soar\",\n \"wavtts\": \"https://github.com/cwx-worst-one/WavTTS\",\n}\n\n# Whether a model supports zero-shot voice cloning at runtime.\n# \"predefined\": fixed speaker(s) / preset voices only.\n# \"cloning\": accepts a reference wav (or wav+text) at inference and matches that voice.\nMODEL_KIND = {\n \"piper\": \"predefined\",\n \"kokoro\": \"predefined\",\n \"kokoro_mlx\": \"predefined\",\n \"kittentts\": \"predefined\",\n \"magpie\": \"predefined\",\n \"vibevoice\": \"predefined\",\n \"vibevoice_15b\": \"cloning\", # no preset voice — clones from a reference wav\n \"scyllasband\": \"predefined\",\n \"inflect_nano\": \"predefined\", # one fixed synthetic voice, no wav cloning\n \"inflect_micro\": \"predefined\",\n \"soprano\": \"predefined\",\n \"supertonic\": \"predefined\",\n \"luxtts\": \"predefined\",\n \"pocket\": \"cloning\",\n \"chatterbox\": \"cloning\",\n \"chatterbox_turbo\": \"cloning\",\n \"f5tts\": \"cloning\",\n \"indextts\": \"cloning\",\n \"omnivoice\": \"cloning\",\n \"zipvoice\": \"cloning\",\n \"voxcpm\": \"cloning\",\n \"coqui\": \"cloning\",\n \"qwentts\": \"cloning\",\n \"qwentts_06b\": \"cloning\",\n \"qwentts_06b_custom\": \"predefined\",\n \"qwentts_fast\": \"cloning\",\n \"sesame\": \"cloning\",\n \"miso\": \"cloning\",\n \"longcat_1b\": \"cloning\",\n \"longcat_3p5b\": \"cloning\",\n \"orpheus\": \"predefined\", # named preset voices (tara, ...), no wav cloning\n \"cosyvoice\": \"cloning\",\n \"lfm2_audio\": \"predefined\", # 4 preset voices (US/UK x m/f) via system prompt; no wav cloning\n \"miotts_01b\": \"cloning\", # zero-shot clone; no-reference run = bundled chris clip\n \"miotts_06b\": \"cloning\",\n \"mars5\": \"cloning\",\n \"dia\": \"cloning\",\n \"neutts_air\": \"cloning\",\n \"neutts_nano\": \"cloning\",\n \"moss_tts_nano\": \"cloning\",\n \"moss_tts\": \"cloning\",\n \"moss_tts_v15\": \"cloning\",\n \"maya1\": \"predefined\", # voice-description preset, no wav cloning\n \"voxtral\": \"cloning\", # cuda/vLLM path clones; MLX path preset-only\n \"fish_15\": \"cloning\",\n \"fish_s2\": \"cloning\",\n \"zonos\": \"cloning\",\n \"zonos2\": \"cloning\", # no preset voice — zero-shot clones from a reference wav\n \"openvoice\": \"cloning\",\n \"styletts2\": \"cloning\",\n \"vibevoice_7b\": \"cloning\",\n \"metavoice\": \"cloning\",\n \"step_editx\": \"cloning\",\n \"echo\": \"cloning\", # no preset voice — zero-shot clones from a reference wav\n \"miratts\": \"cloning\", # no preset voice — zero-shot clones from a reference wav\n \"outetts\": \"cloning\", # clones from a reference wav; ALSO has preset voices (default lens)\n \"parler\": \"predefined\", # voice set by a text description, no wav cloning\n \"melotts\": \"predefined\", # VITS preset speakers (EN-US), no wav cloning\n \"higgs_v3\": \"cloning\", # zero-shot cloning; no-reference run = its own default voice\n \"dramabox\": \"cloning\", # 10s+ wav cloning; no-reference run = prompt-described voice\n \"dots_tts\": \"cloning\", # zero-shot cloning (+ ref transcript); no-reference run = bundled chris clip\n \"wavtts\": \"cloning\", # no preset voice — zero-shot clones from ref wav + sibling .txt\n}\n\n\n# Release month (YYYY-MM) per model. Sourced programmatically from the canonical\n# checkpoint repo's creation date — HF `createdAt` / GitHub `created_at` — which is\n# the first public appearance of the weights, a faithful release proxy at month\n# granularity. Variants sharing a checkpoint share its date (qwentts/qwentts_fast).\n# NOTE: piper is dated to the ORIGINAL rhasspy/piper engine (2023-01); MODEL_URL\n# points at the later piper1-gpl GPL re-home (2025), which would misdate a classic.\nMODEL_RELEASE = {\n \"pocket\": \"2026-01\",\n \"neutts_air\": \"2025-09\",\n \"neutts_nano\": \"2025-12\",\n \"luxtts\": \"2026-01\",\n \"chatterbox\": \"2025-04\",\n \"chatterbox_turbo\": \"2025-12\",\n \"f5tts\": \"2024-10\",\n \"coqui\": \"2023-10\",\n \"vibevoice\": \"2025-12\",\n \"vibevoice_15b\": \"2025-08\",\n \"vibevoice_7b\": \"2025-09\",\n \"omnivoice\": \"2026-03\",\n \"zipvoice\": \"2025-06\",\n \"voxcpm\": \"2026-04\",\n \"magpie\": \"2025-12\",\n \"qwentts\": \"2026-01\",\n \"qwentts_06b\": \"2026-01\",\n \"qwentts_06b_custom\": \"2026-01\",\n \"qwentts_fast\": \"2026-01\",\n \"indextts\": \"2025-06\",\n \"sesame\": \"2025-03\",\n \"miso\": \"2026-05\",\n \"longcat_1b\": \"2026-03\",\n \"longcat_3p5b\": \"2026-03\",\n \"orpheus\": \"2025-03\",\n \"cosyvoice\": \"2025-12\",\n \"lfm2_audio\": \"2025-12\",\n \"miotts_01b\": \"2026-02\",\n \"miotts_06b\": \"2026-02\",\n \"mars5\": \"2024-06\",\n \"dia\": \"2025-06\",\n \"kokoro\": \"2024-12\",\n \"kokoro_mlx\": \"2024-12\", # same Kokoro-82M checkpoint (MLX backend) — shares its date\n \"kittentts\": \"2025-08\",\n \"piper\": \"2023-01\", # original rhasspy/piper engine, not the piper1-gpl re-home\n \"scyllasband\": \"2026-07\",\n \"inflect_nano\": \"2026-07\", # v2.0.0 2026-07-24 (v1 was 2026-06)\n \"inflect_micro\": \"2026-07\",\n \"soprano\": \"2026-01\",\n \"moss_tts_nano\": \"2026-04\",\n \"moss_tts\": \"2026-02\",\n \"moss_tts_v15\": \"2026-05\",\n \"supertonic\": \"2026-05\",\n \"maya1\": \"2025-10\",\n \"voxtral\": \"2025-11\",\n \"fish_15\": \"2024-11\",\n \"fish_s2\": \"2026-03\",\n \"echo\": \"2025-12\",\n \"zonos\": \"2025-02\",\n \"zonos2\": \"2026-06\",\n \"openvoice\": \"2024-04\",\n \"styletts2\": \"2023-06\",\n \"metavoice\": \"2024-02\",\n \"step_editx\": \"2025-10\",\n \"miratts\": \"2025-12\",\n \"outetts\": \"2025-04\",\n \"parler\": \"2024-06\",\n \"melotts\": \"2024-02\",\n \"higgs_v3\": \"2026-06\",\n \"dramabox\": \"2026-04\",\n \"dots_tts\": \"2026-06\",\n \"wavtts\": \"2026-05\",\n}\n\n_MONTHS = (\"\", \"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\",\n \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\")\n\n\ndef _release_label(model):\n \"\"\"Human label for the Released column, e.g. 'May 2026'; '' when unknown.\"\"\"\n ym = MODEL_RELEASE.get(model)\n if not ym:\n return \"\"\n y, m = ym.split(\"-\")\n return f\"{_MONTHS[int(m)]} {y}\"\n\n\ndef _release_sort(model):\n \"\"\"Numeric YYYYMM for the data-sort attr (parseFloat-friendly, chronological);\n None when unknown so the cell sorts to the bottom like other missing values.\"\"\"\n ym = MODEL_RELEASE.get(model)\n if not ym:\n return None\n y, m = ym.split(\"-\")\n return int(y) * 100 + int(m)\n\n\ndef _release_td(model, cls=\"muted\"):\n \"\"\"A sortable Released table cell: 'May 2026' display, numeric YYYYMM data-sort.\"\"\"\n s = _release_sort(model)\n ds = f' data-sort=\"{s}\"' if s is not None else ' data-sort=\"\"'\n return f'<td class=\"{cls}\"{ds}>{escape(_release_label(model) or \"—\")}</td>'\n\n\n# ---------------------------------------------------------------------------\n# Capability matrix data — the structured source behind the Capabilities page.\n# Lifted from the curated README model tables (single values, kept in sync with\n# them). License / SR / Expressive / Langs mirror the README cells verbatim; the\n# README stays hand-maintained for now (single-sourcing it from these dicts is a\n# deferred follow-up). MODEL_CROSSLINGUAL is curated separately (see below).\n# ---------------------------------------------------------------------------\n\n# Output sample rate, as shown in the README (e.g. \"44.1k\").\nMODEL_SR = {\n \"kittentts\": \"24k\", \"kokoro\": \"24k\", \"kokoro_mlx\": \"24k\", \"lfm2_audio\": \"24k\", \"luxtts\": \"22.05k\",\n \"magpie\": \"22.05k\", \"maya1\": \"24k\", \"melotts\": \"44.1k\", \"orpheus\": \"24k\",\n \"outetts\": \"44.1k\", \"parler\": \"44.1k\", \"piper\": \"22.05k\", \"scyllasband\": \"24k\", \"soprano\": \"32k\",\n \"inflect_nano\": \"24k\", \"inflect_micro\": \"24k\",\n \"supertonic\": \"24k\", \"vibevoice\": \"24k\", \"voxtral\": \"24k\", \"chatterbox\": \"24k\",\n \"chatterbox_turbo\": \"24k\", \"coqui\": \"24k\", \"cosyvoice\": \"24k\", \"dia\": \"44.1k\",\n \"dots_tts\": \"48k\", \"dramabox\": \"48k\", \"echo\": \"44.1k\", \"f5tts\": \"24k\",\n \"fish_15\": \"44.1k\", \"fish_s2\": \"44.1k\", \"higgs_v3\": \"24k\", \"indextts\": \"24k\",\n \"longcat_1b\": \"24k\", \"longcat_3p5b\": \"24k\", \"mars5\": \"24k\", \"metavoice\": \"48k\",\n \"miotts_01b\": \"44.1k\", \"miotts_06b\": \"44.1k\", \"miratts\": \"48k\", \"miso\": \"24k\",\n \"moss_tts\": \"24k\", \"moss_tts_v15\": \"24k\", \"moss_tts_nano\": \"48k\",\n \"neutts_air\": \"24k\", \"neutts_nano\": \"24k\", \"omnivoice\": \"24k\", \"openvoice\": \"22.05k\",\n \"pocket\": \"24k\", \"qwentts\": \"24k\", \"qwentts_06b\": \"24k\", \"qwentts_06b_custom\": \"24k\",\n \"qwentts_fast\": \"24k\", \"sesame\": \"24k\",\n \"step_editx\": \"24k\", \"styletts2\": \"24k\", \"vibevoice_15b\": \"24k\", \"vibevoice_7b\": \"24k\",\n \"voxcpm\": \"48k\", \"wavtts\": \"16k\", \"zipvoice\": \"24k\", \"zonos\": \"44.1k\", \"zonos2\": \"44.1k\",\n}\n\n# Explicit emotion/delivery control offered (tags / desc / knob / emo-ref / —);\n# \"*\" = a caveat applies (see docs/expressive-control.md). \"—\" = none.\nMODEL_EXPRESSIVE = {\n \"kittentts\": \"—\", \"kokoro\": \"—\", \"kokoro_mlx\": \"—\", \"lfm2_audio\": \"—\", \"luxtts\": \"—\",\n \"magpie\": \"emotion voices*\", \"maya1\": \"tags + desc\", \"melotts\": \"—\", \"orpheus\": \"tags\",\n \"outetts\": \"—\", \"parler\": \"desc*\", \"piper\": \"—\", \"scyllasband\": \"6 knobs\",\n # speed + variation (latent noise scale). No emotion/style control.\n \"inflect_nano\": \"2 knobs\", \"inflect_micro\": \"2 knobs\",\n \"soprano\": \"—\", \"supertonic\": \"tags\",\n \"vibevoice\": \"—\", \"voxtral\": \"—\", \"chatterbox\": \"knob\", \"chatterbox_turbo\": \"tags*\",\n \"coqui\": \"—\", \"cosyvoice\": \"desc\", \"dia\": \"tags\", \"dots_tts\": \"—\", \"dramabox\": \"desc\",\n \"echo\": \"tags\", \"f5tts\": \"—\", \"fish_15\": \"—\", \"fish_s2\": \"tags\", \"higgs_v3\": \"tags\",\n \"indextts\": \"emo-ref + desc + knob\", \"longcat_1b\": \"—\", \"longcat_3p5b\": \"—\", \"mars5\": \"—\",\n \"metavoice\": \"—\", \"miotts_01b\": \"—\", \"miotts_06b\": \"—\", \"miratts\": \"knob\", \"miso\": \"—\",\n \"moss_tts\": \"—\", \"moss_tts_v15\": \"tags (pause)\", \"moss_tts_nano\": \"—\", \"neutts_air\": \"—\",\n \"neutts_nano\": \"—\", \"omnivoice\": \"tags*\", \"openvoice\": \"knob\", \"pocket\": \"—\",\n \"qwentts\": \"—\", \"qwentts_06b\": \"—\",\n # CustomVoice instruct control is unverified pending a runtime check (card/table conflict).\n \"qwentts_06b_custom\": \"—\", \"qwentts_fast\": \"—\", \"sesame\": \"—\", \"step_editx\": \"tags + desc\",\n \"styletts2\": \"knob\", \"vibevoice_15b\": \"—\", \"vibevoice_7b\": \"—\", \"voxcpm\": \"desc\",\n \"wavtts\": \"—\", \"zipvoice\": \"—\", \"zonos\": \"emo-ref + knob\", \"zonos2\": \"knob\",\n}\n\n# License string as shown in the README (the precise truth; the commercial-OK\n# filter is a coarse heuristic derived from it — see _is_commercial).\nMODEL_LICENSE = {\n \"kittentts\": \"Apache 2.0\", \"kokoro\": \"Apache 2.0\", \"kokoro_mlx\": \"Apache 2.0\",\n \"lfm2_audio\": \"LFM Open v1.0\",\n \"luxtts\": \"MIT\", \"magpie\": \"NVIDIA OML\", \"maya1\": \"Apache 2.0\", \"melotts\": \"MIT\",\n \"orpheus\": \"Apache 2.0\", \"outetts\": \"CC-BY-NC-SA 4.0 + Llama 3.2\", \"parler\": \"Apache 2.0\",\n \"piper\": \"GPL-3.0\", \"scyllasband\": \"Apache 2.0\", \"soprano\": \"Apache 2.0\",\n \"inflect_nano\": \"Apache 2.0\", \"inflect_micro\": \"Apache 2.0\",\n \"supertonic\": \"MIT + OpenRAIL-M\",\n \"vibevoice\": \"MIT\", \"voxtral\": \"CC-BY-NC 4.0\", \"chatterbox\": \"MIT\",\n \"chatterbox_turbo\": \"MIT\", \"coqui\": \"CPML (non-commercial)\", \"cosyvoice\": \"Apache 2.0\",\n \"dia\": \"Apache 2.0\", \"dots_tts\": \"Apache 2.0\", \"dramabox\": \"LTX-2 Community (NC)\",\n \"echo\": \"CC-BY-NC-SA 4.0\", \"f5tts\": \"CC-BY-NC\", \"fish_15\": \"CC-BY-NC-SA 4.0\",\n \"fish_s2\": \"Research (non-commercial)\", \"higgs_v3\": \"Research (NC)\", \"indextts\": \"Apache 2.0\",\n \"longcat_1b\": \"MIT\", \"longcat_3p5b\": \"MIT\", \"mars5\": \"AGPL-3.0\", \"metavoice\": \"Apache 2.0\",\n \"miotts_01b\": \"Falcon-LLM\", \"miotts_06b\": \"Apache 2.0\", \"miratts\": \"MIT\", \"miso\": \"Modified MIT\",\n \"moss_tts\": \"Apache 2.0\", \"moss_tts_v15\": \"Apache 2.0\", \"moss_tts_nano\": \"Apache 2.0\",\n \"neutts_air\": \"Apache 2.0\", \"neutts_nano\": \"Apache 2.0\",\n \"omnivoice\": \"Apache 2.0 code / CC-BY-NC weights\",\n \"openvoice\": \"MIT\", \"pocket\": \"Apache 2.0\", \"qwentts\": \"Apache 2.0\",\n \"qwentts_06b\": \"Apache 2.0\", \"qwentts_06b_custom\": \"Apache 2.0\", \"qwentts_fast\": \"MIT\",\n \"sesame\": \"Apache 2.0\", \"step_editx\": \"Apache 2.0\", \"styletts2\": \"MIT\", \"vibevoice_15b\": \"MIT\",\n \"vibevoice_7b\": \"MIT\", \"voxcpm\": \"Apache 2.0\", \"wavtts\": \"MIT code / CC-BY-NC 4.0 weights\",\n \"zipvoice\": \"Apache 2.0\", \"zonos\": \"Apache 2.0\", \"zonos2\": \"Apache 2.0\",\n}\n\n# Language coverage cell from the README: \"✓\" multilingual, \"✓ (N)\"/\"✓ (zh+en)\"\n# with detail, \"—\"/\"— (en)\" English-only. _is_multilingual keys off the leading ✓.\nMODEL_LANGS = {\n \"kittentts\": \"—\", \"kokoro\": \"✓\", \"kokoro_mlx\": \"✓\", \"lfm2_audio\": \"— (en)\", \"luxtts\": \"—\",\n \"magpie\": \"✓ (9)\", \"maya1\": \"—\", \"melotts\": \"— (en)\", \"orpheus\": \"— (en)\",\n \"outetts\": \"✓ (12)\", \"parler\": \"—\", \"piper\": \"✓\", \"scyllasband\": \"✓ (4)\",\n \"inflect_nano\": \"— (en)\", \"inflect_micro\": \"— (en)\",\n \"soprano\": \"—\",\n \"supertonic\": \"✓ (31)\", \"vibevoice\": \"—\", \"voxtral\": \"✓\", \"chatterbox\": \"—\",\n \"chatterbox_turbo\": \"—\", \"coqui\": \"✓ (17)\", \"cosyvoice\": \"✓\", \"dia\": \"—\",\n \"dots_tts\": \"✓ (24)\", \"dramabox\": \"— (en)\", \"echo\": \"—\", \"f5tts\": \"✓\",\n \"fish_15\": \"✓\", \"fish_s2\": \"—\", \"higgs_v3\": \"✓ (100)\", \"indextts\": \"✓\",\n \"longcat_1b\": \"✓ (zh+en)\", \"longcat_3p5b\": \"✓ (zh+en)\", \"mars5\": \"—\", \"metavoice\": \"—\",\n \"miotts_01b\": \"✓ (en+ja)\", \"miotts_06b\": \"✓ (en+ja)\", \"miratts\": \"—\", \"miso\": \"— (en)\",\n \"moss_tts\": \"✓ (20)\", \"moss_tts_v15\": \"✓ (31)\", \"moss_tts_nano\": \"✓ (zh+en)\",\n \"neutts_air\": \"—\", \"neutts_nano\": \"—\", \"omnivoice\": \"✓ (600+)\", \"openvoice\": \"✓\",\n \"pocket\": \"—\", \"qwentts\": \"✓\", \"qwentts_06b\": \"✓\", \"qwentts_06b_custom\": \"✓\",\n \"qwentts_fast\": \"✓\", \"sesame\": \"—\", \"step_editx\": \"—\",\n \"styletts2\": \"—\", \"vibevoice_15b\": \"—\", \"vibevoice_7b\": \"—\", \"voxcpm\": \"✓ (30)\",\n \"wavtts\": \"✓ (zh+en)\", \"zipvoice\": \"✓ (zh+en)\", \"zonos\": \"✓\", \"zonos2\": \"—\",\n}\n\n# --- Registry-drift guard -------------------------------------------------\n# Every model must appear in ALL capability registries: a missing key silently\n# renders \"—\" cells on Capabilities and mislabels licenses via _is_commercial.\n# Fails at import time (publish.py imports this module) so drift can't ship.\n_REGISTRIES = {\n \"MODEL_DISPLAY_NAMES\": MODEL_DISPLAY_NAMES, \"MODEL_SIZE\": MODEL_SIZE,\n \"MODEL_URL\": MODEL_URL, \"MODEL_KIND\": MODEL_KIND, \"MODEL_RELEASE\": MODEL_RELEASE,\n \"MODEL_SR\": MODEL_SR, \"MODEL_EXPRESSIVE\": MODEL_EXPRESSIVE,\n \"MODEL_LICENSE\": MODEL_LICENSE, \"MODEL_LANGS\": MODEL_LANGS,\n}\n# The expected set MUST include harness.MODELS, not just the union of the\n# registries. A model absent from EVERY registry contributes nothing to that\n# union, so it was invisible to this check — the guard caught partial\n# registration but not zero registration. `scyllasband` shipped to gh-pages\n# exactly that way (2026-07-20): it rendered as the raw slug with empty\n# size/kind/license/SR/language cells, and nothing failed. harness.MODELS is\n# the real source of truth for \"what models exist\", so fold it in.\nimport harness as _harness # stdlib-only at module level; no import cost, no cycle\n\n_HARNESS_MODELS = {_m[0] for _m in _harness.MODELS}\n_ALL_REGISTRY_MODELS = set().union(\n *(d.keys() for d in _REGISTRIES.values())) | _HARNESS_MODELS\n_REGISTRY_DRIFT = {name: sorted(_ALL_REGISTRY_MODELS - d.keys())\n for name, d in _REGISTRIES.items() if _ALL_REGISTRY_MODELS - d.keys()}\nif _REGISTRY_DRIFT:\n raise AssertionError(\n \"model registry drift — these registries are missing models that \"\n \"harness.MODELS or another registry declares. Every model in \"\n \"harness.MODELS must appear in ALL registries, or it renders on the \"\n f\"board as a raw slug with empty capability cells: {_REGISTRY_DRIFT}\")\n\n\n# Tokens in a license string that signal NON-commercial / research-only use.\n_NONCOMMERCIAL_TOKENS = (\"NC\", \"non-commercial\", \"Non-commercial\", \"CPML\", \"Research\")\n\n\ndef _is_multilingual(model):\n \"\"\"True if the model synthesizes more than English (README ✓ in the langs cell).\"\"\"\n return MODEL_LANGS.get(model, \"\").startswith(\"✓\")\n\n\ndef _is_commercial(model):\n \"\"\"Coarse 'commercial use permitted' flag derived from the license string.\n The Capabilities table always shows the exact license too — this only drives\n the convenience filter, so it errs toward the literal NC/Research signal.\"\"\"\n lic = MODEL_LICENSE.get(model, \"\")\n return bool(lic) and not any(tok in lic for tok in _NONCOMMERCIAL_TOKENS)\n\n\ndef _sr_hz(model):\n \"\"\"Numeric sample rate in Hz from the 'NN.Nk' README cell, for sortable cells.\"\"\"\n s = MODEL_SR.get(model, \"\")\n try:\n return int(float(s.rstrip(\"k\")) * 1000) if s.endswith(\"k\") else None\n except ValueError:\n return None\n\n\n# Cross-lingual voice cloning: clones a voice from a reference in one language and\n# speaks a DIFFERENT one in that same voice. CURATED, not derived — each entry was\n# verified against the model's card/paper (2026-06-30). A multilingual cloner is NOT\n# automatically cross-lingual: longcat (1B/3.5B), miotts (0.1B/0.6B), and voxtral are\n# multilingual cloners documented to clone only within a single language, so they are\n# omitted. Multilingual cloners with no documented cross-lingual transfer are also\n# left out (fish_15, moss_tts_nano, voxcpm, wavtts, zipvoice) — flagged only on\n# positive evidence. Everything not listed (incl. all English-only cloners) is False.\nMODEL_CROSSLINGUAL = {\n \"coqui\", \"cosyvoice\", \"dots_tts\", \"f5tts\", \"higgs_v3\", \"indextts\", \"moss_tts\",\n \"moss_tts_v15\", \"omnivoice\", \"openvoice\", \"outetts\", \"qwentts\", \"qwentts_06b\",\n \"qwentts_fast\", \"zonos\",\n}\n\n\ndef _is_crosslingual(model):\n \"\"\"True if the model can clone a voice and then speak a different language in it\n (curated per-model — see MODEL_CROSSLINGUAL).\"\"\"\n return model in MODEL_CROSSLINGUAL\n\n\ndef _ds(val):\n \"\"\"data-sort attribute for numeric cells; empty when None.\"\"\"\n return f' data-sort=\"{val}\"' if val is not None else ' data-sort=\"\"'\n\n\ndef _read_meta(run_dir):\n \"\"\"Read meta.json from a results dir; return None if missing or invalid.\"\"\"\n p = run_dir / \"meta.json\"\n if not p.exists():\n return None\n try:\n return json.loads(p.read_text(encoding=\"utf-8\"))\n except (OSError, json.JSONDecodeError):\n return None\n\n\ndef _rig_summary(meta):\n \"\"\"One-line human summary like 'AMD Ryzen 9 9950X3D · RTX 5090 32GB · 128 GB RAM · Windows 11'.\"\"\"\n if not meta:\n return \"\"\n parts = []\n if meta.get(\"cpu\"):\n cpu = meta[\"cpu\"]\n if meta.get(\"cpu_cores_physical\"):\n cpu = f\"{cpu} ({meta['cpu_cores_physical']}C)\"\n parts.append(cpu)\n if meta.get(\"gpu\"):\n gpu = meta[\"gpu\"]\n if meta.get(\"gpu_vram_gb\"):\n gpu = f\"{gpu} {meta['gpu_vram_gb']}GB\"\n parts.append(gpu)\n if meta.get(\"ram_gb\"):\n parts.append(f\"{meta['ram_gb']} GB RAM\")\n if meta.get(\"os\"):\n parts.append(meta[\"os\"])\n return \" · \".join(parts)\n\n\ndef _read_csv(csv_path):\n rows = []\n with csv_path.open(encoding=\"utf-8\") as f:\n for r in csv.DictReader(f):\n for k in (\"ttfa_ms\", \"gen_s\", \"audio_s\", \"rtf\",\n \"peak_mem_mb\", \"peak_vram_mb\", \"wall_s\"):\n v = r.get(k)\n r[k] = float(v) if v not in (None, \"\") else None\n r[\"run_index\"] = int(r.get(\"run_index\") or 0)\n r[\"is_cold\"] = str(r.get(\"is_cold\")).lower() == \"true\"\n r[\"ok\"] = str(r.get(\"ok\")).lower() == \"true\"\n rows.append(r)\n return rows\n\n\ndef _sort_prompt_ids(ids):\n return sorted(ids, key=lambda s: int(s) if str(s).isdigit() else 1_000_000)\n\n\ndef _build_context(rows, run_dir, meta):\n \"\"\"Pre-compute everything the four lens renderers need from a CSV row list.\n\n Returns a dict with:\n per_cell: {(prompt_id, model, device) -> {\"cold\": row|None, \"warms\": [row], \"fail\": row|None}}\n Note: only the FIRST failure row per cell is retained in \"fail\".\n Subsequent failures for the same cell are dropped.\n per_model: {(model, device) -> aggregated dict with avg TTFA/RTF, peak mem/vram,\n n_ok, n_fail, n_total}\n per_prompt: {prompt_id -> [{model, device, ttfa_warm, rtf_warm, wav, wav_exists}\n ranked by warm TTFA asc (fastest first), successful-cold rows only]}\n tldr_speed: {\"predefined\": (model, device, rtf_warm, ttfa_warm) | None,\n \"cloning\": (model, device, rtf_warm, ttfa_warm) | None}\n prompts_seen, models_seen, devices_seen, runs_per_cell, meta, run_name, run_dir\n \"\"\"\n cells = defaultdict(lambda: {\"cold\": None, \"warms\": [], \"fail\": None})\n for r in rows:\n key = (r[\"prompt_id\"], r[\"model\"], r[\"device\"])\n if not r[\"ok\"]:\n if cells[key][\"fail\"] is None:\n cells[key][\"fail\"] = r\n elif r[\"is_cold\"]:\n cells[key][\"cold\"] = r\n else:\n cells[key][\"warms\"].append(r)\n\n prompts_seen = _sort_prompt_ids({r[\"prompt_id\"] for r in rows})\n models_seen = sorted({r[\"model\"] for r in rows})\n devices_seen = sorted({r[\"device\"] for r in rows})\n # Intentionally caps fail at 1 per cell (matches the first-failure-wins above)\n # so the \"N runs per cell\" badge reflects warm-run count, not failure noise.\n runs_per_cell = max(\n (1 if c[\"cold\"] else 0) + len(c[\"warms\"]) + (1 if c[\"fail\"] else 0)\n for c in cells.values()\n )\n\n def _rtf(r):\n a, g = r.get(\"audio_s\"), r.get(\"gen_s\")\n return (a / g) if (a and g) else None\n\n per_model = {}\n for (pid, model, dev), c in cells.items():\n key = (model, dev)\n per_model.setdefault(key, {\n \"ttfa_cold\": [], \"ttfa_warm\": [], \"rtf_cold\": [], \"rtf_warm\": [],\n \"peak_mem\": [], \"peak_vram\": [],\n \"ok_prompts\": set(), \"fail_prompts\": set(), \"all_prompts\": set(),\n })\n bucket = per_model[key]\n bucket[\"all_prompts\"].add(pid)\n if c[\"cold\"]:\n bucket[\"ok_prompts\"].add(pid)\n bucket[\"ttfa_cold\"].append(c[\"cold\"][\"ttfa_ms\"])\n rc = _rtf(c[\"cold\"])\n if rc is not None:\n bucket[\"rtf_cold\"].append(rc)\n if c[\"cold\"].get(\"peak_mem_mb\") is not None:\n bucket[\"peak_mem\"].append(c[\"cold\"][\"peak_mem_mb\"])\n if c[\"cold\"].get(\"peak_vram_mb\") is not None:\n bucket[\"peak_vram\"].append(c[\"cold\"][\"peak_vram_mb\"])\n if c[\"warms\"]:\n bucket[\"ttfa_warm\"].extend(w[\"ttfa_ms\"] for w in c[\"warms\"])\n warm_rtfs = [v for v in (_rtf(w) for w in c[\"warms\"]) if v is not None]\n bucket[\"rtf_warm\"].extend(warm_rtfs)\n elif c[\"fail\"]:\n bucket[\"fail_prompts\"].add(pid)\n\n def _avg(xs):\n return (sum(xs) / len(xs)) if xs else None\n\n def _maxv(xs):\n return max(xs) if xs else None\n\n agg = {}\n for key, b in per_model.items():\n agg[key] = {\n \"ttfa_cold\": _avg(b[\"ttfa_cold\"]),\n \"ttfa_warm\": _avg(b[\"ttfa_warm\"]),\n \"rtf_cold\": _avg(b[\"rtf_cold\"]),\n \"rtf_warm\": _avg(b[\"rtf_warm\"]),\n \"peak_mem\": _maxv(b[\"peak_mem\"]),\n \"peak_vram\": _maxv(b[\"peak_vram\"]),\n \"n_ok\": len(b[\"ok_prompts\"]),\n \"n_fail\": len(b[\"fail_prompts\"]),\n \"n_total\": len(b[\"all_prompts\"]),\n }\n\n per_prompt = {}\n for pid in prompts_seen:\n items = []\n for (p2, model, dev), c in cells.items():\n if p2 != pid:\n continue\n if not c[\"cold\"]:\n continue\n cold = c[\"cold\"]\n warm_ttfas = [w[\"ttfa_ms\"] for w in c[\"warms\"]]\n ttfa_warm = (sum(warm_ttfas) / len(warm_ttfas)) if warm_ttfas else None\n warm_rtfs = [v for v in (_rtf(w) for w in c[\"warms\"]) if v is not None]\n rtf_warm = (sum(warm_rtfs) / len(warm_rtfs)) if warm_rtfs else None\n items.append({\n \"model\": model,\n \"device\": dev,\n \"ttfa_warm\": ttfa_warm,\n \"rtf_warm\": rtf_warm,\n \"wav\": f\"{model}_{dev}_p{pid}.wav\",\n \"wav_exists\": (run_dir / f\"{model}_{dev}_p{pid}.wav\").exists(),\n })\n # Sort by warm TTFA asc (fastest first); missing values sink to the bottom.\n items.sort(key=lambda it: (it[\"ttfa_warm\"] is None,\n it[\"ttfa_warm\"] or float(\"inf\")))\n per_prompt[pid] = items\n\n def _pick_best_speed(kind):\n best = None\n for (model, dev), a in agg.items():\n if MODEL_KIND.get(model) != kind:\n continue\n if a[\"rtf_warm\"] is None:\n continue\n if best is None or a[\"rtf_warm\"] > best[2]:\n best = (model, dev, a[\"rtf_warm\"], a[\"ttfa_warm\"])\n return best\n\n return {\n \"per_cell\": cells,\n \"per_model\": agg,\n \"per_prompt\": per_prompt,\n \"tldr_speed\": {\n \"predefined\": _pick_best_speed(\"predefined\"),\n \"cloning\": _pick_best_speed(\"cloning\"),\n },\n \"prompts_seen\": prompts_seen,\n \"models_seen\": models_seen,\n \"devices_seen\": devices_seen,\n \"runs_per_cell\": runs_per_cell,\n \"meta\": meta,\n \"run_name\": run_dir.name,\n \"run_dir\": run_dir,\n }\n\n\ndef _render_lens_picker(ctx):\n \"\"\"Render the per-rig landing card with three lens links.\"\"\"\n meta = ctx[\"meta\"]\n out = ['<!doctype html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\">',\n f'<title>TTS Bench — {escape(ctx[\"run_name\"])}</title>',\n STYLE,\n '</head><body>',\n CONTROLS,\n '<div class=\"nav\"><a href=\"../index.html\">← all runs</a></div>',\n f'<h1>TTS Bench — {escape(ctx[\"run_name\"])}</h1>']\n if meta:\n out.append(f'<div class=\"meta\"><strong>Rig:</strong> '\n f'<code>{escape(meta.get(\"rig\") or \"?\")}</code> — '\n f'{escape(_rig_summary(meta))}</div>')\n if meta.get(\"label\"):\n ref = meta.get(\"reference\")\n ref_html = f' — ref <code>{escape(ref)}</code>' if ref else \"\"\n out.append(f'<div class=\"meta\"><strong>Label:</strong> '\n f'{escape(meta[\"label\"])}{ref_html}</div>')\n n_models = len(ctx[\"models_seen\"])\n n_devices = len(ctx[\"devices_seen\"])\n n_prompts = len(ctx[\"prompts_seen\"])\n out.append(\n f'<div class=\"meta\">{n_models} model(s) · '\n f'{n_devices} device(s) · '\n f'{n_prompts} prompt(s) · '\n f'{ctx[\"runs_per_cell\"]} run(s) per cell</div>'\n )\n out.append('<div class=\"lens-grid\">')\n out.append(\n '<div class=\"lens-card\"><a href=\"speed.html\">'\n '<h3>▶ Speed</h3>'\n f'<div class=\"desc\">TTFA, RTFx, memory · {n_models} models · sortable</div>'\n '</a></div>'\n )\n out.append(\n '<div class=\"lens-card\"><a href=\"samples.html\">'\n '<h3>▶ Samples</h3>'\n '<div class=\"desc\">By-prompt gallery · A/B every model</div>'\n '</a></div>'\n )\n out.append('</div>')\n out.append('<div class=\"meta\">'\n '<a href=\"results.csv\">results.csv</a> · '\n '<a href=\"meta.json\">meta.json</a></div>')\n out.append(SCRIPT)\n out.append('</body></html>')\n return \"\\n\".join(out)\n\n\n_LENSES = ((\"speed\", \"Speed\"), (\"samples\", \"Samples\"))\n\n_READING_GUIDE = {\n \"speed\": (\n '<div class=\"reading-guide\">'\n '<strong>TTFA</strong> = time to first audio (ms; lower is better). '\n '<strong>RTFx</strong> = realtime speed (× realtime; higher is better; e.g. 10× means '\n '10 sec of audio generated per 1 sec of compute). '\n '<strong>Cold</strong> = first run after process start; <strong>warm</strong> = subsequent runs.'\n '</div>'\n ),\n \"samples\": (\n '<div class=\"reading-guide\">'\n 'Each prompt section shows every model\\'s audio output, ordered by '\n '<strong>warm TTFA</strong> (fastest first). '\n 'Click any audio player to hear that model\\'s rendering.'\n '</div>'\n ),\n}\n\n\ndef _lens_nav(active):\n \"\"\"Emit the nav strip with prev/next arrows + lens tabs + 'all runs' link.\n\n `active` is one of the slugs in _LENSES. Arrows are bounded — at the\n first lens the ← arrow renders disabled (no link); at the last lens\n the → arrow renders disabled. No wraparound, so the report tour ends\n at the natural edges instead of cycling infinitely.\n \"\"\"\n slugs = [s for s, _ in _LENSES]\n labels = {s: l for s, l in _LENSES}\n idx = slugs.index(active)\n prev_slug = slugs[idx - 1] if idx > 0 else None\n next_slug = slugs[idx + 1] if idx < len(slugs) - 1 else None\n\n parts = ['<div class=\"nav\"><span class=\"lens-tabs\">']\n if prev_slug is not None:\n parts.append(\n f'<a class=\"lens-arrow\" href=\"{prev_slug}.html\" '\n f'title=\"Previous lens: {labels[prev_slug]}\">← {labels[prev_slug]}</a>'\n )\n else:\n parts.append('<span class=\"lens-arrow lens-arrow-disabled\">← </span>')\n for slug, label in _LENSES:\n cls = \"lens-tab active\" if slug == active else \"lens-tab\"\n parts.append(f'<a class=\"{cls}\" href=\"{slug}.html\">{label}</a>')\n if next_slug is not None:\n parts.append(\n f'<a class=\"lens-arrow\" href=\"{next_slug}.html\" '\n f'title=\"Next lens: {labels[next_slug]}\">{labels[next_slug]} →</a>'\n )\n else:\n parts.append('<span class=\"lens-arrow lens-arrow-disabled\"> →</span>')\n parts.append('</span> · <a href=\"../index.html\">all runs</a></div>')\n return \"\".join(parts)\n\n\ndef _speed_table_html(ctx):\n \"\"\"Return (table_html, rtf_warm_col_idx) for one run's speed view.\n\n Just the <table> (aggregated per model/device, no audio) — shared by the\n per-rig speed.html and the cross-rig speed hub on the published landing.\n The caller decides where to place it and whether to set window.__defaultSort\n (rtf_warm_col_idx is returned so the caller can default-sort by warm RTF).\"\"\"\n cols = (\"Model\", \"Device\", \"TTFA cold\", \"TTFA warm\",\n \"RTFx cold\", \"RTFx warm\", \"Peak RAM\", \"Peak VRAM\", \"Size\", \"Released\")\n num_cols = {\"TTFA cold\", \"TTFA warm\", \"RTFx cold\", \"RTFx warm\",\n \"Peak RAM\", \"Peak VRAM\"}\n rtf_warm_idx = cols.index(\"RTFx warm\")\n out = ['<table><thead><tr>']\n for c in cols:\n cls = ' class=\"num\"' if c in num_cols else ''\n out.append(f'<th{cls}>{c}</th>')\n out.append('</tr></thead><tbody>')\n\n # Sort rows by RTFx warm desc for stable origIdx ordering\n keys_sorted = sorted(\n ctx[\"per_model\"].keys(),\n key=lambda k: (ctx[\"per_model\"][k][\"rtf_warm\"] is None,\n -(ctx[\"per_model\"][k][\"rtf_warm\"] or 0))\n )\n for (model, dev) in keys_sorted:\n a = ctx[\"per_model\"][(model, dev)]\n row_id = f\"speed-{model}-{dev}\".lower().replace(\"/\", \"-\")\n dev_class = f\"dev-{dev}\"\n size_str = MODEL_SIZE.get(model, \"—\")\n n_ok = a[\"n_ok\"]; n_total = a[\"n_total\"]\n partial = (n_ok < n_total) and n_ok > 0\n partial_tag = (f' <span class=\"muted\">({n_ok}/{n_total} ok)</span>'\n if partial else \"\")\n if n_ok == 0:\n err_row = next(\n (c[\"fail\"] for k, c in ctx[\"per_cell\"].items()\n if k[1] == model and k[2] == dev and c[\"fail\"]),\n None,\n )\n err = (err_row.get(\"error\") if err_row else \"\") or \"no successful run\"\n out.append(f'<tr id=\"{escape(row_id)}\">'\n f'<td>{escape(_display_name(model))}</td>'\n f'<td class=\"{dev_class}\">{escape(dev)}</td>'\n f'<td colspan=\"{len(cols)-2}\" class=\"fail\">{escape(_humanize_error(err))}</td>'\n '</tr>')\n continue\n out.append(f'<tr id=\"{escape(row_id)}\">')\n out.append(f'<td>{escape(_display_name(model))}{partial_tag}</td>'\n f'<td class=\"{dev_class}\">{escape(dev)}</td>')\n out.append(f'<td class=\"num\"{_ds(a[\"ttfa_cold\"])}>{_fmt_ttfa(a[\"ttfa_cold\"])}</td>')\n out.append(f'<td class=\"num\"{_ds(a[\"ttfa_warm\"])}>{_fmt_ttfa(a[\"ttfa_warm\"])}</td>')\n out.append(f'<td class=\"num\"{_ds(a[\"rtf_cold\"])}>{_fmt_rtf(a[\"rtf_cold\"])}</td>')\n out.append(f'<td class=\"num\"{_ds(a[\"rtf_warm\"])}>{_fmt_rtf(a[\"rtf_warm\"])}</td>')\n out.append(f'<td class=\"num\"{_ds(a[\"peak_mem\"])}>{_fmt_mb(a[\"peak_mem\"])}</td>')\n out.append(f'<td class=\"num\"{_ds(a[\"peak_vram\"])}>{_fmt_mb(a[\"peak_vram\"])}</td>')\n out.append(f'<td class=\"muted\">{escape(size_str)}</td>')\n out.append(_release_td(model))\n out.append('</tr>')\n out.append('</tbody></table>')\n return \"\".join(out), rtf_warm_idx\n\n\ndef _render_speed(ctx):\n \"\"\"Render speed.html — aggregated per (model, device), no audio.\"\"\"\n meta = ctx[\"meta\"]\n has_ref = bool((meta or {}).get(\"reference\"))\n out = ['<!doctype html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\">',\n f'<title>TTS Bench — Speed — {escape(ctx[\"run_name\"])}</title>',\n STYLE,\n '</head><body>',\n CONTROLS,\n _lens_nav(\"speed\"),\n f'<h1>TTS Bench — Speed — {escape(ctx[\"run_name\"])}</h1>']\n if meta:\n out.append(f'<div class=\"meta\"><strong>Rig:</strong> '\n f'<code>{escape(meta.get(\"rig\") or \"?\")}</code> — '\n f'{escape(_rig_summary(meta))}</div>')\n if meta.get(\"label\"):\n ref = meta.get(\"reference\")\n ref_html = f' — ref <code>{escape(ref)}</code>' if ref else \"\"\n out.append(f'<div class=\"meta\"><strong>Label:</strong> '\n f'{escape(meta[\"label\"])}{ref_html}</div>')\n\n out.append(_READING_GUIDE[\"speed\"])\n\n # TLDR\n out.append('<div class=\"tldr\"><h2>Speed winners</h2>')\n tldr = ctx[\"tldr_speed\"]\n def _fmt_tldr(entry, label):\n if not entry:\n return f'<p>{label}: <span class=\"muted\">no data</span></p>'\n model, dev, rtf, ttfa = entry\n return (f'<p>{label}: <strong>{escape(_display_name(model))}</strong> ({escape(dev)}) — '\n f'{_fmt_rtf(rtf)} warm RTFx, {_fmt_ttfa(ttfa)} warm TTFA</p>')\n if has_ref:\n # Cloning-only run; collapse to single line\n best = tldr[\"cloning\"] or tldr[\"predefined\"]\n out.append(_fmt_tldr(best, \"Fastest (this cloning run)\"))\n else:\n out.append(_fmt_tldr(tldr[\"predefined\"], \"Fastest predefined-voice\"))\n out.append(_fmt_tldr(tldr[\"cloning\"], \"Fastest cloning-capable\"))\n out.append('</div>')\n\n # Table (shared builder — same markup the cross-rig speed hub uses)\n table_html, rtf_warm_idx = _speed_table_html(ctx)\n out.append(table_html)\n\n # Default sort: RTFx warm desc\n out.append(f'<script>window.__defaultSort = {{colIdx: {rtf_warm_idx}, dir: -1}};</script>')\n out.append(SCRIPT)\n out.append('</body></html>')\n return \"\\n\".join(out)\n\n\ndef _render_samples(ctx):\n \"\"\"Render samples.html — by-prompt gallery, successful rows only, ordered by warm TTFA.\"\"\"\n meta = ctx[\"meta\"]\n prompts = ctx[\"prompts_seen\"]\n out = ['<!doctype html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\">',\n f'<title>TTS Bench — Samples — {escape(ctx[\"run_name\"])}</title>',\n STYLE,\n '</head><body>',\n CONTROLS,\n _lens_nav(\"samples\"),\n f'<h1>TTS Bench — Samples — {escape(ctx[\"run_name\"])}</h1>']\n if meta:\n out.append(f'<div class=\"meta\"><strong>Rig:</strong> '\n f'<code>{escape(meta.get(\"rig\") or \"?\")}</code> — '\n f'{escape(_rig_summary(meta))}</div>')\n if meta.get(\"label\"):\n ref = meta.get(\"reference\")\n ref_html = f' — ref <code>{escape(ref)}</code>' if ref else \"\"\n out.append(f'<div class=\"meta\"><strong>Label:</strong> '\n f'{escape(meta[\"label\"])}{ref_html}</div>')\n rank_basis = \"warm TTFA (fastest first)\"\n out.append(f'<div class=\"meta\">{len(prompts)} prompt(s) · '\n f'one section per prompt · all models ranked by {rank_basis} within each</div>')\n\n out.append(_READING_GUIDE[\"samples\"])\n\n # Reference-voice player for cloning runs: each model in the table below\n # is trying to imitate this voice, so the listener needs to hear it to\n # judge fidelity. publish.py copies the source wav into the slug dir as\n # `_reference.wav`; if absent (older runs / non-cloning runs) we skip.\n ref_meta = (meta or {}).get(\"reference\") if meta else None\n if ref_meta and (ctx[\"run_dir\"] / \"_reference.wav\").exists():\n out.append(\n '<div class=\"prompt\" style=\"border-left: 3px solid var(--accent);\">'\n '<h2>Reference voice</h2>'\n f'<div class=\"meta\">Each model below was given this clip + transcript as the voice to imitate. '\n f'Source: <code>{escape(ref_meta)}</code></div>'\n '<audio controls preload=\"metadata\" src=\"_reference.wav\" '\n 'style=\"width: 100%; max-width: 480px;\"></audio>'\n '</div>'\n )\n\n if len(prompts) > 3:\n out.append('<nav class=\"prompt-jumper\">Jump to: ')\n out.append(\" · \".join(f'<a href=\"#p{escape(pid)}\">P{escape(pid)}</a>' for pid in prompts))\n out.append('</nav>')\n\n cols = (\"Rank\", \"Model\", \"Device\", \"TTFA warm\", \"Audio\")\n num_cols = {\"Rank\", \"TTFA warm\"}\n for pid in prompts:\n # Samples only lists clips we can actually play. A successful row normally\n # ships its cold-run wav; a speed-only model (e.g. kokoro_mlx, benched for\n # timing but publishing no Mac clip) lands here with no wav — skip it rather\n # than render a dead \"missing\" row.\n items = [it for it in ctx[\"per_prompt\"].get(pid, []) if it.get(\"wav_exists\")]\n out.append(f'<div class=\"prompt\" id=\"p{escape(pid)}\"><h2>Prompt {escape(pid)}</h2>')\n ptext = PROMPT_INFO.get(pid)\n if ptext:\n lang, text = ptext\n out.append(f'<span class=\"prompt-text\"><span class=\"lang\">[{escape(lang)}]</span>'\n f'\"{escape(text)}\"</span>')\n if not items:\n out.append('<div class=\"meta\">No successful samples for this prompt.</div></div>')\n continue\n out.append('<table><thead><tr>')\n for c in cols:\n cls = ' class=\"num\"' if c in num_cols else ''\n out.append(f'<th{cls}>{c}</th>')\n out.append('</tr></thead><tbody>')\n for rank, it in enumerate(items, 1):\n row_id = f\"sample-p{pid}-{it['model']}-{it['device']}\".lower().replace(\"/\", \"-\")\n dev_class = f\"dev-{it['device']}\"\n audio_html = (\n f'<audio controls preload=\"none\" src=\"{escape(it[\"wav\"])}\"></audio>'\n if it[\"wav_exists\"] else '<span class=\"muted\">missing</span>'\n )\n out.append(f'<tr id=\"{escape(row_id)}\">')\n out.append(f'<td class=\"num\" data-sort=\"{rank}\">{rank}</td>')\n out.append(f'<td>{escape(_display_name(it[\"model\"]))}</td>')\n out.append(f'<td class=\"{dev_class}\">{escape(it[\"device\"])}</td>')\n out.append(f'<td class=\"num pill\"{_ds(it[\"ttfa_warm\"])}>{_fmt_ttfa(it[\"ttfa_warm\"])}</td>')\n out.append(f'<td>{audio_html}</td>')\n out.append('</tr>')\n out.append('</tbody></table></div>')\n\n out.append(SCRIPT)\n out.append('</body></html>')\n return \"\\n\".join(out)\n\n\ndef build_report(run_dir: Path) -> Path:\n \"\"\"Emit index.html + speed.html + samples.html from results.csv.\n Returns the path to index.html (the lens picker).\"\"\"\n csv_path = run_dir / \"results.csv\"\n if not csv_path.exists():\n raise SystemExit(f\"No results.csv in {run_dir}\")\n rows = _read_csv(csv_path)\n if not rows:\n raise SystemExit(f\"Empty results.csv in {run_dir}\")\n\n meta = _read_meta(run_dir)\n ctx = _build_context(rows, run_dir, meta)\n\n (run_dir / \"index.html\").write_text(_render_lens_picker(ctx), encoding=\"utf-8\")\n (run_dir / \"speed.html\").write_text(_render_speed(ctx), encoding=\"utf-8\")\n # NAQ left the harness — drop any quality.html left over from an older run.\n (run_dir / \"quality.html\").unlink(missing_ok=True)\n (run_dir / \"samples.html\").write_text(_render_samples(ctx), encoding=\"utf-8\")\n\n # Legacy report.html — emit a small redirect stub so any external link still works.\n legacy = run_dir / \"report.html\"\n legacy.write_text(\n '<!doctype html><html><head><meta charset=\"utf-8\">'\n '<meta http-equiv=\"refresh\" content=\"0; url=index.html\">'\n '<title>Redirecting…</title></head>'\n '<body><a href=\"index.html\">Lens picker</a></body></html>',\n encoding=\"utf-8\",\n )\n return run_dir / \"index.html\"\n\n\ndef build_index() -> Path:\n if not RESULTS.exists():\n raise SystemExit(f\"No results dir at {RESULTS}\")\n\n runs = []\n for d in sorted(RESULTS.iterdir(), reverse=True):\n if not d.is_dir():\n continue\n csv_path = d / \"results.csv\"\n if not csv_path.exists():\n continue\n try:\n rows = _read_csv(csv_path)\n except Exception:\n continue\n if not rows:\n continue\n meta = _read_meta(d)\n runs.append({\n \"name\": d.name,\n \"models\": sorted({r[\"model\"] for r in rows}),\n \"devices\": sorted({r[\"device\"] for r in rows}),\n \"prompts\": _sort_prompt_ids({r[\"prompt_id\"] for r in rows}),\n \"rows\": len(rows),\n \"ok\": sum(1 for r in rows if r[\"ok\"]),\n \"has_index\": (d / \"index.html\").exists(),\n \"rig\": (meta or {}).get(\"rig\"),\n \"rig_full\": _rig_summary(meta),\n \"label\": (meta or {}).get(\"label\"),\n })\n\n out = ['<!doctype html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\">',\n '<title>TTS Bench — All Runs</title>',\n STYLE,\n '</head><body>',\n CONTROLS,\n '<h1>TTS Bench — All Runs</h1>',\n f'<div class=\"meta\">{len(runs)} run(s) with <code>results.csv</code></div>',\n '<table><thead><tr>']\n num_cols = {\"Prompts\", \"Rows\", \"OK\"}\n for col in (\"Run\", \"Label\", \"Rig\", \"Models\", \"Devices\", \"Prompts\", \"Rows\", \"OK\", \"Report\"):\n cls = ' class=\"num\"' if col in num_cols else ''\n out.append(f'<th{cls}>{col}</th>')\n out.append('</tr></thead><tbody>')\n\n for r in runs:\n models = (\", \".join(r[\"models\"])\n if len(r[\"models\"]) <= 5\n else f\"{len(r['models'])} models\")\n if r[\"has_index\"]:\n parts = [f'<a href=\"{escape(r[\"name\"])}/speed.html\">speed</a>']\n parts.append(f'<a href=\"{escape(r[\"name\"])}/samples.html\">samples</a>')\n link = \" · \".join(parts)\n elif (RESULTS / r[\"name\"] / \"report.html\").exists():\n link = f'<a href=\"{escape(r[\"name\"])}/report.html\">view (legacy)</a>'\n else:\n link = '<span class=\"muted\">no report</span>'\n rig_cell = (f'<code title=\"{escape(r[\"rig_full\"])}\">{escape(r[\"rig\"])}</code>'\n if r[\"rig\"] else '<span class=\"muted\">—</span>')\n label_cell = (escape(r[\"label\"])\n if r[\"label\"]\n else '<span class=\"muted\">—</span>')\n out.append('<tr>')\n if r[\"has_index\"]:\n out.append(f\"<td><a href='{escape(r['name'])}/index.html'>{escape(r['name'])}</a></td>\")\n else:\n out.append(f\"<td>{escape(r['name'])}</td>\")\n out.append(f\"<td>{label_cell}</td>\")\n out.append(f\"<td>{rig_cell}</td>\")\n out.append(f\"<td{_ds(len(r['models']))}>{escape(models)}</td>\")\n out.append(f\"<td>{escape(', '.join(r['devices']))}</td>\")\n out.append(f\"<td class='num'{_ds(len(r['prompts']))}>{len(r['prompts'])}</td>\")\n out.append(f\"<td class='num'{_ds(r['rows'])}>{r['rows']}</td>\")\n out.append(f\"<td class='num'{_ds(r['ok'])}>{r['ok']}/{r['rows']}</td>\")\n out.append(f'<td>{link}</td>')\n out.append('</tr>')\n out.append('</tbody></table>')\n out.append(SCRIPT)\n out.append('</body></html>')\n\n index_path = RESULTS / \"index.html\"\n index_path.write_text(\"\\n\".join(out), encoding=\"utf-8\")\n return index_path\n\n\ndef main() -> int:\n p = argparse.ArgumentParser(description=\"Generate HTML reports from bench.py CSVs.\")\n p.add_argument(\"run_dir\", nargs=\"?\", help=\"Results subdir (e.g. results/2026-05-23_1409).\")\n p.add_argument(\"--index\", action=\"store_true\", help=\"Build only results/index.html.\")\n p.add_argument(\"--all\", action=\"store_true\", help=\"Regenerate report.html for every run + index.\")\n p.add_argument(\"--open\", action=\"store_true\", help=\"Open the generated HTML in a browser.\")\n args = p.parse_args()\n\n if args.all:\n for d in sorted(RESULTS.iterdir(), reverse=True):\n if not d.is_dir() or not (d / \"results.csv\").exists():\n continue\n html = build_report(d)\n print(f\" wrote {html.relative_to(REPO)} (+ 3 lens pages)\")\n idx = build_index()\n print(f\"wrote {idx.relative_to(REPO)}\")\n if args.open:\n webbrowser.open(idx.as_uri())\n return 0\n\n if args.index:\n idx = build_index()\n print(f\"wrote {idx.relative_to(REPO)}\")\n if args.open:\n webbrowser.open(idx.as_uri())\n return 0\n\n if not args.run_dir:\n p.error(\"Provide a run dir, --index, or --all.\")\n run_dir = Path(args.run_dir)\n if not run_dir.is_absolute():\n run_dir = REPO / args.run_dir\n if not run_dir.exists():\n raise SystemExit(f\"Not found: {run_dir}\")\n\n html = build_report(run_dir)\n print(f\"wrote {html.relative_to(REPO)} (+ speed.html, samples.html)\")\n build_index()\n if args.open:\n webbrowser.open(html.as_uri())\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n"}}}} |