Files
UmbraAudio/umbraaudio/web/language_routes.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
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.
2026-11-06 16:55:49 -08:00

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,
},
)