Files

1 line
77 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}