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:
204
scripts/probe_cross_language_tts.py
Normal file
204
scripts/probe_cross_language_tts.py
Normal 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())
|
||||
145
tests/test_translation_gaps.py
Normal file
145
tests/test_translation_gaps.py
Normal 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 == {}
|
||||
@@ -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 ----------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
return JSONResponse({
|
||||
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,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
{% if audio.has_translation %}
|
||||
<small style="color:#e0e0e0">{{ audio.display_text }}</small>
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user