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:
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
|
||||
54
umbraaudio/web/templates/lang_samples.html
Normal file
54
umbraaudio/web/templates/lang_samples.html
Normal 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 · {% 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 %}
|
||||
Reference in New Issue
Block a user