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