Add an SLang tab listing the multilingual sample chapters

The rendered sample chapters existed on disk but had no way to be listened
to through the UI. New /lang-samples page, reachable from a new SLang nav
tab, lists every language chapter with an inline player plus the combined
all-languages file.

- Serves from <project>/Lang_test, which is where render_lang_test.py writes.
  Deliberately project_dir and not data_dir: .umbra is gitignored state and
  the render output lives beside the rest of the project.
- Languages are discovered from disk in a fixed order rather than hardcoded,
  so an unrendered language simply does not appear instead of offering a
  player that 404s.
- The audio path validates a two-to-three letter code and builds the
  filename server-side, so it cannot be walked out of the sample directory.
  Verified: %2e%2e and over-long codes are rejected 400, unauthenticated
  requests 303 to login.
- A missing language now 404s rather than falling back to the combined file.
  It did before, so an unrendered language would silently play the wrong
  audio - the same class of bug as a stale speaker name resolving to no
  voice at all.

Verified in a browser: 11 players, durations resolve (EN 92.3s, ZH 92.0s,
combined 1067.8s), no load errors, range requests 206 so seeking works.

routes.py stays at its 76 pre-existing ruff errors. Suite: 721 passed,
8 pre-existing.
This commit is contained in:
B.D. Murphy
2026-10-13 02:39:29 -07:00
parent d73cdf5442
commit 948034c365
3 changed files with 165 additions and 0 deletions

View File

@@ -6767,6 +6767,116 @@ def _m4b_meta_view(request: Request) -> dict[str, Any]:
}
#: Fixed language order for the sample players, English first.
LANG_SAMPLE_ORDER = ["EN", "ZH", "JA", "KO", "DE", "ES", "FR", "RU", "IT", "PT"]
def _lang_samples_dir(request: Request) -> Path:
"""Where render_lang_test.py writes: ``<project>/Lang_test``.
Deliberately project_dir, not data_dir - .umbra is gitignored state and
Lang_test is render output that lives alongside the rest of the project.
"""
return _get_active_config(request).project_dir / "Lang_test"
def _discover_lang_samples(request: Request) -> list[dict]:
"""Per-language full-sample chapters found on disk, in the fixed order.
Discovered rather than hardcoded so a language that has not been rendered
simply does not appear, instead of showing a player that 404s on click.
"""
import soundfile as sf
root = _lang_samples_dir(request)
out: list[dict] = []
if not root.is_dir():
return out
seen = set()
for code in LANG_SAMPLE_ORDER:
f = root / code / f"_full_sample_{code}.wav"
seen.add(code)
if not f.exists():
continue
try:
info = sf.info(str(f))
dur = round(info.duration, 1)
except Exception:
dur = None
out.append(
{
"code": code,
"path": f,
"duration": dur,
"size_mb": round(f.stat().st_size / 1048576, 1),
}
)
# Anything rendered that is not in the fixed order still gets listed.
for d in sorted(p for p in root.iterdir() if p.is_dir() and p.name not in seen):
f = d / f"_full_sample_{d.name}.wav"
if not f.exists():
continue
out.append(
{
"code": d.name,
"path": f,
"duration": None,
"size_mb": round(f.stat().st_size / 1048576, 1),
}
)
combined = root / "_ALL_LANGUAGES_full_sample.wav"
if combined.exists():
out.append(
{
"code": "ALL",
"path": combined,
"duration": None,
"size_mb": round(combined.stat().st_size / 1048576, 1),
}
)
return out
@router.get("/lang-samples", response_class=HTMLResponse)
async def lang_samples_page(request: Request):
config = _get_active_config(request)
return templates.TemplateResponse(
request,
"lang_samples.html",
{
"request": request,
"samples": _discover_lang_samples(request),
"project_name": config.project_dir.name,
"project_slug": config.project_dir.name,
},
)
@router.get("/lang-samples/audio/{code}")
async def lang_sample_audio(request: Request, code: str):
"""Serve one rendered language chapter.
The filename is built server-side from a validated two-letter code rather
than taken from the URL, so this cannot be walked out of the sample
directory.
"""
if not re.fullmatch(r"[A-Za-z]{2,3}", code):
return JSONResponse({"error": "invalid language code"}, status_code=400)
root = _lang_samples_dir(request)
upper = code.upper()
if upper == "ALL":
candidates = [root / "_ALL_LANGUAGES_full_sample.wav"]
else:
# A directory that does not exist is a miss, not a reason to fall back
# to some other file - otherwise an unrendered language silently plays
# the wrong audio instead of reporting that it has none.
candidates = [root / upper / f"_full_sample_{upper}.wav"]
for path in candidates:
if path.exists() and path.resolve().parent.is_relative_to(root.resolve()):
return FileResponse(str(path), media_type="audio/wav")
return JSONResponse({"error": f"no sample for {code}"}, status_code=404)
@router.get("/m4b-meta", response_class=HTMLResponse)
async def m4b_meta_page(request: Request):
return templates.TemplateResponse(request, "m4b_meta.html", _m4b_meta_view(request))

View File

@@ -94,6 +94,7 @@
<a href="/quality">Quality</a>
<a href="/library">Lib</a>
<a href="/m4b-meta">m4b Meta</a>
<a href="/lang-samples">SLang</a>
<a href="/projects" style="margin-left: auto; border-color: #a78bfa;">Projects</a>
{# Logout lives in a form because it is a POST that clears the cookie. #}
<form method="post" action="/api/logout" style="display:inline">

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h2 style="margin:0">SLang — multilingual samples</h2>
<p style="color:#94a3b8; margin:0.25rem 0 0">
The sample chapter rendered in each language Qwen3-TTS produces, spoken by
the same characters as the English book. Generated with the
<strong>1.7B</strong> model, the one the English audiobook was made with.
</p>
{% if not samples %}
<div style="margin-top:1rem; padding:1rem; background:#1a1a2e; border-left:4px solid #f59e0b; border-radius:6px; color:#fde68a">
No samples found for <strong>{{ project_name }}</strong>.
Render them with
<code style="color:#e0e0e0">python scripts/render_lang_test.py --project {{ project_slug }}</code>
</div>
{% else %}
<div style="margin-top:0.75rem">
<span class="badge badge-green" style="font-size:0.8rem">{{ samples|length }} playable</span>
</div>
<div style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem">
{% for s in samples %}
<div style="padding:0.7rem 0.9rem; background:#1a1a2e; border-radius:6px; border:1px solid #333">
<div style="display:flex; align-items:center; gap:0.6rem; margin-bottom:0.4rem">
<span class="badge badge-blue" style="font-size:0.8rem; min-width:3.2rem; text-align:center">{{ s.code }}</span>
{% if s.code == 'ALL' %}
<strong style="color:#e0e7ff">All languages in one file</strong>
<span class="badge badge-purple" style="font-size:0.7rem">combined</span>
{% else %}
<strong style="color:#e0e0e0">{{ s.code }} sample chapter</strong>
{% endif %}
<span style="color:#64748b; font-size:0.78rem; margin-left:auto">
{% if s.duration %}{{ s.duration }}s &middot; {% endif %}{{ s.size_mb }} MB
</span>
</div>
<audio controls preload="none" style="width:100%"
src="/lang-samples/audio/{{ s.code }}"></audio>
</div>
{% endfor %}
</div>
{% endif %}
<div style="margin-top:1.25rem; padding-top:0.75rem; border-top:1px solid #333; color:#94a3b8; font-size:0.82rem; line-height:1.6">
<strong style="color:#cbd5e1">Before treating these as finished:</strong> the
translations are single-pass LLM drafts, not the multi-pass pipeline in
<code style="color:#cbd5e1">specs/27</code>. Grammatical and plausible, and still
open to meaning errors. These exist to judge voice fidelity and pacing per
language, not translation quality.
</div>
</div>
{% endblock %}