The review UI's language dropdown did nothing: every selection kept rendering the English text. Two independent causes, both fixed. 1. The translations were never being read. The pipeline's `utterance_translations` table holds one row in Deal Hunter, and that row stores the *English* text under a `zh-Hans` code. The real translations live in the on-disk sample files under `samples/translations/`. `umbraaudio/translations.py` now resolves both sources and merges them by (chapter_id, utterance_index): the table wins where it holds a real translation, the sample files fill the gaps. A candidate whose text is identical to the source is a placeholder, not a translation, so it is ignored - otherwise the page would show English under a Chinese heading and claim it was translated. 2. The query that read them was broken. `language_routes.py` selected `u.index` and `u.speaker`; the columns are `utterance_index` and `speaker_name`, so every read raised `sqlite3.OperationalError: near "index": syntax error` and `/language-mockup` returned 500. It also opened `<project>/project.db` - a 0-byte decoy - instead of `<project>/.umbra/project.db`, the file that actually holds the data. Also in this commit: * The chapter review page honours `?lang=`, remembers the choice in a cookie (set by a middleware, so it survives on every page), and shows the selected language's *name* in the "has not been translated yet" message. * The page now shows the translation, not the source, and the edit box is bound to the source text so saving cannot overwrite the English with the Chinese. * The stray language selector that sat after `</html>` in base.html is gone; the chapters-list page's dropdown had zero options because the route never passed the language list. * The language tag is wired into generation: `utterance.language_code` (NULL = project source language) reaches `engine.generate()` and the staleness marker, so a retagged line is re-rendered instead of keeping English phonology behind an unchanged text. The marker records the language; a marker written before the tag existed means English, so the ~4,300 existing clips are not invalidated. * `project.source_language` (spec 31 section 13.1) records the book's own language, with a migration for existing project DBs. * A scene-break marker deliberately records no language: it is silence, identical in every language, and tagging it would claim a phonology the audio does not have. Tests: 883 passed / 8 failed - the same 8 pre-existing failures (2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests cover the resolver and the generation wiring. ruff introduces no new findings.
153 lines
4.6 KiB
Python
153 lines
4.6 KiB
Python
"""Per-utterance language in the TTS call (spec 31 section 4.3 / section 9).
|
|
|
|
Two guarantees are pinned here:
|
|
|
|
1. **Refuse, never wrong.** A language Qwen3-TTS cannot speak is rejected before
|
|
any network call or filesystem write, so an unsupported tag leaves a visible
|
|
refusal instead of a silent English render.
|
|
2. **Byte-identical when varied.** With no language passed, the payload does not
|
|
change, so existing renders are untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import wave
|
|
|
|
import pytest
|
|
|
|
from umbraaudio import languages as lang
|
|
from umbraaudio.voices.qwen_tts import QwenTTSEngine
|
|
|
|
|
|
def test_gap_language_refused_before_touching_the_network(tmp_path) -> None:
|
|
engine = QwenTTSEngine()
|
|
out = tmp_path / "utt_0001.wav"
|
|
with pytest.raises(ValueError) as exc:
|
|
engine.generate("Hallo wereld.", out, language="nl")
|
|
assert "Dutch" in str(exc.value)
|
|
assert not out.exists(), "refusal must happen before any output is written"
|
|
|
|
|
|
def test_unrecognised_language_is_refused(tmp_path) -> None:
|
|
engine = QwenTTSEngine()
|
|
out = tmp_path / "utt_0002.wav"
|
|
with pytest.raises(ValueError) as exc:
|
|
engine.generate("Whatever.", out, language="klingon")
|
|
assert "unrecognised" in str(exc.value)
|
|
assert not out.exists()
|
|
|
|
|
|
def test_voiceable_languages_pass_the_refusal_guard() -> None:
|
|
for code in ("en", "zh", "ja", "ko", "de", "es", "fr", "ru", "it", "pt"):
|
|
assert lang.refusal_reason(code) is None
|
|
|
|
|
|
def test_resolved_name_is_what_the_model_needs() -> None:
|
|
"""A code must become the model's own string, not the code itself."""
|
|
assert lang.tts_name("es") == "spanish"
|
|
assert lang.tts_name("de") == "german"
|
|
assert lang.tts_name("zh") == "chinese"
|
|
|
|
|
|
def _tiny_wav() -> bytes:
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(24000)
|
|
w.writeframes(b"\x00\x00" * 240)
|
|
return buf.getvalue()
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, content: bytes) -> None:
|
|
self.status_code = 200
|
|
self.content = content
|
|
self.headers = {"content-type": "audio/wav"}
|
|
self.text = ""
|
|
|
|
|
|
def test_remote_payload_sends_language_when_varied(tmp_path) -> None:
|
|
engine = QwenTTSEngine()
|
|
captured: dict = {}
|
|
|
|
class _Resp(_FakeResponse):
|
|
def __init__(self) -> None:
|
|
super().__init__(_tiny_wav())
|
|
|
|
import httpx
|
|
|
|
def fake_post(url, data=None, files=None, timeout=None): # noqa: ANN001
|
|
captured["url"] = url
|
|
captured["data"] = data
|
|
return _Resp()
|
|
|
|
original = httpx.post
|
|
httpx.post = fake_post # type: ignore[assignment]
|
|
try:
|
|
engine._remote_generate_at(
|
|
url="http://127.0.0.1:9/v1",
|
|
text="Hola, amigo.",
|
|
files=None,
|
|
instruct=None,
|
|
ref_text=None,
|
|
prompt_slug=None,
|
|
temperature=0.9,
|
|
do_sample=True,
|
|
seed=None,
|
|
language="spanish",
|
|
)
|
|
finally:
|
|
httpx.post = original # type: ignore[assignment]
|
|
|
|
assert captured["data"]["language"] == "spanish"
|
|
|
|
|
|
def test_remote_payload_omits_language_when_not_given(tmp_path) -> None:
|
|
"""No language passed -> payload unchanged, so existing renders are identical."""
|
|
engine = QwenTTSEngine()
|
|
captured: dict = {}
|
|
|
|
import httpx
|
|
|
|
class _Resp(_FakeResponse):
|
|
def __init__(self) -> None:
|
|
super().__init__(_tiny_wav())
|
|
|
|
def fake_post(url, data=None, files=None, timeout=None): # noqa: ANN001
|
|
captured["data"] = data
|
|
return _Resp()
|
|
|
|
original = httpx.post
|
|
httpx.post = fake_post # type: ignore[assignment]
|
|
try:
|
|
engine._remote_generate_at(
|
|
url="http://127.0.0.1:9/v1",
|
|
text="Plain English line.",
|
|
files=None,
|
|
instruct=None,
|
|
ref_text=None,
|
|
prompt_slug=None,
|
|
temperature=0.9,
|
|
do_sample=True,
|
|
seed=None,
|
|
language=None,
|
|
)
|
|
finally:
|
|
httpx.post = original # type: ignore[assignment]
|
|
|
|
assert "language" not in captured["data"]
|
|
|
|
|
|
def test_local_clone_defaults_to_english_when_not_given() -> None:
|
|
"""The local paths keep their historical default rather than becoming None."""
|
|
import inspect
|
|
|
|
from umbraaudio.voices import qwen_tts as mod
|
|
|
|
clone = inspect.signature(mod.QwenTTSEngine._generate_clone)
|
|
vd = inspect.signature(mod.QwenTTSEngine._generate_voicedesign)
|
|
assert clone.parameters["language"].default == "english"
|
|
assert vd.parameters["language"].default == "english"
|