From ff73c99f8df98731901b18fe59fbbe8e589dcb1f Mon Sep 17 00:00:00 2001 From: Umbra Tools Date: Fri, 6 Nov 2026 21:56:28 -0800 Subject: [PATCH] Show the reviewer why a translation lane has a gap, not just that it has one 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. --- scripts/probe_cross_language_tts.py | 204 ++++++++++++++++++ tests/test_translation_gaps.py | 145 +++++++++++++ umbraaudio/translations.py | 29 ++- umbraaudio/web/language_routes.py | 69 +++++- umbraaudio/web/routes.py | 75 ++++--- .../web/templates/chapter_audio_detail.html | 4 + 6 files changed, 492 insertions(+), 34 deletions(-) create mode 100644 scripts/probe_cross_language_tts.py create mode 100644 tests/test_translation_gaps.py diff --git a/scripts/probe_cross_language_tts.py b/scripts/probe_cross_language_tts.py new file mode 100644 index 0000000..1657167 --- /dev/null +++ b/scripts/probe_cross_language_tts.py @@ -0,0 +1,204 @@ +# Copyright 2026 Umbra Tools (umbratools.com) +# SPDX-License-Identifier: Apache-2.0 + +"""Does Qwen3-TTS speak Chinese when the voice reference is English? + +This is the open prerequisite for any target-language audio lane. The +engine and the server both accept a per-request ``language``, so it is +possible in principle - but "possible in principle" is not a measurement. + +The question is answered by transcription rather than by listening. Whisper +is asked to transcribe each generated clip and to say what language it +thinks it heard. If the model is reading the Chinese text with English +phonology, the transcription comes back as English-sounding gibberish or as +the wrong characters; if it is speaking Chinese, the transcription comes back +as the Chinese characters that were sent. + +Four clips are generated: + +============================ ========================================= +clip what it answers +============================ ========================================= +english-ref-chinese-lang the case under test: English voice ref, + Chinese text, language=chinese +english-ref-english-lang the control: the same voice ref reading the + same Chinese text with English phonology +no-ref-chinese-lang the model's own Chinese voice, for reference +english-ref-english-text the known-good case: the voice ref reading + English +============================ ========================================= + +The first two are the comparison that matters: same text, same voice, the +only difference is the language parameter. + +Run it with the TTS server up and LM Studio unloaded - on a 16 GB Mac the +two models cannot be resident at the same time. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import wave +from pathlib import Path + +import httpx + +CHINESE_TEXT = "凯因达站在舰桥上,看着舷窗外的星云,握紧了手里的扳手。" +CHINESE_TEXT_2 = "我们必须在下一次跃迁之前找到那块燃料。" +ENGLISH_TEXT = "Kainda stood on the bridge, watching the nebula through the viewport." + +TTS_URL = "http://127.0.0.1:8881" + + +def _health(client: httpx.Client) -> dict: + response = client.get(f"{TTS_URL}/health", timeout=10) + response.raise_for_status() + return response.json() + + +def _load_base(client: httpx.Client) -> None: + response = client.post(f"{TTS_URL}/load", json={"model_type": "base"}, timeout=1800) + response.raise_for_status() + + +def _synthesise( + client: httpx.Client, + out_path: Path, + text: str, + *, + language: str, + ref_audio: Path | None = None, + ref_text: str | None = None, + voice_description: str | None = None, +) -> None: + data: dict[str, str] = {"text": text, "language": language, "sample_rate": "24000"} + files = None + if voice_description: + data["voice_description"] = voice_description + if ref_audio is not None: + data["ref_text"] = ref_text or "" + files = {"ref_audio": (ref_audio.name, ref_audio.read_bytes(), "audio/wav")} + response = client.post(f"{TTS_URL}/v1/tts", data=data, files=files, timeout=1800) + response.raise_for_status() + out_path.write_bytes(response.content) + print(f" wrote {out_path} ({out_path.stat().st_size} bytes)", flush=True) + + +def _duration(path: Path) -> float: + with wave.open(str(path), "rb") as handle: + return handle.getnframes() / float(handle.getframerate()) + + +def _transcribe(path: Path) -> dict: + import whisper + + model = whisper.load_model("base") + result = model.transcribe(str(path), fp16=False) + return { + "text": (result.get("text") or "").strip(), + "language": result.get("language"), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ref", type=Path, required=True, help="an English voice reference wav") + parser.add_argument("--ref-text", default="", help="the transcript of the reference clip") + parser.add_argument( + "--out", type=Path, required=True, help="where to write the clips and report" + ) + args = parser.parse_args() + + args.out.mkdir(parents=True, exist_ok=True) + + with httpx.Client() as client: + health = _health(client) + print(f"tts server: {health}", flush=True) + if not health.get("base_loaded"): + print("loading the base model (this takes a while)...", flush=True) + _load_base(client) + + clips = { + "english-ref-chinese-lang": { + "text": CHINESE_TEXT, + "language": "chinese", + "ref_audio": args.ref, + "ref_text": args.ref_text, + }, + "english-ref-english-lang": { + "text": CHINESE_TEXT, + "language": "english", + "ref_audio": args.ref, + "ref_text": args.ref_text, + }, + "no-ref-chinese-lang": { + "text": CHINESE_TEXT, + "language": "chinese", + "voice_description": ( + "A calm, measured male narrator with a deep, radio-announcer voice." + ), + }, + "english-ref-english-text": { + "text": ENGLISH_TEXT, + "language": "english", + "ref_audio": args.ref, + "ref_text": args.ref_text, + }, + } + + report: dict[str, dict] = {} + for name, spec in clips.items(): + print(f"generating {name}...", flush=True) + path = args.out / f"{name}.wav" + _synthesise( + client, + path, + spec["text"], + language=spec["language"], + ref_audio=spec.get("ref_audio"), + ref_text=spec.get("ref_text"), + voice_description=spec.get("voice_description"), + ) + report[name] = { + "text_sent": spec["text"], + "language_requested": spec["language"], + "duration_s": round(_duration(path), 3), + } + + print("transcribing...", flush=True) + for name in report: + path = args.out / f"{name}.wav" + transcript = _transcribe(path) + report[name].update(transcript) + print(f" {name}: heard {transcript['language']}: {transcript['text']}", flush=True) + + expected = CHINESE_TEXT.replace(",", "").replace("。", "") + for name in ("english-ref-chinese-lang", "english-ref-english-lang", "no-ref-chinese-lang"): + heard = report[name]["text"].replace(" ", "").replace(",", "").replace(".", "") + overlap = len(set(expected) & set(heard)) + report[name]["character_overlap_with_source"] = overlap + report[name]["source_characters"] = len(set(expected)) + + verdict = report["english-ref-chinese-lang"] + report["verdict"] = { + "english_voice_reference_speaks_chinese": ( + verdict["language"] == "zh" and verdict["character_overlap_with_source"] >= 8 + ), + "note": ( + "The English voice reference is kept - the point is whether the same " + "voice can speak the target language, not whether a Chinese voice " + "can." + ), + } + + report_path = args.out / "probe-report.json" + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nwrote {report_path}") + print(json.dumps(report["verdict"], ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_translation_gaps.py b/tests/test_translation_gaps.py new file mode 100644 index 0000000..0dbbd86 --- /dev/null +++ b/tests/test_translation_gaps.py @@ -0,0 +1,145 @@ +# Copyright 2026 Umbra Tools (umbratools.com) +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for a delivered gap in the translation lane (spec 31 section 6.2). + +A delivered gap is not a missing translation. The delivery arrived and said +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, so the reason has to reach the reviewer. + +The bug this guards against is quiet: the row is stored with an empty text, +and a resolver that skips empty text throws the reason away. The review lane +then says "Chinese has not been translated yet", which is a false statement +about a translation that was delivered and rejected. +""" + +from __future__ import annotations + +import sqlite3 +from typing import TYPE_CHECKING + +import pytest + +from umbraaudio import translations as tr + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def conn() -> sqlite3.Connection: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + c.executescript( + """ + CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE chapters (id INTEGER PRIMARY KEY, chapter_index INTEGER); + CREATE TABLE utterances ( + id INTEGER PRIMARY KEY, + chapter_id INTEGER, + utterance_index INTEGER, + speaker_name TEXT, + text TEXT, + context_before TEXT, + context_after TEXT, + language_code TEXT, + language_origin TEXT + ); + INSERT INTO project (id, name) VALUES (1, 'Test'); + INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES + (1, 1, 0, 'narrator', 'The ship landed.'), + (2, 1, 1, 'Kainda', 'Welcome to the ship.'), + (3, 1, 2, 'narrator', 'A line that was dropped.'); + """ + ) + c.commit() + tr.ensure_tables(c) + yield c + c.close() + + +def _insert_gap(conn: sqlite3.Connection, utterance_id: int, reason: str) -> None: + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, status, + origin, voiced, review_reason) + VALUES (1, ?, 'zh', 'Chinese', '', 'draft', 'umbratrans', 0, ?) + """, + (utterance_id, reason), + ) + conn.commit() + + +def test_a_delivered_gap_survives_the_resolver_with_its_reason( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + _insert_gap(conn, 3, "translation belongs to another paragraph") + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + entry = resolved[(1, 2)] + assert entry.voiced is False + assert entry.reason == "translation belongs to another paragraph" + assert entry.text == "" + + +def test_a_delivered_gap_is_not_reported_as_translated_text( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + """The gap must not inflate the coverage the reviewer sees.""" + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, status, + origin, voiced) + VALUES (1, 1, 'zh', 'Chinese', '飞船降落了。', 'draft', 'umbratrans', 1) + """ + ) + conn.commit() + _insert_gap(conn, 3, "translation belongs to another paragraph") + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert resolved[(1, 0)].text == "飞船降落了。" + assert resolved[(1, 0)].voiced is True + # The gap is present as a gap, not as translated text. + assert resolved[(1, 2)].text == "" + + +def test_a_voiced_row_with_no_text_is_still_not_a_gap( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + """An empty text with no gap flag is an empty row, not a delivered gap.""" + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, status, + origin, voiced) + VALUES (1, 3, 'zh', 'Chinese', '', 'draft', 'umbratrans', 1) + """ + ) + conn.commit() + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert (1, 2) not in resolved + + +def test_a_gap_for_another_language_is_not_returned( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, status, + origin, voiced, review_reason) + VALUES (1, 3, 'ru', 'Russian', '', 'draft', 'umbratrans', 0, 'not Russian') + """ + ) + conn.commit() + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert resolved == {} diff --git a/umbraaudio/translations.py b/umbraaudio/translations.py index 9994697..11dd805 100644 --- a/umbraaudio/translations.py +++ b/umbraaudio/translations.py @@ -646,6 +646,11 @@ class TranslationEntry: origin: str = "sample" speaker: str = "" type: str = "" + # False when the delivery explicitly said this target language does not + # voice this utterance - a gap with a known cause, not a missing + # translation. The reason is what the reviewer needs to see. + voiced: bool = True + reason: str = "" @property def confirmed(self) -> bool: @@ -747,7 +752,7 @@ def _db_translation_rows( """ SELECT u.chapter_id, u.utterance_index, u.text AS source_text, t.text AS target_text, t.status, t.origin, u.speaker_name, - t.language_code + t.language_code, t.voiced, t.review_reason FROM utterance_translations t JOIN utterances u ON u.id = t.utterance_id """ @@ -766,6 +771,8 @@ def _db_translation_rows( "origin": _col(r, "origin", 5), "speaker_name": _col(r, "speaker_name", 6), "language_code": _col(r, "language_code", 7), + "voiced": _col(r, "voiced", 8), + "review_reason": _col(r, "review_reason", 9), } for r in rows ] @@ -796,7 +803,25 @@ def resolved_translations( continue source_text = row["source_text"] or "" text = row["target_text"] or "" + voiced = bool(row["voiced"]) if row["voiced"] is not None else True + reason = str(row["review_reason"] or "") if not text.strip(): + if not voiced: + # A delivered gap: the target language does not voice this + # utterance, and the delivery said why. It is kept so the + # reviewer sees a gap with a known cause - "the translation + # renders another paragraph" is a different problem from + # "nobody translated this", and the two need different + # actions. + key = (int(row["chapter_id"]), int(row["utterance_index"])) + resolved[key] = TranslationEntry( + text="", + status=row["status"] or STATUS_DRAFT, + origin=row["origin"] or "umbratrans", + speaker=row["speaker_name"] or "", + voiced=False, + reason=reason, + ) continue if source_text and text.strip() == source_text.strip(): # The English text stored under a target-language code: a @@ -808,6 +833,8 @@ def resolved_translations( status=row["status"] or STATUS_DRAFT, origin=row["origin"] or "umbratrans", speaker=row["speaker_name"] or "", + voiced=True, + reason=reason, ) # --- the on-disk sample files ---------------------------------------- diff --git a/umbraaudio/web/language_routes.py b/umbraaudio/web/language_routes.py index a91ffdf..ed97009 100644 --- a/umbraaudio/web/language_routes.py +++ b/umbraaudio/web/language_routes.py @@ -110,6 +110,8 @@ def _translation_rows(request: Request, code: str) -> list[dict]: "text": entry.text, "status": entry.status, "origin": entry.origin, + "voiced": entry.voiced, + "reason": entry.reason, } ) return rows @@ -137,6 +139,53 @@ def _count_utterances(request: Request) -> int: 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*. @@ -175,6 +224,20 @@ def _collect_issues(request: Request, code: str, text_count: int) -> list[dict]: ), } ) + + # 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 @@ -267,10 +330,14 @@ async def language_mockup_page(request: Request, lang: str = "de"): "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", ""), } ) - text_have = len(rows) + # 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 diff --git a/umbraaudio/web/routes.py b/umbraaudio/web/routes.py index a72af48..dd4ae89 100644 --- a/umbraaudio/web/routes.py +++ b/umbraaudio/web/routes.py @@ -123,6 +123,7 @@ def _voice_maintenance_block() -> JSONResponse | None: status_code=409, ) + from umbraaudio.generation.audio_effects import ( apply_effects_to_file_by_name, load_speaker_distortion_effects, @@ -419,10 +420,7 @@ def _ensure_utterance_columns(db: sqlite3.Connection) -> None: # with no language tag is written in this language. project_cols = {r[1] for r in db.execute("PRAGMA table_info(project)")} if "source_language" not in project_cols: - db.execute( - "ALTER TABLE project ADD COLUMN source_language " - "TEXT NOT NULL DEFAULT 'en'" - ) + db.execute("ALTER TABLE project ADD COLUMN source_language TEXT NOT NULL DEFAULT 'en'") db.commit() @@ -2073,7 +2071,9 @@ async def speakers_list_partial(request: Request): return False from umbraaudio.voices.qwen_tts import QwenTTSEngine - engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url) + engine = QwenTTSEngine( + qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url + ) try: import httpx @@ -2537,8 +2537,7 @@ async def delete_speaker(request: Request, index: int, reassign_to: str = ""): ( s for s in speakers - if s.get("primary_name", "").strip().casefold() - == reassign_to.strip().casefold() + if s.get("primary_name", "").strip().casefold() == reassign_to.strip().casefold() ), None, ) @@ -3131,6 +3130,7 @@ def _active_language_for_template(request: Request) -> str: def _languages_for_template() -> list[tuple[str, str]]: """Return the supported languages for the selector dropdown.""" from umbraaudio.web.language_routes import SUPPORTED + return SUPPORTED @@ -3253,6 +3253,13 @@ async def chapter_audio_review(request: Request, chapter_dir: str, lang: str | N has_translation = translated is not None and translated.text.strip() display_text = translated.text if has_translation else source_text translation_status = translated.status if translated else None + # A delivered gap: the translation exists but does not voice this + # utterance, and the delivery says why. "Chinese has not been + # translated yet" would be a false statement here. + translation_gap = ( + translated is not None and not translated.voiced and translated.reason.strip() + ) + translation_gap_reason = translated.reason if translation_gap else "" audio_files.append( { @@ -3266,6 +3273,8 @@ async def chapter_audio_review(request: Request, chapter_dir: str, lang: str | N "source_text": source_text, "translated_text": translated.text if has_translation else None, "translation_status": translation_status, + "translation_gap": translation_gap, + "translation_gap_reason": translation_gap_reason, "has_translation": has_translation, "voice": voice, "emotion": u["emotion"] or "neutral", @@ -3659,7 +3668,9 @@ async def generate_utterance_audio(request: Request, utt_id: int): voice_ref = vf break - engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url) + engine = QwenTTSEngine( + qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url + ) tts_instruct = compose_utterance_instruct(instruct, emotion, pace) @@ -3784,7 +3795,9 @@ async def delete_utterance(request: Request, utt_id: int): if "kainda" in vf.stem.lower(): narrator_ref = vf break - engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url) + engine = QwenTTSEngine( + qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url + ) for u in remaining: out = audio_dir / f"utt_{u['utterance_index']:04d}.wav" if is_scene_break(u["text"], u["type"]): @@ -3976,7 +3989,9 @@ async def create_utterance(request: Request, chapter_id: int): narrator_ref = vf break try: - engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url) + engine = QwenTTSEngine( + qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url + ) if narrator_ref: engine.generate( text=spoken_text, @@ -4141,7 +4156,10 @@ async def set_voice_status(request: Request): QwenTTSEngine, ) - engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url) + engine = QwenTTSEngine( + qwen_tts_url=config.tts.qwen_tts_url, + helper_url=config.tts.qwen_tts_helper_url, + ) slug = engine.build_voice_prompt( ref_path, ref_text=VOICE_REFERENCE_TEXT, @@ -4204,16 +4222,16 @@ async def regenerate_speaker_voice(request: Request): from umbraaudio.web.voice_queue_routes import get_voice_queue - item = get_voice_queue(request).enqueue( - name, row_index=body.get("row_index") + item = get_voice_queue(request).enqueue(name, row_index=body.get("row_index")) + return JSONResponse( + { + "ok": True, + "speaker_name": name, + "deprecated": True, + "queued": True, + "item": item, + } ) - return JSONResponse({ - "ok": True, - "speaker_name": name, - "deprecated": True, - "queued": True, - "item": item, - }) @router.post("/api/speakers/generate-all-voices") @@ -5212,6 +5230,7 @@ async def run_full_pipeline(request: Request): from umbraaudio.analysis.clarity import ( assess_utterance_clarity, ) + try: alias_map: dict[str, str] = {} with open(speakers_path) as f: @@ -5222,9 +5241,7 @@ async def run_full_pipeline(request: Request): for a in s.get("aliases", []): if a: alias_map[a.lower()] = primary - attributed = assess_utterance_clarity( - attributed, ch_id, alias_map - ) + attributed = assess_utterance_clarity(attributed, ch_id, alias_map) except Exception: pass for u in attributed: @@ -5412,9 +5429,7 @@ async def download_mp3_package(request: Request): tracks = sorted(package_dir.glob("*.mp3")) if not tracks: - return JSONResponse( - {"error": "MP3 package is empty. Rebuild it first."}, status_code=404 - ) + return JSONResponse({"error": "MP3 package is empty. Rebuild it first."}, status_code=404) # AppConfig has no project_name; the directory name is the project slug. safe = re.sub(r"[^A-Za-z0-9._-]+", "_", config.project_dir.name).strip("_") @@ -5438,9 +5453,7 @@ async def download_mp3_package(request: Request): return StreamingResponse( _iter_zip(), media_type="application/zip", - headers={ - "Content-Disposition": f'attachment; filename="{safe}_mp3_package.zip"' - }, + headers={"Content-Disposition": f'attachment; filename="{safe}_mp3_package.zip"'}, ) @@ -5639,9 +5652,7 @@ async def segment_utterances(request: Request): for a in s.get("aliases", []): if a: alias_map[a.lower()] = primary - attributed = assess_utterance_clarity( - attributed, ch_id, alias_map - ) + attributed = assess_utterance_clarity(attributed, ch_id, alias_map) except Exception: pass diff --git a/umbraaudio/web/templates/chapter_audio_detail.html b/umbraaudio/web/templates/chapter_audio_detail.html index 01e973e..ec49c15 100644 --- a/umbraaudio/web/templates/chapter_audio_detail.html +++ b/umbraaudio/web/templates/chapter_audio_detail.html @@ -190,6 +190,10 @@ {% if audio.has_translation %} {{ audio.display_text }} ✓ {{ active_language_name }} + {% elif audio.translation_gap %} + {{ audio.source_text or '(click to add text)' }} + ⚠ {{ active_language_name }} gap: {{ audio.translation_gap_reason[:120] }} {% else %} {{ audio.source_text or '(click to add text)' }} ⚠ {{ active_language_name }} has not been translated yet