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.
This commit is contained in:
Umbra Tools
2026-11-06 21:56:28 -08:00
parent 25605e2cad
commit ff73c99f8d
6 changed files with 492 additions and 34 deletions

View File

@@ -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())

View File

@@ -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 == {}

View File

@@ -646,6 +646,11 @@ class TranslationEntry:
origin: str = "sample" origin: str = "sample"
speaker: str = "" speaker: str = ""
type: 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 @property
def confirmed(self) -> bool: def confirmed(self) -> bool:
@@ -747,7 +752,7 @@ def _db_translation_rows(
""" """
SELECT u.chapter_id, u.utterance_index, u.text AS source_text, 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.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 FROM utterance_translations t
JOIN utterances u ON u.id = t.utterance_id JOIN utterances u ON u.id = t.utterance_id
""" """
@@ -766,6 +771,8 @@ def _db_translation_rows(
"origin": _col(r, "origin", 5), "origin": _col(r, "origin", 5),
"speaker_name": _col(r, "speaker_name", 6), "speaker_name": _col(r, "speaker_name", 6),
"language_code": _col(r, "language_code", 7), "language_code": _col(r, "language_code", 7),
"voiced": _col(r, "voiced", 8),
"review_reason": _col(r, "review_reason", 9),
} }
for r in rows for r in rows
] ]
@@ -796,7 +803,25 @@ def resolved_translations(
continue continue
source_text = row["source_text"] or "" source_text = row["source_text"] or ""
text = row["target_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 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 continue
if source_text and text.strip() == source_text.strip(): if source_text and text.strip() == source_text.strip():
# The English text stored under a target-language code: a # The English text stored under a target-language code: a
@@ -808,6 +833,8 @@ def resolved_translations(
status=row["status"] or STATUS_DRAFT, status=row["status"] or STATUS_DRAFT,
origin=row["origin"] or "umbratrans", origin=row["origin"] or "umbratrans",
speaker=row["speaker_name"] or "", speaker=row["speaker_name"] or "",
voiced=True,
reason=reason,
) )
# --- the on-disk sample files ---------------------------------------- # --- the on-disk sample files ----------------------------------------

View File

@@ -110,6 +110,8 @@ def _translation_rows(request: Request, code: str) -> list[dict]:
"text": entry.text, "text": entry.text,
"status": entry.status, "status": entry.status,
"origin": entry.origin, "origin": entry.origin,
"voiced": entry.voiced,
"reason": entry.reason,
} }
) )
return rows return rows
@@ -137,6 +139,53 @@ def _count_utterances(request: Request) -> int:
return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0] 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]: def _collect_issues(request: Request, code: str, text_count: int) -> list[dict]:
"""Issues that need fixing or confirming for *code*. """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 return issues
@@ -267,10 +330,14 @@ async def language_mockup_page(request: Request, lang: str = "de"):
"target_text": u["text"], "target_text": u["text"],
"source_text": src["text"] if src else "", "source_text": src["text"] if src else "",
"has_audio": audio_by_index.get(u["index"], False), "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"]) audio_have = sum(1 for r in rows if r["has_audio"])
text_pct = (text_have / total * 100) if total else 0.0 text_pct = (text_have / total * 100) if total else 0.0
audio_pct = (audio_have / total * 100) if total else 0.0 audio_pct = (audio_have / total * 100) if total else 0.0

View File

@@ -123,6 +123,7 @@ def _voice_maintenance_block() -> JSONResponse | None:
status_code=409, status_code=409,
) )
from umbraaudio.generation.audio_effects import ( from umbraaudio.generation.audio_effects import (
apply_effects_to_file_by_name, apply_effects_to_file_by_name,
load_speaker_distortion_effects, 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. # with no language tag is written in this language.
project_cols = {r[1] for r in db.execute("PRAGMA table_info(project)")} project_cols = {r[1] for r in db.execute("PRAGMA table_info(project)")}
if "source_language" not in project_cols: if "source_language" not in project_cols:
db.execute( db.execute("ALTER TABLE project ADD COLUMN source_language TEXT NOT NULL DEFAULT 'en'")
"ALTER TABLE project ADD COLUMN source_language "
"TEXT NOT NULL DEFAULT 'en'"
)
db.commit() db.commit()
@@ -2073,7 +2071,9 @@ async def speakers_list_partial(request: Request):
return False return False
from umbraaudio.voices.qwen_tts import QwenTTSEngine 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: try:
import httpx import httpx
@@ -2537,8 +2537,7 @@ async def delete_speaker(request: Request, index: int, reassign_to: str = ""):
( (
s s
for s in speakers for s in speakers
if s.get("primary_name", "").strip().casefold() if s.get("primary_name", "").strip().casefold() == reassign_to.strip().casefold()
== reassign_to.strip().casefold()
), ),
None, None,
) )
@@ -3131,6 +3130,7 @@ def _active_language_for_template(request: Request) -> str:
def _languages_for_template() -> list[tuple[str, str]]: def _languages_for_template() -> list[tuple[str, str]]:
"""Return the supported languages for the selector dropdown.""" """Return the supported languages for the selector dropdown."""
from umbraaudio.web.language_routes import SUPPORTED from umbraaudio.web.language_routes import SUPPORTED
return 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() has_translation = translated is not None and translated.text.strip()
display_text = translated.text if has_translation else source_text display_text = translated.text if has_translation else source_text
translation_status = translated.status if translated else None 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( audio_files.append(
{ {
@@ -3266,6 +3273,8 @@ async def chapter_audio_review(request: Request, chapter_dir: str, lang: str | N
"source_text": source_text, "source_text": source_text,
"translated_text": translated.text if has_translation else None, "translated_text": translated.text if has_translation else None,
"translation_status": translation_status, "translation_status": translation_status,
"translation_gap": translation_gap,
"translation_gap_reason": translation_gap_reason,
"has_translation": has_translation, "has_translation": has_translation,
"voice": voice, "voice": voice,
"emotion": u["emotion"] or "neutral", "emotion": u["emotion"] or "neutral",
@@ -3659,7 +3668,9 @@ async def generate_utterance_audio(request: Request, utt_id: int):
voice_ref = vf voice_ref = vf
break 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) 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(): if "kainda" in vf.stem.lower():
narrator_ref = vf narrator_ref = vf
break 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: for u in remaining:
out = audio_dir / f"utt_{u['utterance_index']:04d}.wav" out = audio_dir / f"utt_{u['utterance_index']:04d}.wav"
if is_scene_break(u["text"], u["type"]): if is_scene_break(u["text"], u["type"]):
@@ -3976,7 +3989,9 @@ async def create_utterance(request: Request, chapter_id: int):
narrator_ref = vf narrator_ref = vf
break break
try: 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: if narrator_ref:
engine.generate( engine.generate(
text=spoken_text, text=spoken_text,
@@ -4141,7 +4156,10 @@ async def set_voice_status(request: Request):
QwenTTSEngine, 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( slug = engine.build_voice_prompt(
ref_path, ref_path,
ref_text=VOICE_REFERENCE_TEXT, 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 from umbraaudio.web.voice_queue_routes import get_voice_queue
item = get_voice_queue(request).enqueue( item = get_voice_queue(request).enqueue(name, row_index=body.get("row_index"))
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") @router.post("/api/speakers/generate-all-voices")
@@ -5212,6 +5230,7 @@ async def run_full_pipeline(request: Request):
from umbraaudio.analysis.clarity import ( from umbraaudio.analysis.clarity import (
assess_utterance_clarity, assess_utterance_clarity,
) )
try: try:
alias_map: dict[str, str] = {} alias_map: dict[str, str] = {}
with open(speakers_path) as f: with open(speakers_path) as f:
@@ -5222,9 +5241,7 @@ async def run_full_pipeline(request: Request):
for a in s.get("aliases", []): for a in s.get("aliases", []):
if a: if a:
alias_map[a.lower()] = primary alias_map[a.lower()] = primary
attributed = assess_utterance_clarity( attributed = assess_utterance_clarity(attributed, ch_id, alias_map)
attributed, ch_id, alias_map
)
except Exception: except Exception:
pass pass
for u in attributed: for u in attributed:
@@ -5412,9 +5429,7 @@ async def download_mp3_package(request: Request):
tracks = sorted(package_dir.glob("*.mp3")) tracks = sorted(package_dir.glob("*.mp3"))
if not tracks: if not tracks:
return JSONResponse( return JSONResponse({"error": "MP3 package is empty. Rebuild it first."}, status_code=404)
{"error": "MP3 package is empty. Rebuild it first."}, status_code=404
)
# AppConfig has no project_name; the directory name is the project slug. # 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("_") 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( return StreamingResponse(
_iter_zip(), _iter_zip(),
media_type="application/zip", media_type="application/zip",
headers={ headers={"Content-Disposition": f'attachment; filename="{safe}_mp3_package.zip"'},
"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", []): for a in s.get("aliases", []):
if a: if a:
alias_map[a.lower()] = primary alias_map[a.lower()] = primary
attributed = assess_utterance_clarity( attributed = assess_utterance_clarity(attributed, ch_id, alias_map)
attributed, ch_id, alias_map
)
except Exception: except Exception:
pass pass

View File

@@ -190,6 +190,10 @@
{% if audio.has_translation %} {% if audio.has_translation %}
<small style="color:#e0e0e0">{{ audio.display_text }}</small> <small style="color:#e0e0e0">{{ audio.display_text }}</small>
<span style="color:#22c55e; font-size:0.7rem; margin-left:0.5rem">✓ {{ active_language_name }}</span> <span style="color:#22c55e; font-size:0.7rem; margin-left:0.5rem">✓ {{ active_language_name }}</span>
{% elif audio.translation_gap %}
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
<span style="color:#f97316; font-size:0.7rem; margin-left:0.5rem"
title="{{ audio.translation_gap_reason }}">⚠ {{ active_language_name }} gap: {{ audio.translation_gap_reason[:120] }}</span>
{% else %} {% else %}
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small> <small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
<span style="color:#f59e0b; font-size:0.7rem; margin-left:0.5rem">⚠ {{ active_language_name }} has not been translated yet</span> <span style="color:#f59e0b; font-size:0.7rem; margin-left:0.5rem">⚠ {{ active_language_name }} has not been translated yet</span>