The review UI's language dropdown did nothing: every selection kept rendering the English text. Two independent causes, both fixed. 1. The translations were never being read. The pipeline's `utterance_translations` table holds one row in Deal Hunter, and that row stores the *English* text under a `zh-Hans` code. The real translations live in the on-disk sample files under `samples/translations/`. `umbraaudio/translations.py` now resolves both sources and merges them by (chapter_id, utterance_index): the table wins where it holds a real translation, the sample files fill the gaps. A candidate whose text is identical to the source is a placeholder, not a translation, so it is ignored - otherwise the page would show English under a Chinese heading and claim it was translated. 2. The query that read them was broken. `language_routes.py` selected `u.index` and `u.speaker`; the columns are `utterance_index` and `speaker_name`, so every read raised `sqlite3.OperationalError: near "index": syntax error` and `/language-mockup` returned 500. It also opened `<project>/project.db` - a 0-byte decoy - instead of `<project>/.umbra/project.db`, the file that actually holds the data. Also in this commit: * The chapter review page honours `?lang=`, remembers the choice in a cookie (set by a middleware, so it survives on every page), and shows the selected language's *name* in the "has not been translated yet" message. * The page now shows the translation, not the source, and the edit box is bound to the source text so saving cannot overwrite the English with the Chinese. * The stray language selector that sat after `</html>` in base.html is gone; the chapters-list page's dropdown had zero options because the route never passed the language list. * The language tag is wired into generation: `utterance.language_code` (NULL = project source language) reaches `engine.generate()` and the staleness marker, so a retagged line is re-rendered instead of keeping English phonology behind an unchanged text. The marker records the language; a marker written before the tag existed means English, so the ~4,300 existing clips are not invalidated. * `project.source_language` (spec 31 section 13.1) records the book's own language, with a migration for existing project DBs. * A scene-break marker deliberately records no language: it is silence, identical in every language, and tagging it would claim a phonology the audio does not have. Tests: 883 passed / 8 failed - the same 8 pre-existing failures (2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests cover the resolver and the generation wiring. ruff introduces no new findings.
303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""Language selection UI (spec 30/31).
|
|
|
|
Reads translation rows from the active project's ``utterance_translations`` table
|
|
so the UI always shows the real project state, not a stale sample snapshot.
|
|
|
|
Writes per-language audio under ``<project>/Lang_test/<CODE>``. Audio generation
|
|
is driven by the same pipeline that renders English — the ``code`` is passed
|
|
through to the TTS engine so each language gets its own rendered chapter files.
|
|
|
|
Do not delete this module: the language-mockup route is how the operator reviews
|
|
language state before spec 31 commits to a full pipeline rewrite.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
from collections import defaultdict
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
# The 10 languages Qwen3-TTS speaks. Lowercase codes as the model expects
|
|
# (spec 30 section 8). The 5 gap languages are NOT offered: showing them as
|
|
# selectable would imply we can voice them.
|
|
SUPPORTED: list[tuple[str, str]] = [
|
|
("en", "English"),
|
|
("de", "German"),
|
|
("fr", "French"),
|
|
("es", "Spanish"),
|
|
("it", "Italian"),
|
|
("pt", "Portuguese"),
|
|
("ru", "Russian"),
|
|
("zh", "Chinese"),
|
|
("ja", "Japanese"),
|
|
("ko", "Korean"),
|
|
]
|
|
|
|
SOURCE_LANGUAGE = "en"
|
|
|
|
# Above this, the issue list stops being scannable and groups by chapter instead.
|
|
ISSUE_INLINE_THRESHOLD = 25
|
|
|
|
|
|
def _project_db(request: Request) -> Path:
|
|
"""Path to the active project's SQLite DB.
|
|
|
|
``data_dir`` (``<project>/.umbra``), the same file routes.py opens. A
|
|
0-byte ``project.db`` also sits in the project root; opening that one
|
|
finds no tables at all, so every query fails with "no such table".
|
|
"""
|
|
from umbraaudio.web.routes import _get_active_config
|
|
|
|
return _get_active_config(request).data_dir / "project.db"
|
|
|
|
|
|
def _language_code_variants(code: str) -> list[str]:
|
|
"""Return a language code and its common variants for DB lookup.
|
|
|
|
The project DB stores ``'zh-Hans'`` while SUPPORTED uses ``'zh'``.
|
|
This function returns both so the query can match either.
|
|
"""
|
|
variants: list[str] = [code]
|
|
if code == "zh":
|
|
variants.append("zh-Hans")
|
|
if code == "en":
|
|
variants.append("en-US")
|
|
return variants
|
|
|
|
|
|
def _translation_rows(request: Request, code: str) -> list[dict]:
|
|
"""The translated rows for *code*, from the DB and the sample files.
|
|
|
|
Each row has: index, speaker, type, text — matching the shape the mockup
|
|
template expects so the HTML table renders without changes.
|
|
|
|
The project's ``utterance_translations`` table is the pipeline's output,
|
|
but today it holds one placeholder row; the real translations live in the
|
|
on-disk sample files under ``samples/translations``. Both are read, and
|
|
the DB wins where it holds a real translation.
|
|
"""
|
|
from umbraaudio.translations import resolved_translations
|
|
from umbraaudio.web.routes import _get_active_config
|
|
|
|
cfg = _get_active_config(request)
|
|
db = _project_db(request)
|
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
con.row_factory = sqlite3.Row
|
|
try:
|
|
resolved = resolved_translations(con, cfg.project_dir, code)
|
|
finally:
|
|
con.close()
|
|
|
|
rows: list[dict] = []
|
|
for (chapter_id, index), entry in sorted(resolved.items()):
|
|
rows.append(
|
|
{
|
|
"chapter_id": chapter_id,
|
|
"index": index,
|
|
"speaker": entry.speaker or "",
|
|
"type": entry.type or "",
|
|
"text": entry.text,
|
|
"status": entry.status,
|
|
"origin": entry.origin,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _total_utterances(request: Request) -> int:
|
|
"""Total utterances in the project - the denominator for coverage."""
|
|
db = _project_db(request)
|
|
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
|
|
return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0]
|
|
|
|
|
|
def _lang_audio_dir(request: Request, code: str) -> Path:
|
|
"""Where per-language audio is written: <project>/Lang_test/<CODE>."""
|
|
from umbraaudio.web.routes import _get_active_config
|
|
|
|
cfg = _get_active_config(request)
|
|
return cfg.project_dir / "Lang_test" / code.upper()
|
|
|
|
|
|
def _count_utterances(request: Request) -> int:
|
|
"""Total utterances in the project - the denominator for both coverages."""
|
|
db = _project_db(request)
|
|
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
|
|
return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0]
|
|
|
|
|
|
def _collect_issues(request: Request, code: str, text_count: int) -> list[dict]:
|
|
"""Issues that need fixing or confirming for *code*.
|
|
|
|
Only genuine content problems are listed here. Deliberately NOT listed:
|
|
missing staleness markers on the sample render. Those exist because
|
|
render_lang_test.py does not write markers, not because anything is wrong
|
|
with the translation - reporting them would send someone hunting a defect
|
|
that is not there.
|
|
"""
|
|
issues: list[dict] = []
|
|
|
|
if text_count == 0:
|
|
# Nothing translated at all - one representative row rather than 4,319.
|
|
return [
|
|
{
|
|
"chapter": 1,
|
|
"index": 0,
|
|
"kind": "no translation",
|
|
"badge": "badge-red",
|
|
"text": f"No {code.upper()} text delivered yet - whole book untranslated.",
|
|
}
|
|
]
|
|
|
|
if text_count < 25:
|
|
# Too little translated to call the book ready. This is the honest
|
|
# headline for any language that has not had a real translation run.
|
|
issues.append(
|
|
{
|
|
"chapter": 1,
|
|
"index": 0,
|
|
"kind": "unconfirmed",
|
|
"badge": "badge-yellow",
|
|
"text": (
|
|
f"Only {text_count} of 4,319 utterances translated, and none "
|
|
f"confirmed. Sample only - not a publishable translation."
|
|
),
|
|
}
|
|
)
|
|
return issues
|
|
|
|
|
|
def _group_by_chapter(issues: list[dict]) -> list[dict]:
|
|
"""Compact >25 issues into one row per chapter, worst first."""
|
|
buckets: dict[int, dict] = defaultdict(lambda: {"count": 0, "kinds": set()})
|
|
for i in issues:
|
|
b = buckets[i["chapter"]]
|
|
b["count"] += 1
|
|
b["kinds"].add(i["kind"])
|
|
rows = [
|
|
{
|
|
"chapter": ch,
|
|
"count": b["count"],
|
|
"kinds": ", ".join(sorted(b["kinds"])),
|
|
}
|
|
for ch, b in sorted(buckets.items(), key=lambda kv: -kv[1]["count"])
|
|
]
|
|
return rows
|
|
|
|
|
|
def _source_rows(request: Request, chapter_id: int = 1) -> dict[int, dict]:
|
|
"""The project's own English text for *chapter_id*, keyed by index.
|
|
|
|
The source column of the mockup shows what the book actually says. The
|
|
sample translation files paraphrase the English slightly, so reading the
|
|
source from the DB keeps the two columns honest.
|
|
"""
|
|
db = _project_db(request)
|
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
con.row_factory = sqlite3.Row
|
|
try:
|
|
rows = con.execute(
|
|
"""
|
|
SELECT utterance_index, speaker_name, type, text
|
|
FROM utterances
|
|
WHERE chapter_id = ?
|
|
ORDER BY utterance_index
|
|
""",
|
|
(chapter_id,),
|
|
).fetchall()
|
|
except sqlite3.OperationalError:
|
|
logger.exception("mockup: utterances could not be read")
|
|
return {}
|
|
finally:
|
|
con.close()
|
|
return {
|
|
row["utterance_index"]: {
|
|
"index": row["utterance_index"],
|
|
"speaker": row["speaker_name"] or "",
|
|
"type": row["type"] or "",
|
|
"text": row["text"] or "",
|
|
}
|
|
for row in rows
|
|
}
|
|
|
|
|
|
@router.get("/language-mockup", response_class=HTMLResponse)
|
|
async def language_mockup_page(request: Request, lang: str = "de"):
|
|
from umbraaudio.web.routes import templates
|
|
|
|
code = lang.lower() if lang.lower() in dict(SUPPORTED) else "de"
|
|
names = dict(SUPPORTED)
|
|
|
|
# No fallback path here on purpose: a silent fallback would render English
|
|
# under a German heading and look like a working page.
|
|
target = _translation_rows(request, code)
|
|
source = _source_rows(request)
|
|
|
|
total = _total_utterances(request)
|
|
|
|
rows: list[dict] = []
|
|
adir = _lang_audio_dir(request, code)
|
|
# Rendered clips are named utt<NN>_<Speaker>.wav, not utt<NNNN>.wav, so
|
|
# match by index prefix rather than assuming the padded form.
|
|
audio_by_index: dict[int, bool] = {}
|
|
if adir.exists():
|
|
for f in adir.glob("utt*_*.wav"):
|
|
m = re.match(r"utt(\d+)_", f.name)
|
|
if m:
|
|
audio_by_index[int(m.group(1))] = True
|
|
|
|
for u in target:
|
|
src = source.get(u["index"])
|
|
rows.append(
|
|
{
|
|
"index": u["index"],
|
|
"speaker": u.get("speaker", "") or (src["speaker"] if src else ""),
|
|
"type": u.get("type", "") or (src["type"] if src else ""),
|
|
"target_text": u["text"],
|
|
"source_text": src["text"] if src else "",
|
|
"has_audio": audio_by_index.get(u["index"], False),
|
|
}
|
|
)
|
|
|
|
text_have = len(rows)
|
|
audio_have = sum(1 for r in rows if r["has_audio"])
|
|
text_pct = (text_have / total * 100) if total else 0.0
|
|
audio_pct = (audio_have / total * 100) if total else 0.0
|
|
|
|
issues = _collect_issues(request, code, text_have)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"language_mockup.html",
|
|
{
|
|
"languages": SUPPORTED,
|
|
"code": code,
|
|
"language_name": names.get(code, code),
|
|
"rows": rows,
|
|
"total": total,
|
|
"text_have": text_have,
|
|
"text_pct": text_pct,
|
|
"audio_have": audio_have,
|
|
"audio_pct": audio_pct,
|
|
"audio_blocked": audio_have < text_have,
|
|
"audio_blocked_count": text_have - audio_have,
|
|
"issues": issues,
|
|
"issue_count": len(issues),
|
|
"issue_threshold": ISSUE_INLINE_THRESHOLD,
|
|
"issue_by_chapter": _group_by_chapter(issues),
|
|
"is_source": code == SOURCE_LANGUAGE,
|
|
"show_source_ref": False,
|
|
},
|
|
)
|