Files
UmbraAudio/tests/test_tts_language.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
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.
2026-11-06 16:55:49 -08:00

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"