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"
|
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 ----------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user