A delivered gap is not a missing translation. The delivery arrives and says this target language does not voice this utterance, with a reason - the translation renders a different paragraph, say. That is a different problem with a different fix, and the reviewer needs to be able to tell them apart. The reason was being thrown away. The row is stored with an empty text and a review_reason; the resolver skipped every row with empty text, so the gap was dropped and the review lane said "Chinese has not been translated yet". That is a false statement about a translation that was delivered and rejected. So the resolver now keeps a not-voiced row as a gap with its reason, the chapter page renders it as a gap rather than as missing text, and the language page lists the delivered gaps under "Needs attention" with their reasons. A gap row is also no longer counted as translated text, so the coverage the reviewer sees is the coverage the book has. 4 new tests. Suite: 913 passed, 8 failed (the same 8 pre-existing failures). Also adds scripts/probe_cross_language_tts.py: the open prerequisite for any target-language audio lane is whether Qwen3-TTS can speak Chinese with an English voice reference. The probe answers it by transcription rather than by listening - Whisper transcribes each clip and reports what language it heard, and the English-reference clip reading Chinese is compared against the same clip reading Chinese with English phonology, so the only variable is the language parameter.
370 lines
13 KiB
Python
370 lines
13 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,
|
|
"voiced": entry.voiced,
|
|
"reason": entry.reason,
|
|
}
|
|
)
|
|
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 _delivered_gaps(request: Request, code: str) -> list[dict]:
|
|
"""Utterances the delivery explicitly does not voice, and why.
|
|
|
|
A delivered gap is not a missing translation: the delivery arrived and
|
|
said this target language does not voice this utterance, with a reason.
|
|
That is a different problem with a different fix, so it has to be
|
|
listed separately rather than folded into "not translated yet".
|
|
"""
|
|
from umbraaudio.translations import canonical_code
|
|
|
|
db = _project_db(request)
|
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
con.row_factory = sqlite3.Row
|
|
try:
|
|
rows = con.execute(
|
|
"""
|
|
SELECT u.chapter_id, u.utterance_index, u.text AS source_text,
|
|
t.review_reason, t.language_code
|
|
FROM utterance_translations t
|
|
JOIN utterances u ON u.id = t.utterance_id
|
|
WHERE t.voiced = 0
|
|
ORDER BY u.chapter_id, u.utterance_index
|
|
"""
|
|
).fetchall()
|
|
except sqlite3.OperationalError:
|
|
logger.exception("language page: utterance_translations could not be read")
|
|
return []
|
|
finally:
|
|
con.close()
|
|
|
|
target = canonical_code(code) or str(code).strip().lower()
|
|
out: list[dict] = []
|
|
for row in rows:
|
|
stored = canonical_code(row["language_code"]) or str(row["language_code"] or "").lower()
|
|
if stored != target:
|
|
continue
|
|
out.append(
|
|
{
|
|
"chapter": row["chapter_id"],
|
|
"index": row["utterance_index"],
|
|
"reason": str(row["review_reason"] or ""),
|
|
"source_text": str(row["source_text"] or ""),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
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."
|
|
),
|
|
}
|
|
)
|
|
|
|
# Delivered gaps: the translation arrived and declined to voice these
|
|
# utterances, with a reason. Listed with the reason so the reviewer knows
|
|
# what to do about them.
|
|
for gap in _delivered_gaps(request, code):
|
|
issues.append(
|
|
{
|
|
"chapter": gap["chapter"],
|
|
"index": gap["index"],
|
|
"kind": "delivered gap",
|
|
"badge": "badge-yellow",
|
|
"text": f"{gap['reason'] or 'the delivery did not voice this utterance'}",
|
|
}
|
|
)
|
|
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),
|
|
"voiced": u.get("voiced", True),
|
|
"reason": u.get("reason", ""),
|
|
}
|
|
)
|
|
|
|
# A delivered gap is not translated text. Counting it as translated would
|
|
# report coverage the book does not have.
|
|
text_have = sum(1 for r in rows if r["target_text"].strip())
|
|
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,
|
|
},
|
|
)
|