From 2b294d7fa45a141a5993b88cd917e2e81113bff5 Mon Sep 17 00:00:00 2001 From: "B.D. Murphy" <161863146+bdmurphy73@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:55:49 -0800 Subject: [PATCH] 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.db` - a 0-byte decoy - instead of `/.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 `` 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. --- samples/translations/speakers.json | 139 ++++ scripts/generate_all_audio.py | 15 +- .../31-utterance-language-and-voiced-flag.md | 238 ++++++ tests/test_generation/test_language_wiring.py | 195 +++++ tests/test_generation/test_staleness.py | 67 ++ tests/test_languages.py | 101 +++ tests/test_translation_resolution.py | 266 ++++++ tests/test_translations.py | 366 +++++++++ tests/test_tts_language.py | 152 ++++ umbraaudio/database.py | 90 ++- umbraaudio/generation/chapter_builder.py | 27 +- umbraaudio/generation/scene_break.py | 9 +- umbraaudio/generation/staleness.py | 21 + umbraaudio/languages.py | 130 +++ umbraaudio/translations.py | 765 ++++++++++++++++++ umbraaudio/voices/qwen_tts.py | 39 +- umbraaudio/web/__init__.py | 28 +- umbraaudio/web/language_routes.py | 196 +++-- umbraaudio/web/routes.py | 102 ++- .../web/templates/chapter_audio_detail.html | 24 +- 20 files changed, 2875 insertions(+), 95 deletions(-) create mode 100644 samples/translations/speakers.json create mode 100644 specs/31-utterance-language-and-voiced-flag.md create mode 100644 tests/test_generation/test_language_wiring.py create mode 100644 tests/test_languages.py create mode 100644 tests/test_translation_resolution.py create mode 100644 tests/test_translations.py create mode 100644 tests/test_tts_language.py create mode 100644 umbraaudio/languages.py create mode 100644 umbraaudio/translations.py diff --git a/samples/translations/speakers.json b/samples/translations/speakers.json new file mode 100644 index 0000000..09e9834 --- /dev/null +++ b/samples/translations/speakers.json @@ -0,0 +1,139 @@ +{ + "speakers": [ + { + "name": "Kainda Walters Foster Murphy", + "type": "narration", + "voice_ref": "kainda_walters_foster_murphy_ref.wav" + }, + { + "name": "narrator", + "type": "narration", + "voice_ref": "narrator_ref.wav" + }, + { + "name": "Deal Hunter", + "type": "narration", + "voice_ref": "deal_hunter_ref.wav" + }, + { + "name": "Nanny", + "type": "narration", + "voice_ref": "nanny_ref.wav" + }, + { + "name": "Princess Kainda", + "type": "dialogue", + "voice_ref": "princess_kainda_ref.wav" + }, + { + "name": "Duchess Victoria", + "type": "narration", + "voice_ref": "duchess_victoria_ref.wav" + }, + { + "name": "Adric", + "type": "dialogue", + "voice_ref": "adric_ref.wav" + }, + { + "name": "Kabir", + "type": "dialogue", + "voice_ref": "kabir_ref.wav" + }, + { + "name": "Adonai Maul", + "type": "dialogue", + "voice_ref": "adonai_maul_ref.wav" + }, + { + "name": "Xu Ming", + "type": "dialogue", + "voice_ref": "xu_ming_ref.wav" + }, + { + "name": "Willamina Wake", + "type": "dialogue", + "voice_ref": "willamina_wake_ref.wav" + }, + { + "name": "Distorted Voice", + "type": "dialogue", + "voice_ref": "distorted_voice_ref.wav" + }, + { + "name": "Worker Robot (Bill)", + "type": "dialogue", + "voice_ref": "worker_robot_(bill)_ref.wav" + }, + { + "name": "Captain Weston", + "type": "dialogue", + "voice_ref": "captain_weston_ref.wav" + }, + { + "name": "Security Administrator", + "type": "dialogue", + "voice_ref": "security_administrator_ref.wav" + }, + { + "name": "Pirate Clan Leader", + "type": "dialogue", + "voice_ref": "pirate_clan_leader_ref.wav" + }, + { + "name": "Narrator", + "type": "dialogue", + "voice_ref": "narrator_ref.wav" + }, + { + "name": "Trader Wakam", + "type": "dialogue", + "voice_ref": "trader_wakam_ref.wav" + }, + { + "name": "Service Robot", + "type": "dialogue", + "voice_ref": "service_robot_ref.wav" + }, + { + "name": "Admiral Wilbur Wake", + "type": "dialogue", + "voice_ref": "admiral_wilbur_wake_ref.wav" + }, + { + "name": "Pilot (Freighter)", + "type": "dialogue", + "voice_ref": "pilot_(freighter)_ref.wav" + }, + { + "name": "Trader Romano", + "type": "narration", + "voice_ref": "trader_romano_ref.wav" + }, + { + "name": "Frigate Captain", + "type": "dialogue", + "voice_ref": "frigate_captain_ref.wav" + }, + { + "name": "Crew Member", + "type": "dialogue", + "voice_ref": "crew_member_ref.wav" + }, + { + "name": "Brutus", + "type": "dialogue", + "voice_ref": "brutus_ref.wav" + }, + { + "name": "Guard Detail Speaker 1", + "type": "dialogue", + "voice_ref": "guard_detail_speaker_1_ref.wav" + }, + { + "name": "Guard Detail Speaker 2", + "type": "dialogue", + "voice_ref": "guard_detail_speaker_2_ref.wav" + } + ] +} \ No newline at end of file diff --git a/scripts/generate_all_audio.py b/scripts/generate_all_audio.py index 36c42de..e4500c0 100644 --- a/scripts/generate_all_audio.py +++ b/scripts/generate_all_audio.py @@ -139,10 +139,16 @@ def generate_chapter( title = chapter["title"] or f"Chapter {chapter['chapter_index'] + 1}" utterances = db.execute( - "SELECT id, speaker_name, text, utterance_index, emotion, pace, instruct " + "SELECT id, speaker_name, text, utterance_index, emotion, pace, instruct, " + "language_code " "FROM utterances WHERE chapter_id = ? ORDER BY utterance_index", (chapter_id,), ).fetchall() + # The project's source language. An utterance with no language tag is + # written in it (spec 31 section 6.1). + from umbraaudio.translations import source_language + + project_language = source_language(db) db.close() chapter_dir = output_base / f"ch{chapter_id}" @@ -189,9 +195,13 @@ def generate_chapter( fx_version = EMOTION_FX_VERSION if (emotion_fx and utt["emotion"]) else None + # The language the line is written in (spec 31 source axis). + line_language = utt["language_code"] or project_language + if is_current( spoken_text, output_path, speaker=speaker, voice_ref=ref_path, instruct=instruct, fx_version=fx_version, + language=line_language, ): skipped += 1 if utterance_callback: @@ -207,12 +217,14 @@ def generate_chapter( voice_ref=ref_path, ref_text=VOICE_REFERENCE_TEXT, instruct=instruct, + language=line_language, ) else: engine.generate( text=spoken_text, output_path=output_path, instruct=instruct or "A calm, neutral voice speaking clearly.", + language=line_language, ) if output_path.exists(): @@ -230,6 +242,7 @@ def generate_chapter( voice_ref=ref_path, voice_mode="embedding" if ref_path else None, instruct=instruct, fx_version=fx_version, + language=line_language, ) generated += 1 utt_success = True diff --git a/specs/31-utterance-language-and-voiced-flag.md b/specs/31-utterance-language-and-voiced-flag.md new file mode 100644 index 0000000..c8f5a42 --- /dev/null +++ b/specs/31-utterance-language-and-voiced-flag.md @@ -0,0 +1,238 @@ +# Spec 31 — Utterance source language and the voiced flag + +**Status:** agreed 2026-10-17 (operator decisions recorded in §4), being implemented. +Extends spec 30, which remains the *target*-language design. + +--- + +## 1. The two axes, kept apart + +The single most important thing in this spec. Conflating these two is the design error it +exists to prevent. + +| | **Source axis** (this spec) | **Target axis** (spec 30) | +|---|---|---| +| Question | What language is this utterance's text *written in*? | What language are we rendering it *into*? | +| Example | A Spanish line inside an English book | The whole book in German | +| Where it lives | `utterances.language_code` | `utterance_translations.language_code` | +| Translated? | **No** — it is story content | Yes, that is the point | +| Cardinailty | one per utterance | one per (utterance, target language) | + +A Spanish phrase in a Chinese scene is **not a translation target**. It is source content +that must be *spoken in Spanish* and must be *excluded from translation*. "Bob overhears +them speaking in German" is the test case: the German is narrative payload. The reader is +meant to read it. Translating it destroys the scene. + +Both axes are needed. They do not conflict, and they are not the same column. + +## 2. What exists today — measured, not assumed + +| Thing | State | +|---|---| +| `utterances` (Deal Hunter) | **4,319 rows, no language column** — one `text`, implicitly English | +| `qwen_tts.py` language | **hardcoded `language="english"`** at lines 494 and 522 — never varies per utterance | +| `/v1/tts` language | `language: str = Form("english")`, free-form, **not validated** against the supported set | +| `utterance_translations` | **does not exist** (spec 30 is a proposal; only a mockup route is built) | +| translation export/import | **does not exist** | +| language in config | **none** — the source language is the constant `SOURCE_LANGUAGE = "en"` in the mockup | +| `characters.status = 'voiced'` | **already means "this speaker has a voice reference"** — a *different* concept, at speaker grain | +| mockup "translated but not voiced" | **pipeline progress** (text delivered, audio not yet rendered) — also *not* this flag | +| sample translations | 10 languages delivered as samples; the **5 gap languages have none** | + +Three different things in this tree say "voiced" and mean different things. §6 gives this +flag an unambiguous name for that reason. + +## 3. The constraint that shapes the design + +`is_current()` (`umbraaudio/generation/staleness.py`) compares the marker's recorded `text` +against the utterance's text. Spec 30 §2 established that audio must be namespaced per +language or two languages destroy each other's markers. + +This spec adds a second, finer requirement: **language must be in the marker**, because an +utterance's language can be corrected *after* its audio was made. A Spanish line voiced as +English is byte-comparable (the text is unchanged) but acoustically wrong. Without +`language` in the marker, correcting the tag would not invalidate the audio, and the wrong +render would survive every subsequent check. + +## 4. Decisions (operator, 2026-10-17) + +1. **The voiced flag is per-language.** It lives with the translation row, not on the + utterance. The source language always voices everything, so a single utterance-level + flag cannot express "voiced in English, not in German" — which is exactly the + sentence-collapse case. +2. **The source language tag comes from UmbraTrans during translation.** UmbraTrans is the + authority on identification. See §9 for the sequencing consequence this creates. +3. **A tag in an unsupported language is allowed and recorded; the render is refused.** + Never emit a degraded voice. Fail loud, never wrong. +4. **Scope: source side + translation alignment.** + +## 5. Language registry + +One module, one source of truth: `umbraaudio/languages.py`. + +| Class | Languages | Voiceable? | +|---|---|---| +| Qwen3-TTS (10) | Chinese, English, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish | yes | +| Gap (5) | Dutch, Norwegian, Polish, Arabic, Hindi | **no** — tag recorded, render refused | + +`sources` — where a tag can originate — is recorded so a reviewer can tell a human +correction from a machine identification: + +| origin | meaning | +|---|---| +| `unknown` | no tag yet (default) | +| `umbratrans` | identified by the controller during translation | +| `operator` | corrected by hand | + +The registry is the only place the supported set is written down. `/v1/tts` validation +(spec 30 §8) and the export filter both read it, so they cannot disagree. + +## 6. Schema + +### 6.1 `utterances` gains the source tag + +```sql +ALTER TABLE utterances ADD COLUMN language_code TEXT; -- NULL = project source language +ALTER TABLE utterances ADD COLUMN language_origin TEXT; -- 'unknown' | 'umbratrans' | 'operator' +``` + +NULL means "the project's source language", which keeps all 4,319 existing rows correct +with no backfill and no ambiguity. A non-NULL value is a *deliberate* claim that the text is +in another language. + +**Not named `voiced`.** The per-language flag lives on the translation row (§6.2), and the +word `voiced` is already taken twice in this tree (§2). + +### 6.2 `utterance_translations` gains the flag + +Spec 30 §3.2's table, verbatim, plus one column: + +```sql +CREATE TABLE IF NOT EXISTS utterance_translations ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL, + utterance_id INTEGER NOT NULL REFERENCES utterances(id), + language_code TEXT NOT NULL, -- target language, 'de' + language_name TEXT NOT NULL, -- 'German' + text TEXT NOT NULL, + status TEXT NOT NULL, -- 'draft' | 'confirmed' + origin TEXT NOT NULL, -- 'umbratrans' | 'manual' + source_ref TEXT, + delivered_at TEXT, + confirmed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + voiced INTEGER NOT NULL DEFAULT 1, -- NEW: render audio for this row? + review_reason TEXT, -- NEW: why it is not voiced + UNIQUE(utterance_id, language_code) +); +``` + +`voiced = 0` means *this* target language does not voice this utterance. The canonical case +is collapse: the source used three sentences, the target needs two, so one source utterance +has no target counterpart. It is **not deleted** — deleting it would lose the alignment and +make the gap unreconstructable. It is marked not-voiced with a reason and surfaced for +review. + +The default is `1`, so every ordinary translation is voiced and nothing changes for the +common case. + +### 6.3 Nothing else moves + +English audio stays at `.umbra/chapter_audio/`. No existing column is renamed or dropped. +Zero regression risk to the pipeline that works today — the same reasoning spec 30 §4 used. + +## 7. Export contract (UmbraAudio → UmbraTrans) + +One row per utterance. This extends spec 30 §7 rather than replacing it. + +``` +{utterance_id, chapter_id, chapter_index, utterance_index, speaker_name, + source_text, context_before, context_after, + language_code, -- NEW: non-null only when already known + book_language} -- NEW: the project's primary language +``` + +`language_code` is sent when known so a re-delivery does not ask UmbraTrans to re-identify +a line the operator already corrected. It is null on the first pass, which is normal. + +## 8. Import contract (UmbraTrans → UmbraAudio) + +`POST /api/import/translations`. Idempotent via `UNIQUE(utterance_id, language_code)`. +Refuses to silently overwrite a `confirmed` row. + +Per utterance, UmbraTrans may return: + +| field | meaning | +|---|---| +| `source_language` | identified language of the *original* text. Stored on `utterances`. | +| `text` + `language_code` | a translation *into* `language_code`. Stored in `utterance_translations`. | +| `voiced: false` + `reason` | this utterance has no target counterpart (collapse). | +| *(no `text`)* | the utterance is in a non-primary language — **not translated, by design**. | + +The last row is the whole point of the source axis: a Spanish line comes back with +`source_language: "es"` and **no translation**. That is a correct, expected response, not an +omission to be chased. + +An `source_language` in the gap set (Dutch, Norwegian, Polish, Arabic, Hindi) is stored and +recorded (§4.3), and the render is refused. It is reported as an explicit refusal, not as a +failure of the import. + +## 9. The sequencing consequence (stated plainly) + +Because the tag arrives *during translation* (§4.2), the source-language master is voiced +**before any tag exists**. So today's English master of a bilingual book contains at least +one wrong-voice line: a Spanish phrase read with English phonology. + +That line is corrected only after a translation round-trip returns `es`. For the correction +to actually happen, `language` must be in the staleness marker (§10) — otherwise the audio +is never invalidated and the wrong render survives indefinitely. + +This is a consequence of the decision, not a defect in it. It is recorded here so nobody +later reads "the English master is complete" as "the English master is correct". + +## 10. Staleness + +`write_marker()` records `language` (the effective language the clip was synthesised in); +`is_current()` compares it. A clip whose utterance language has changed since it was made is +stale, even when its text is byte-identical. + +This is the same class of bug the release work already fixed: a *declared* value +(the marker says English) disagreeing with a *measured* one (the tag now says Spanish) must +force a re-render, not pass silently. + +## 11. Refuse-render is loud + +An utterance whose language cannot be voiced is **not silently skipped**. It appears: + +- in a count on the page (`N utterances cannot be voiced in `), +- in a list, with the language and the reason (`no Qwen3-TTS voice for 'nl'`), +- and in the pipeline's completion report. + +A deliberate hole that nobody can see is indistinguishable from a bug. The operator chose +fail-loud (§4.3); this is the part that makes it actually loud. + +## 12. What this does not do + +- **Does not translate anything.** UmbraTrans does the translating; this stores what comes + back and honours the source axis. +- **Does not implement spec 30's UI language selector.** That remains spec 30, which can now + build on a schema that already knows what "the source language" means per utterance. +- **Does not backfill a language onto existing rows.** Every existing utterance is correctly + NULL = source language. Backfilling would assert an identification nobody performed. +- **Does not choose a voice for a foreign passage.** A character keeps their own voice + (spec 29); only the *language* of the phonology changes. + +## 13. Open questions + +1. **Book-level source language.** Currently a constant (`"en"`). Should it become a column + on `projects`, set at parse time? Needed to know what "not the primary language" means + per book. Proposed: yes, column on `projects`, defaulting to `en`. +2. **Character voice for a foreign line.** A character with an Australian accent reading + Spanish — spec 29 says characters keep their accent. Does "accent" mean *English-accented + Spanish*, or native Spanish in that character's timbre? Spec 29 leans to the latter for + the narrator and the former for characters; unresolved for in-dialogue code-switching. +3. **Detecting a missing language tag.** If UmbraTrans never runs, no foreign line is ever + tagged and the master is silently all-English. A cheap heuristic (non-Latin script, + dictionary miss) could flag candidates for review. Out of scope; noted because §9's + correction path never fires without a tag. diff --git a/tests/test_generation/test_language_wiring.py b/tests/test_generation/test_language_wiring.py new file mode 100644 index 0000000..65d14ed --- /dev/null +++ b/tests/test_generation/test_language_wiring.py @@ -0,0 +1,195 @@ +"""The per-utterance language tag must reach the TTS engine and the marker. + +Spec 31's source axis: an utterance tagged with the language it is *written in* +must be voiced in that language, and the marker must record it so a later +retag invalidates the clip instead of leaving English phonology behind an +unchanged text. +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +import pytest + +from umbraaudio.generation import chapter_builder as cb + + +class _FakeConfig: + def __init__(self, data_dir: Path) -> None: + self.data_dir = data_dir + self.project_dir = data_dir.parent + self.tts = type( + "T", + (), + {"qwen_tts_url": "http://127.0.0.1:1", "qwen_tts_helper_url": ""}, + )() + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + """A minimal project DB with one English line and one Spanish line.""" + data_dir = tmp_path / ".umbra" + data_dir.mkdir() + con = sqlite3.connect(str(data_dir / "project.db")) + con.executescript( + """ + CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT, source_language TEXT DEFAULT 'en'); + CREATE TABLE chapters ( + id INTEGER PRIMARY KEY, + chapter_index INTEGER, + title TEXT, + source_text TEXT + ); + CREATE TABLE utterances ( + id INTEGER PRIMARY KEY, + chapter_id INTEGER, + utterance_index INTEGER, + text TEXT, + speaker_name TEXT, + emotion TEXT, + pace TEXT, + instruct TEXT, + type TEXT, + source_unit_key TEXT, + language_code TEXT, + language_origin TEXT, + context_before TEXT, + context_after TEXT + ); + INSERT INTO project (id, name, source_language) VALUES (1, 'Test', 'en'); + INSERT INTO chapters (id, chapter_index, title, source_text) + VALUES (1, 0, 'One', 'The ship landed. Hola, amigo.'); + INSERT INTO utterances + (id, chapter_id, utterance_index, text, speaker_name, type, language_code) + VALUES + (1, 1, 0, 'The ship landed.', 'Narrator', 'narration', NULL), + (2, 1, 1, 'Hola, amigo.', 'Kabir', 'narration', 'es'); + """ + ) + con.commit() + con.close() + return tmp_path + + +def _builder(project: Path) -> Any: + return cb.ChapterBuilder(_FakeConfig(project / ".umbra"), chapter_id=1) + + +def test_untranslated_line_uses_the_project_source_language(project: Path) -> None: + builder = _builder(project) + missing = builder._validate_utterances() + # Both lines need generating (no audio on disk yet); the point is that + # the validation pass did not crash and found the right chapter. + assert len(missing) == 2 + + +def test_retagged_line_goes_stale_even_when_the_text_is_unchanged(project: Path) -> None: + """The marker records the language, so a retag invalidates the clip.""" + from umbraaudio.generation.staleness import write_marker + + audio_dir = project / ".umbra" / "chapter_audio" / "ch1" + audio_dir.mkdir(parents=True) + wav = audio_dir / "utt_0001.wav" + wav.write_bytes(b"RIFF----WAVEfmt ") + + # The clip was rendered as English. + write_marker("Hola, amigo.", wav, speaker="Kabir", language="en") + + builder = _builder(project) + missing = builder._validate_utterances() + indexes = {u["utterance_index"] for u in missing} + assert 1 in indexes, "a Spanish line rendered in English must be regenerated" + + +def test_a_line_already_rendered_in_its_tagged_language_is_current(project: Path) -> None: + from umbraaudio.generation.staleness import write_marker + + audio_dir = project / ".umbra" / "chapter_audio" / "ch1" + audio_dir.mkdir(parents=True) + + english = audio_dir / "utt_0000.wav" + english.write_bytes(b"RIFF----WAVEfmt ") + write_marker("The ship landed.", english, speaker="Narrator", language="en") + + spanish = audio_dir / "utt_0001.wav" + spanish.write_bytes(b"RIFF----WAVEfmt ") + write_marker("Hola, amigo.", spanish, speaker="Kabir", language="es") + + builder = _builder(project) + missing = builder._validate_utterances() + assert missing == [], f"unexpected regeneration: {missing}" + + +def test_legacy_marker_without_a_language_is_still_current_for_english( + project: Path, +) -> None: + """Existing clips must not all be invalidated by adding the tag. + + A marker written before the language tag existed records no language; + that audio was English, so absence means English (spec 31 section 10). + """ + audio_dir = project / ".umbra" / "chapter_audio" / "ch1" + audio_dir.mkdir(parents=True) + wav = audio_dir / "utt_0000.wav" + wav.write_bytes(b"RIFF----WAVEfmt ") + + # Simulate a legacy marker: no language key at all. + marker = wav.with_suffix(".wav.txt") + marker.write_text(json.dumps({"speaker": "Narrator", "text": "The ship landed."})) + + builder = _builder(project) + missing = builder._validate_utterances() + indexes = {u["utterance_index"] for u in missing} + assert 0 not in indexes + + +def test_engine_receives_the_language_the_line_is_written_in( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The language must reach the TTS engine, not just the marker.""" + import numpy as np + import soundfile as sf + + import umbraaudio.voices.qwen_tts as qwen_tts + + calls: list[dict[str, Any]] = [] + + class _FakeEngine: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def build_voice_prompt(self, *args: Any, **kwargs: Any) -> str: + return "prompt-slug" + + def generate(self, **kwargs: Any) -> Path: + calls.append(kwargs) + out = kwargs["output_path"] + out.parent.mkdir(parents=True, exist_ok=True) + sf.write(str(out), np.zeros(2400, dtype=np.float32), 24000) + return out + + monkeypatch.setattr(qwen_tts, "QwenTTSEngine", _FakeEngine) + + builder = _builder(project) + # The health monitor would race the generation loop against a TTS server + # that does not exist; the builder's own flag is what matters here. + monkeypatch.setattr(builder, "_start_health_monitor", lambda: None) + builder._tts_healthy = True + + missing = builder._validate_utterances() + builder._generate_utterances(missing) + + languages = {c["text"]: c["language"] for c in calls} + assert languages["The ship landed."] == "en" + assert languages["Hola, amigo."] == "es" + + # The marker records the same language, so a later retag invalidates it. + marker = json.loads( + (project / ".umbra" / "chapter_audio" / "ch1" / "utt_0001.wav.txt").read_text() + ) + assert marker["language"] == "es" + diff --git a/tests/test_generation/test_staleness.py b/tests/test_generation/test_staleness.py index aede86b..abf67de 100644 --- a/tests/test_generation/test_staleness.py +++ b/tests/test_generation/test_staleness.py @@ -226,3 +226,70 @@ def test_sha256_verification_is_opt_in_and_detects_tampering(tmp_path: Path) -> wav.write_bytes(b"RIFF-tampered") assert is_current("Same words.", wav, speaker="Narrator") is True assert is_current("Same words.", wav, speaker="Narrator", verify_sha256=True) is False + + +def test_language_recorded_in_marker(tmp_path: Path) -> None: + import json + + wav = tmp_path / "utt_0040.wav" + wav.write_bytes(b"RIFF") + write_marker("Hola, amigo.", wav, speaker="Kabir", language="es") + payload = json.loads(marker_path(wav).read_text()) + assert payload["language"] == "es" + + +def test_omitting_language_keeps_markers_byte_identical(tmp_path: Path) -> None: + """Existing callers pass no language; their markers must not change shape.""" + import json + + wav = tmp_path / "utt_0041.wav" + write_marker("Some text", wav, speaker="Narrator") + payload = json.loads(marker_path(wav).read_text()) + assert "language" not in payload + + +def test_changed_language_is_stale(tmp_path: Path) -> None: + """A retagged line must re-render even when its text is untouched. + + The audio is wrong (English phonology) while the text is identical, so only + the language in the marker can invalidate it (spec 31 section 10). + """ + wav = tmp_path / "utt_0042.wav" + wav.write_bytes(b"RIFF") + write_marker("Bonjour, ami.", wav, speaker="Kabir", language="en") + assert is_current("Bonjour, ami.", wav, speaker="Kabir", language="en") is True + assert is_current("Bonjour, ami.", wav, speaker="Kabir", language="fr") is False + + +def test_matching_language_is_current(tmp_path: Path) -> None: + wav = tmp_path / "utt_0043.wav" + wav.write_bytes(b"RIFF") + write_marker("Hola, amigo.", wav, speaker="Kabir", language="es") + assert is_current("Hola, amigo.", wav, speaker="Kabir", language="es") is True + + +def test_legacy_english_marker_stays_current(tmp_path: Path) -> None: + """Markers written before the language tag exist stay valid. + + Absence means English, so ~4,300 existing clips are not invalidated by this + feature. + """ + wav = tmp_path / "utt_0044.wav" + wav.write_bytes(b"RIFF") + write_marker("Hello there.", wav, speaker="Narrator") + assert is_current("Hello there.", wav, speaker="Narrator", language="en") is True + + +def test_legacy_marker_is_stale_when_a_foreign_language_is_expected(tmp_path: Path) -> None: + wav = tmp_path / "utt_0045.wav" + wav.write_bytes(b"RIFF") + write_marker("Hola, amigo.", wav, speaker="Kabir") + assert is_current("Hola, amigo.", wav, speaker="Kabir", language="es") is False + + +def test_language_not_compared_when_caller_omits_it(tmp_path: Path) -> None: + """Callers not yet language-aware keep working exactly as before.""" + wav = tmp_path / "utt_0046.wav" + wav.write_bytes(b"RIFF") + write_marker("Same words.", wav, speaker="Narrator", language="es") + assert is_current("Same words.", wav, speaker="Narrator") is True diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 0000000..16858ff --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,101 @@ +"""Tests for the language registry (spec 31 section 5). + +The registry is the single source of truth for what this system can speak, so +these tests pin the two things that would silently mis-voice audio: the +voiceable/gap split, and the exact strings the TTS model expects. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from umbraaudio import languages as lang + +REPO = Path(__file__).resolve().parents[1] + + +def test_voiceable_count_is_ten() -> None: + assert len(lang.QWEN_LANGUAGES) == 10 + assert sum(1 for x in lang.QWEN_LANGUAGES if x.tts_name) == 10 + + +def test_gap_languages_are_not_voiceable() -> None: + assert len(lang.GAP_LANGUAGES) == 5 + for gap in lang.GAP_LANGUAGES: + assert gap.tts_name is None + assert not lang.is_voiceable(gap.code) + assert lang.is_known(gap.code), "gap languages are known, just not speakable" + + +def test_gap_set_is_exactly_the_five_unsupported() -> None: + # spec 30 section 8 named these five; a sixth appearing here silently would + # change what the product claims to support. + assert {g.code for g in lang.GAP_LANGUAGES} == {"nl", "no", "pl", "ar", "hi"} + + +def test_no_duplicate_codes_or_names() -> None: + codes = [x.code for x in lang.ALL_LANGUAGES] + names = [x.name for x in lang.ALL_LANGUAGES] + assert len(codes) == len(set(codes)) + assert len(names) == len(set(names)) + + +def test_codes_are_lowercase_iso() -> None: + for x in lang.ALL_LANGUAGES: + assert re.fullmatch(r"[a-z]{2}", x.code), x + + +def test_normalize_accepts_code_name_and_model_string() -> None: + for x in lang.ALL_LANGUAGES: + assert lang.normalize(x.code) == x.code + assert lang.normalize(x.code.upper()) == x.code + assert lang.normalize(x.name) == x.code + assert lang.normalize(f" {x.name.lower()} ") == x.code + + +def test_normalize_rejects_unknown_without_raising() -> None: + # spec 31 section 8: an unrecognised language is a finding, not a crash. + for bad in (None, "", " ", "klingon", "zz"): + assert lang.normalize(bad) is None + + +def test_tts_name_is_the_model_string() -> None: + assert lang.tts_name("es") == "spanish" + assert lang.tts_name("Spanish") == "spanish" + assert lang.tts_name("zh") == "chinese" + assert lang.tts_name("nl") is None + + +def test_language_name_falls_back_gracefully() -> None: + assert lang.language_name("fr") == "French" + assert lang.language_name("xx") == "xx" + assert lang.language_name(None) == "unknown" + + +def test_refusal_reason_is_explicit() -> None: + assert lang.refusal_reason("en") is None + assert lang.refusal_reason(None) is None + nl = lang.refusal_reason("nl") + assert nl is not None and "'nl'" in nl and "Dutch" in nl + xx = lang.refusal_reason("xx") + assert xx is not None and "unrecognised" in xx + + +def test_origins_cover_the_three_sources() -> None: + assert set(lang.ORIGINS) == {"unknown", "umbratrans", "operator"} + + +def test_matches_the_working_reference_map() -> None: + """The renderer that actually produces 10 languages is the authority. + + If the registry and render_lang_test.py disagree, one of them is wrong and + the audio is mis-voiced. This is the test that catches it. + """ + src = (REPO / "scripts" / "render_lang_test.py").read_text(encoding="utf-8") + block = re.search(r"LANG_CODES = \{(.*?)\}", src, re.S) + assert block is not None, "LANG_CODES not found - did the renderer change?" + pairs = dict(re.findall(r'"([A-Z]{2})": "(\w+)"', block.group(1))) + assert len(pairs) == 10 + for code, model_name in pairs.items(): + assert lang.tts_name(code.lower()) == model_name, (code, model_name) diff --git a/tests/test_translation_resolution.py b/tests/test_translation_resolution.py new file mode 100644 index 0000000..8ec8e62 --- /dev/null +++ b/tests/test_translation_resolution.py @@ -0,0 +1,266 @@ +"""Tests for resolving a translation from the DB and the on-disk sample files. + +The behaviours pinned here are the ones that made the review UI lie: a valid +Chinese translation existed on disk but the page showed English, and the DB +held an English string under a ``zh-Hans`` code that the page would have +rendered as "translated" if it had simply trusted the row. +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +import pytest + +from umbraaudio import translations as tr + + +def _seed(conn: sqlite3.Connection) -> None: + conn.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 chapters (id, chapter_index) VALUES (1, 1); + INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES + (1, 1, 0, 'Narrator', 'Welcome to the book.'), + (2, 1, 1, 'Kainda', 'Hello, I am the main character.'); + """ + ) + tr.ensure_tables(conn) + conn.commit() + + +@pytest.fixture +def conn() -> sqlite3.Connection: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + _seed(c) + yield c + c.close() + + +def _write_sample(directory: Path, code: str, texts: list[str], chapter_id: int = 1) -> None: + directory.mkdir(parents=True, exist_ok=True) + payload = { + "project": "Test", + "chapter_id": chapter_id, + "language_code": code, + "utterances": [ + {"index": str(i), "speaker": "Narrator", "type": "narration", "text": text} + for i, text in enumerate(texts) + ], + } + (directory / f"sample_ch1_{code.upper()}.json").write_text( + json.dumps(payload, ensure_ascii=False), encoding="utf-8" + ) + + +# --- code normalisation --------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("zh", "zh"), + ("zh-Hans", "zh"), + ("zh_CN", "zh"), + ("EN", "en"), + ("en-US", "en"), + ("Russian", "ru"), + ("nonsense", None), + ("", None), + (None, None), + ], +) +def test_canonical_code_tolerates_script_subtags(value: str | None, expected: str | None) -> None: + assert tr.canonical_code(value) == expected + + +# --- reading the sample files --------------------------------------------- + + +def test_sample_translations_dir_is_beside_the_project(tmp_path: Path) -> None: + # .umbra is gitignored runtime state; the translations are project content. + assert tr.sample_translations_dir(tmp_path) == tmp_path / "samples" / "translations" + + +def test_load_sample_translations_reads_every_file(tmp_path: Path) -> None: + directory = tr.sample_translations_dir(tmp_path) + _write_sample(directory, "en", ["Welcome.", "Hello."]) + _write_sample(directory, "zh", ["欢迎。", "你好。"]) + + files = tr.load_sample_translations(tmp_path) + + assert set(files) == {"en", "zh"} + assert files["zh"][(1, 0)] == "欢迎。" + assert files["zh"][(1, 1)] == "你好。" + + +def test_load_sample_translations_survives_a_broken_file(tmp_path: Path) -> None: + directory = tr.sample_translations_dir(tmp_path) + _write_sample(directory, "zh", ["欢迎。", "你好。"]) + (directory / "sample_ch1_BROKEN.json").write_text("{not json", encoding="utf-8") + + files = tr.load_sample_translations(tmp_path) + + assert files["zh"][(1, 0)] == "欢迎。" + + +def test_load_sample_translations_missing_directory_is_empty(tmp_path: Path) -> None: + assert tr.load_sample_translations(tmp_path) == {} + + +# --- resolving ------------------------------------------------------------ + + +def test_sample_file_supplies_a_translation_the_db_does_not_have( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + directory = tr.sample_translations_dir(tmp_path) + _write_sample(directory, "en", ["Welcome.", "Hello."]) + _write_sample(directory, "zh", ["欢迎。", "你好。"]) + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert resolved[(1, 0)].text == "欢迎。" + assert resolved[(1, 1)].text == "你好。" + assert resolved[(1, 0)].origin == "sample" + + +def test_english_is_not_reported_as_a_translation( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + # The English file is the reference the other files are compared against, + # so English must never come back as a translation of itself. + directory = tr.sample_translations_dir(tmp_path) + _write_sample(directory, "en", ["Welcome.", "Hello."]) + + resolved = tr.resolved_translations(conn, tmp_path, "en") + + assert resolved == {} + + +def test_db_row_wins_over_the_sample_file( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + directory = tr.sample_translations_dir(tmp_path) + _write_sample(directory, "en", ["Welcome.", "Hello."]) + _write_sample(directory, "zh", ["欢迎。", "你好。"]) + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, status, origin) + VALUES (1, 1, 'zh', 'Chinese', '从数据库来的欢迎。', 'confirmed', 'umbratrans') + """ + ) + conn.commit() + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert resolved[(1, 0)].text == "从数据库来的欢迎。" + assert resolved[(1, 0)].status == "confirmed" + assert resolved[(1, 0)].origin == "umbratrans" + # The second utterance still comes from the file. + assert resolved[(1, 1)].text == "你好。" + + +def test_db_row_holding_the_english_source_is_a_placeholder_not_a_translation( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + # This is the shape of the one row that was actually in Deal Hunter's DB: + # the English text stored under a zh-Hans code. Rendering it would show + # English under a Chinese heading and claim it was translated. + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, status, origin) + VALUES (1, 1, 'zh-Hans', 'Chinese', 'Welcome to the book.', 'draft', 'umbratrans') + """ + ) + conn.commit() + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert resolved == {} + + +def test_script_subtag_rows_are_matched_to_the_dropdown_code( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, status, origin) + VALUES (1, 1, 'zh-Hans', 'Chinese', '从数据库来的欢迎。', 'draft', 'umbratrans') + """ + ) + conn.commit() + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert resolved[(1, 0)].text == "从数据库来的欢迎。" + + +def test_a_row_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) + VALUES (1, 1, 'ru', 'Russian', 'Добро пожаловать.', 'draft', 'umbratrans') + """ + ) + conn.commit() + + resolved = tr.resolved_translations(conn, tmp_path, "zh") + + assert resolved == {} + + +def test_missing_translations_table_is_not_an_error( + tmp_path: Path, +) -> None: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + c.executescript( + """ + CREATE TABLE utterances ( + id INTEGER PRIMARY KEY, chapter_id INTEGER, utterance_index INTEGER, + speaker_name TEXT, text TEXT + ); + """ + ) + c.commit() + try: + assert tr.resolved_translations(c, tmp_path, "zh") == {} + finally: + c.close() + + +def test_untranslated_target_file_is_not_counted_as_translated( + conn: sqlite3.Connection, tmp_path: Path +) -> None: + # A target file whose text is identical to the source file has not been + # translated; reporting it as translated would hide the gap. + directory = tr.sample_translations_dir(tmp_path) + _write_sample(directory, "en", ["Welcome.", "Hello."]) + _write_sample(directory, "ru", ["Welcome.", "Hello."]) + + resolved = tr.resolved_translations(conn, tmp_path, "ru") + + assert resolved == {} diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 0000000..c909be1 --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,366 @@ +"""Tests for source-language tagging and per-language translations (spec 31). + +The behaviours pinned here are the ones that would silently corrupt audio or +lose alignment if they regressed: the source/target axis split, the per-language +voiced flag, idempotent re-delivery, and refusal to overwrite approved text. +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from umbraaudio import translations as tr + + +def _seed(conn: sqlite3.Connection) -> None: + conn.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 chapters (id, chapter_index) VALUES (1, 1), (2, 2); + INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES + (1, 1, 0, 'narrator', 'The ship landed.'), + (2, 1, 1, 'Kabir', 'Hola, amigo.'), + (3, 1, 2, 'Kabir', 'We should go.'), + (4, 2, 0, 'narrator', 'Later that day.'), + (5, 2, 1, 'Nanny', 'Danger.'); + """ + ) + conn.commit() + + +@pytest.fixture +def conn() -> sqlite3.Connection: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + _seed(c) + yield c + c.close() + + +@pytest.fixture +def plain_conn() -> sqlite3.Connection: + """No row_factory - proves the accessors do not assume sqlite3.Row.""" + c = sqlite3.connect(":memory:") + _seed(c) + yield c + c.close() + + +# --- source axis --------------------------------------------------------- + + +def test_source_language_defaults_to_english(conn: sqlite3.Connection) -> None: + assert tr.source_language(conn) == "en" + + +def test_source_language_reads_project_column() -> None: + c = sqlite3.connect(":memory:") + c.execute("CREATE TABLE project (id INTEGER PRIMARY KEY, source_language TEXT)") + c.execute("INSERT INTO project VALUES (1, 'ZH')") + assert tr.source_language(c) == "zh" + c.close() + + +def test_set_source_language_normalises_and_reports_change(conn: sqlite3.Connection) -> None: + assert tr.set_source_language(conn, 2, "Spanish") is True + row = conn.execute( + "SELECT language_code, language_origin FROM utterances WHERE id=2" + ).fetchone() + assert row["language_code"] == "es" + assert row["language_origin"] == "umbratrans" + # Setting the same value again is not a change. + assert tr.set_source_language(conn, 2, "es") is False + + +def test_set_source_language_unknown_id_is_not_an_error(conn: sqlite3.Connection) -> None: + assert tr.set_source_language(conn, 9999, "es") is False + + +def test_set_source_language_rejects_bad_origin(conn: sqlite3.Connection) -> None: + with pytest.raises(ValueError): + tr.set_source_language(conn, 1, "es", "nonsense") + + +def test_operator_tag_outranks_machine_reidentification(conn: sqlite3.Connection) -> None: + tr.set_source_language(conn, 2, "es", tr.lang.ORIGIN_OPERATOR) + assert tr.set_source_language(conn, 2, "fr", tr.lang.ORIGIN_UMBRATRANS) is False + row = conn.execute("SELECT language_code FROM utterances WHERE id=2").fetchone() + assert row["language_code"] == "es" + + +def test_force_overrides_operator_tag(conn: sqlite3.Connection) -> None: + tr.set_source_language(conn, 2, "es", tr.lang.ORIGIN_OPERATOR) + assert tr.set_source_language(conn, 2, "fr", tr.lang.ORIGIN_UMBRATRANS, force=True) is True + row = conn.execute("SELECT language_code FROM utterances WHERE id=2").fetchone() + assert row["language_code"] == "fr" + + +def test_foreign_lines_are_excluded_from_translation(conn: sqlite3.Connection) -> None: + tr.set_source_language(conn, 2, "es") + assert tr.foreign_utterance_ids(conn) == {2} + to_translate = tr.utterances_to_translate(conn) + assert 2 not in to_translate + assert to_translate == [1, 3, 4, 5] + + +def test_untagged_rows_count_as_source_language(conn: sqlite3.Connection) -> None: + # NULL tag = the project's primary language (spec 31 section 6.1). + assert tr.foreign_utterance_ids(conn) == set() + assert tr.utterances_to_translate(conn) == [1, 2, 3, 4, 5] + + +def test_effective_language_resolves_null_to_primary(conn: sqlite3.Connection) -> None: + tr.set_source_language(conn, 2, "es") + assert tr.effective_language(conn, 1) == "en" + assert tr.effective_language(conn, 2) == "es" + + +def test_unknown_tag_never_falls_back_to_the_primary_language(conn: sqlite3.Connection) -> None: + """A Klingon line must not be spoken as English. + + This is the fail-loud guarantee (spec 31 section 4.3). If an unrecognised tag + collapsed to NULL, the line would look untagged, resolve to 'en', and be + voiced with English phonology - wrong, and silent about being wrong. + """ + tr.set_source_language(conn, 1, "klingon") + assert tr.effective_language(conn, 1) == "klingon" + assert 1 in tr.foreign_utterance_ids(conn), "an unreadable language is not the primary one" + assert not tr.lang.is_voiceable(tr.effective_language(conn, 1)) + + +# --- target axis --------------------------------------------------------- + + +def test_import_stores_a_translation(conn: sqlite3.Connection) -> None: + report = tr.import_translations( + conn, + 1, + { + "target_language": "de", + "utterances": [ + {"utterance_id": 1, "source_language": "en", "text": "Das Schiff landete."} + ], + }, + ) + assert report.accepted == 1 + assert report.source_tagged == 1 + row = conn.execute( + "SELECT language_name, text, status, origin, voiced" + " FROM utterance_translations WHERE utterance_id=1" + ).fetchone() + assert row["language_name"] == "German" + assert row["text"] == "Das Schiff landete." + assert row["status"] == "draft" + assert row["origin"] == "umbratrans" + assert row["voiced"] == 1, "a normal translation is voiced by default" + + +def test_import_is_idempotent_on_redelivery(conn: sqlite3.Connection) -> None: + payload = {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Erste"}]} + tr.import_translations(conn, 1, payload) + payload["utterances"][0]["text"] = "Zweite" + tr.import_translations(conn, 1, payload) + rows = conn.execute("SELECT text FROM utterance_translations WHERE utterance_id=1").fetchall() + assert len(rows) == 1, "re-delivery updates in place, it does not accumulate" + assert rows[0]["text"] == "Zweite" + + +def test_confirmed_translation_is_not_overwritten(conn: sqlite3.Connection) -> None: + tr.import_translations( + conn, + 1, + {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Approved"}]}, + confirm=True, + ) + report = tr.import_translations( + conn, 1, {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Sneaky"}]} + ) + assert report.skipped_confirmed == 1 + assert report.accepted == 0 + row = conn.execute( + "SELECT text, status FROM utterance_translations WHERE utterance_id=1" + ).fetchone() + assert row["text"] == "Approved" + assert row["status"] == "confirmed" + + +def test_foreign_line_returns_identification_only(conn: sqlite3.Connection) -> None: + """The whole point of the source axis: a Spanish line is not translated.""" + report = tr.import_translations( + conn, + 1, + {"target_language": "de", "utterances": [{"utterance_id": 2, "source_language": "es"}]}, + ) + assert report.source_tagged == 1 + assert report.foreign_untouched == 1 + assert report.accepted == 0 + assert ( + conn.execute("SELECT COUNT(*) FROM utterance_translations WHERE utterance_id=2").fetchone()[ + 0 + ] + == 0 + ) + assert tr.effective_language(conn, 2) == "es" + + +def test_collapse_orphan_is_kept_with_a_reason(conn: sqlite3.Connection) -> None: + """Three source sentences collapsing into two leaves one with no target.""" + report = tr.import_translations( + conn, + 1, + { + "target_language": "de", + "utterances": [ + {"utterance_id": 1, "text": "Das Schiff landete. Wir gingen."}, + {"utterance_id": 3, "voiced": False, "reason": "absorbed into utterance 1"}, + ], + }, + ) + assert report.accepted == 2 + assert report.not_voiced == 1 + row = conn.execute( + "SELECT voiced, review_reason FROM utterance_translations WHERE utterance_id=3" + ).fetchone() + assert row["voiced"] == 0 + assert row["review_reason"] == "absorbed into utterance 1" + orphans = tr.not_voiced_rows(conn, "de") + assert [o["utterance_id"] for o in orphans] == [3] + + +def test_gap_language_tag_is_stored_and_render_refused(conn: sqlite3.Connection) -> None: + report = tr.import_translations( + conn, + 1, + {"target_language": "de", "utterances": [{"utterance_id": 1, "source_language": "nl"}]}, + ) + assert report.refused_voice == 1 + assert tr.effective_language(conn, 1) == "nl" + refused = tr.refused_render_rows(conn) + assert len(refused) == 1 + assert refused[0]["language_code"] == "nl" + assert "Dutch" in refused[0]["reason"] + + +def test_unknown_language_is_reported_not_crashed(conn: sqlite3.Connection) -> None: + report = tr.import_translations( + conn, + 1, + { + "target_language": "de", + "utterances": [{"utterance_id": 1, "source_language": "klingon"}], + }, + ) + assert "klingon" in report.unknown_language + assert tr.refused_render_rows(conn)[0]["reason"].startswith("unrecognised") + + +def test_identification_only_payload_needs_no_target_language(conn: sqlite3.Connection) -> None: + report = tr.import_translations( + conn, 1, {"utterances": [{"utterance_id": 2, "source_language": "es"}]} + ) + assert report.source_tagged == 1 + assert report.problems == [] + + +def test_text_without_target_language_is_a_problem(conn: sqlite3.Connection) -> None: + report = tr.import_translations(conn, 1, {"utterances": [{"utterance_id": 1, "text": "Hallo"}]}) + assert report.problems and "target_language" in report.problems[0] + + +def test_row_without_utterance_id_is_reported(conn: sqlite3.Connection) -> None: + report = tr.import_translations( + conn, 1, {"target_language": "de", "utterances": [{"text": "x"}]} + ) + assert report.problems and "utterance_id" in report.problems[0] + + +def test_utterances_must_be_a_list(conn: sqlite3.Connection) -> None: + with pytest.raises(ValueError): + tr.import_translations(conn, 1, {"target_language": "de", "utterances": {"a": 1}}) + + +def test_plain_tuple_connection_is_supported(plain_conn: sqlite3.Connection) -> None: + """No row_factory set - the accessors must still work.""" + tr.set_source_language(plain_conn, 2, "es") + assert tr.effective_language(plain_conn, 2) == "es" + report = tr.import_translations( + plain_conn, + 1, + {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Hallo"}]}, + ) + assert report.accepted == 1 + + +# --- coverage and export ------------------------------------------------- + + +def test_coverage_counts_the_gap_honestly(conn: sqlite3.Connection) -> None: + tr.import_translations( + conn, + 1, + { + "target_language": "de", + "utterances": [ + {"utterance_id": 1, "text": "Eins"}, + {"utterance_id": 3, "voiced": False, "reason": "merged"}, + ], + }, + ) + cov = tr.coverage(conn, "de") + assert cov["total"] == 5 + assert cov["delivered"] == 2 + assert cov["voiced"] == 1 + assert cov["not_voiced"] == 1 + assert cov["missing"] == 3 + + +def test_coverage_of_an_untouched_language_is_all_missing(conn: sqlite3.Connection) -> None: + cov = tr.coverage(conn, "fr") + assert cov["delivered"] == 0 and cov["missing"] == 5 and cov["not_voiced"] == 0 + + +def test_export_payload_shape(conn: sqlite3.Connection) -> None: + tr.set_source_language(conn, 2, "es") + payload = tr.build_export_payload(conn) + assert payload["book_language"] == "en" + assert payload["utterance_count"] == 5 + assert payload["foreign_count"] == 1 + first = payload["utterances"][0] + assert first["source_text"] == "The ship landed." + assert first["language_code"] is None, "untagged lines are null on the first pass" + spanish = [u for u in payload["utterances"] if u["utterance_id"] == 2][0] + assert spanish["language_code"] == "es", "a known tag is sent so it is not re-identified" + + +def test_export_can_exclude_foreign_lines(conn: sqlite3.Connection) -> None: + tr.set_source_language(conn, 2, "es") + payload = tr.build_export_payload(conn, include_foreign=False) + assert payload["utterance_count"] == 4 + assert 2 not in [u["utterance_id"] for u in payload["utterances"]] + assert payload["foreign_count"] == 1, "the count is still reported even when omitted" + + +def test_ensure_tables_is_idempotent(conn: sqlite3.Connection) -> None: + tr.ensure_tables(conn) + tr.ensure_tables(conn) + assert ( + conn.execute( + "SELECT COUNT(*) FROM sqlite_master WHERE name='utterance_translations'" + ).fetchone()[0] + == 1 + ) diff --git a/tests/test_tts_language.py b/tests/test_tts_language.py new file mode 100644 index 0000000..d3fb5f3 --- /dev/null +++ b/tests/test_tts_language.py @@ -0,0 +1,152 @@ +"""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" diff --git a/umbraaudio/database.py b/umbraaudio/database.py index 97d8844..41e044f 100644 --- a/umbraaudio/database.py +++ b/umbraaudio/database.py @@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS project ( source_format TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), - status TEXT NOT NULL DEFAULT 'initialized' + status TEXT NOT NULL DEFAULT 'initialized', + source_language TEXT NOT NULL DEFAULT 'en' ); CREATE TABLE IF NOT EXISTS chapters ( @@ -102,7 +103,37 @@ CREATE TABLE IF NOT EXISTS utterances ( emotion_confidence REAL DEFAULT 1.0, user_edited INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', - source_unit_key TEXT + source_unit_key TEXT, + -- spec 31: the language this utterance's TEXT is written in. NULL means the + -- project's source language; a non-NULL value is a deliberate claim that the + -- line is in another language (a Spanish line inside an English book). + -- language_origin records where that claim came from ('unknown', + -- 'umbratrans', 'operator') so a machine identification is distinguishable + -- from a human correction. + language_code TEXT, + language_origin TEXT +); + +CREATE TABLE IF NOT EXISTS utterance_translations ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES project(id), + utterance_id INTEGER NOT NULL REFERENCES utterances(id), + language_code TEXT NOT NULL, + language_name TEXT NOT NULL, + text TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + origin TEXT NOT NULL DEFAULT 'umbratrans', + source_ref TEXT, + delivered_at TEXT, + confirmed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + -- spec 31 section 6.2: per-language voiced flag. 0 means this target + -- language must not render audio for this utterance, because the source + -- sentence was absorbed into another translated sentence. + voiced INTEGER NOT NULL DEFAULT 1, + review_reason TEXT, + UNIQUE(utterance_id, language_code) ); CREATE TABLE IF NOT EXISTS generated_audio ( @@ -162,6 +193,8 @@ CREATE INDEX IF NOT EXISTS idx_name_tokens_project ON name_tokens(project_id); CREATE INDEX IF NOT EXISTS idx_utterances_chapter ON utterances(chapter_id); CREATE INDEX IF NOT EXISTS idx_utterances_source_unit ON utterances(source_unit_key); CREATE INDEX IF NOT EXISTS idx_utterances_character ON utterances(character_id); +CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt ON utterance_translations(utterance_id); +CREATE INDEX IF NOT EXISTS idx_utterance_translations_lang ON utterance_translations(language_code); CREATE INDEX IF NOT EXISTS idx_generated_audio_utterance ON generated_audio(utterance_id); CREATE INDEX IF NOT EXISTS idx_chapters_project ON chapters(project_id); CREATE INDEX IF NOT EXISTS idx_characters_project ON characters(project_id); @@ -220,6 +253,8 @@ class Database: ("needs_review", "INTEGER NOT NULL DEFAULT 0"), ("review_reason", "TEXT"), ("source_unit_key", "TEXT"), + ("language_code", "TEXT"), + ("language_origin", "TEXT"), ): if col not in utt_cols: await self._conn.execute( @@ -228,6 +263,57 @@ class Database: await self._conn.execute( "CREATE INDEX IF NOT EXISTS idx_utterances_source_unit ON utterances(source_unit_key)" ) + # DELIBERATELY here and not in SCHEMA: for an existing project DB, + # `CREATE TABLE IF NOT EXISTS utterances` is a no-op, so language_code + # does not exist yet when SCHEMA runs. An index on it in SCHEMA fails + # with "no such column: language_code" and takes out every caller. + # The column is added by the ALTER above, so the index must follow it. + await self._conn.execute( + "CREATE INDEX IF NOT EXISTS idx_utterances_language ON utterances(language_code)" + ) + + # spec 31: translations arrived before the project DB existed in its + # current shape, so the table is created here too and not only in SCHEMA. + await self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS utterance_translations ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES project(id), + utterance_id INTEGER NOT NULL REFERENCES utterances(id), + language_code TEXT NOT NULL, + language_name TEXT NOT NULL, + text TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + origin TEXT NOT NULL DEFAULT 'umbratrans', + source_ref TEXT, + delivered_at TEXT, + confirmed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + voiced INTEGER NOT NULL DEFAULT 1, + review_reason TEXT, + UNIQUE(utterance_id, language_code) + ) + """ + ) + await self._conn.execute( + "CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt " + "ON utterance_translations(utterance_id)" + ) + await self._conn.execute( + "CREATE INDEX IF NOT EXISTS idx_utterance_translations_lang " + "ON utterance_translations(language_code)" + ) + + # spec 31 section 13.1: the project's source language. An utterance + # with no language tag is written in this language, so the column has + # to live on the project rather than being implied by every row. + cursor = await self._conn.execute("PRAGMA table_info(project)") + project_cols = {row[1] for row in await cursor.fetchall()} + if "source_language" not in project_cols: + await self._conn.execute( + "ALTER TABLE project ADD COLUMN source_language TEXT NOT NULL DEFAULT 'en'" + ) cursor = await self._conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='voice_review_history'" diff --git a/umbraaudio/generation/chapter_builder.py b/umbraaudio/generation/chapter_builder.py index 1a82cfe..bb6b43b 100644 --- a/umbraaudio/generation/chapter_builder.py +++ b/umbraaudio/generation/chapter_builder.py @@ -381,10 +381,16 @@ class ChapterBuilder: utterances = db.execute( "SELECT id, utterance_index, text, speaker_name, emotion, pace, instruct, type, " - "source_unit_key " + "source_unit_key, language_code " "FROM utterances WHERE chapter_id = ? ORDER BY utterance_index", (self.chapter_id,), ).fetchall() + # The source language of the book. An utterance with no language tag + # is written in this language (spec 31 section 6.1: NULL means the + # project source language). + from umbraaudio.translations import source_language + + project_language = source_language(db) db.close() if stamped: self._set_phase("validating", f"Recorded {stamped} source-unit keys", 8) @@ -418,11 +424,16 @@ class ChapterBuilder: spoken_text = prepare_tts_text(text, phonetic, proper_names) spk = (u["speaker_name"] or "narrator").strip() vref = _resolve_voice_ref(spk, alias_map, voices_dir) + # The language the line is WRITTEN in. This is the source axis + # (spec 31), not the target translation: a book in English with a + # German line inside it is voiced in German for that line. + line_language = u["language_code"] or project_language if not is_current( spoken_text, audio_dir / fname, speaker=spk, voice_ref=vref, accepted_voice_modes=self._accepted_voice_modes(vref), + language=line_language, ): missing.append(dict(u)) @@ -465,6 +476,12 @@ class ChapterBuilder: self._set_phase("generating", f"Generating {len(missing)} utterances...", 15) + from umbraaudio.translations import source_language + + _lang_db = sqlite3.connect(str(self.data_dir / "project.db")) + project_language = source_language(_lang_db) + _lang_db.close() + phonetic = load_phonetic_names(self.data_dir) proper_names = load_proper_names(self.data_dir) speakers = _load_speakers(self.data_dir) @@ -529,6 +546,9 @@ class ChapterBuilder: spoken_text = prepare_tts_text(text, phonetic, proper_names) voice_ref = _resolve_voice_ref(speaker, alias_map, voices_dir) + # The language the line is written in (spec 31 source axis). + # NULL on the utterance means the project's source language. + line_language = u.get("language_code") or project_language tts_instruct = compose_utterance_instruct( instruct, emotion, u["pace"] @@ -567,6 +587,7 @@ class ChapterBuilder: instruct=tts_instruct, temperature=self.temperature, seed=seed, + language=line_language, ) voice_mode = "embedding" elif prompt_slug: @@ -577,6 +598,7 @@ class ChapterBuilder: instruct=tts_instruct, temperature=self.temperature, seed=seed, + language=line_language, ) voice_mode = "icl" elif voice_ref: @@ -588,6 +610,7 @@ class ChapterBuilder: instruct=tts_instruct, temperature=self.temperature, seed=seed, + language=line_language, ) voice_mode = "embedding" else: @@ -597,6 +620,7 @@ class ChapterBuilder: instruct=tts_instruct or "A calm, neutral voice speaking clearly.", temperature=self.temperature, seed=seed, + language=line_language, ) if out.exists(): @@ -620,6 +644,7 @@ class ChapterBuilder: voice_mode=voice_mode, instruct=tts_instruct, source_unit_key=u.get("source_unit_key"), effects=applied, + language=line_language, ) self.result.generated += 1 success = True diff --git a/umbraaudio/generation/scene_break.py b/umbraaudio/generation/scene_break.py index eeb0bf5..9427c4d 100644 --- a/umbraaudio/generation/scene_break.py +++ b/umbraaudio/generation/scene_break.py @@ -58,7 +58,14 @@ def write_scene_break_audio( duration_ms: int = SCENE_BREAK_SILENCE_MS, sample_rate: int = DEFAULT_SAMPLE_RATE, ) -> Path: - """Write a silence wav (plus staleness marker) for a scene break.""" + """Write a silence wav (plus staleness marker) for a scene break. + + The marker records no language on purpose. A scene break is silence, not + speech: it is the same clip whatever language the surrounding line is + written in, so tagging it would claim a phonology the audio does not have. + ``needs_silence`` therefore does not compare languages either - retagging a + scene-break row must not force a regeneration of an identical silence. + """ samples = int(sample_rate * duration_ms / 1000) output_path.parent.mkdir(parents=True, exist_ok=True) if output_path.exists(): diff --git a/umbraaudio/generation/staleness.py b/umbraaudio/generation/staleness.py index 73789ad..c215dc2 100644 --- a/umbraaudio/generation/staleness.py +++ b/umbraaudio/generation/staleness.py @@ -30,6 +30,13 @@ if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path +# Spec 31 section 10. A marker written before the language tag existed records no +# ``language``; that audio was English (the only language the pipeline could +# make), so an absent value means this. Treating absence as "unknown" instead +# would invalidate every existing clip - ~4,300 files and a full regeneration - +# for no gain. +DEFAULT_LANGUAGE = "en" + def marker_path(wav_path: Path) -> Path: """Sidecar marker path for a wav file (utt_0000.wav -> utt_0000.wav.txt).""" @@ -60,6 +67,7 @@ def is_current( instruct: str | None = None, fx_version: int | None = None, verify_sha256: bool = False, + language: str | None = None, ) -> bool: """True when the wav exists, its marker is JSON, and text (+ speaker if given) match exactly. If *voice_ref* is provided the marker's @@ -94,6 +102,13 @@ def is_current( return False if instruct is not None and payload.get("instruct") != instruct: return False + if language is not None: + # Absence means English (DEFAULT_LANGUAGE), so a legacy English clip + # stays current while a line retagged as another language - or an + # English line whose clip was made in another language - goes stale. + recorded_language = payload.get("language") or DEFAULT_LANGUAGE + if recorded_language != language: + return False if voice_ref is not None: if not voice_ref.exists(): return False @@ -128,6 +143,7 @@ def write_marker( fx_version: int | None = None, source_unit_key: str | None = None, effects: Sequence[str] | None = None, + language: str | None = None, ) -> None: """Record *text*, *speaker*, and (optionally) the voice-reference fingerprint as the spoken content of *wav_path*. When *instruct* is given @@ -154,6 +170,11 @@ def write_marker( payload["fx_version"] = fx_version if source_unit_key is not None: payload["source_unit_key"] = source_unit_key + if language is not None: + # Recorded at generation so a language tag applied *later* (spec 31 + # section 9) invalidates the clip rather than leaving it with English + # phonology behind an unchanged text. + payload["language"] = language if effects: # The recorded sha256 identifies the FINAL bytes. Without this, nothing records # which post-processing turned the raw TTS output into those bytes, so a changed diff --git a/umbraaudio/languages.py b/umbraaudio/languages.py new file mode 100644 index 0000000..26342b3 --- /dev/null +++ b/umbraaudio/languages.py @@ -0,0 +1,130 @@ +"""The set of languages this system knows about, in one place. + +Two groups, and the split is a hard capability boundary, not a preference: + +* **Voiceable** - the ten languages Qwen3-TTS can synthesise. The model's own + ``codec_language_id`` keys are the authority for the name string it expects. +* **Gap** - languages we can tag and store but cannot speak. Recording the tag + is useful (it excludes the line from translation and explains why a hole + exists); emitting audio for it would be wrong, so the render is refused. + +Spec 31 section 5. ``/v1/tts`` validation, the export filter and the review UI +all read this module, so they cannot disagree about what is supported. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Where a source-language tag came from. Kept as plain strings rather than an +# enum so the value round-trips through JSON and SQLite without conversion. +ORIGIN_UNKNOWN = "unknown" +ORIGIN_UMBRATRANS = "umbratrans" +ORIGIN_OPERATOR = "operator" +ORIGINS: tuple[str, ...] = (ORIGIN_UNKNOWN, ORIGIN_UMBRATRANS, ORIGIN_OPERATOR) + + +@dataclass(frozen=True) +class Language: + """One language. ``tts_name`` is None when it cannot be voiced.""" + + code: str + name: str + tts_name: str | None = None + + +# The ten Qwen3-TTS languages. ``tts_name`` is copied from the model's own +# codec_language_id keys via scripts/render_lang_test.py, which is the working +# reference - do not retype these by hand. +QWEN_LANGUAGES: tuple[Language, ...] = ( + Language("en", "English", "english"), + Language("zh", "Chinese", "chinese"), + Language("ja", "Japanese", "japanese"), + Language("ko", "Korean", "korean"), + Language("de", "German", "german"), + Language("es", "Spanish", "spanish"), + Language("fr", "French", "french"), + Language("ru", "Russian", "russian"), + Language("it", "Italian", "italian"), + Language("pt", "Portuguese", "portuguese"), +) + +# Taggable, storable, and deliberately not speakable (spec 31 section 4.3). +GAP_LANGUAGES: tuple[Language, ...] = ( + Language("nl", "Dutch"), + Language("no", "Norwegian"), + Language("pl", "Polish"), + Language("ar", "Arabic"), + Language("hi", "Hindi"), +) + +ALL_LANGUAGES: tuple[Language, ...] = QWEN_LANGUAGES + GAP_LANGUAGES + +_BY_CODE: dict[str, Language] = {lang.code: lang for lang in ALL_LANGUAGES} + +# Accepts a code ('DE'), a name ('German') or the model string ('german'). +_BY_ALIAS: dict[str, Language] = {} +for _lang in ALL_LANGUAGES: + _BY_ALIAS[_lang.code] = _lang + _BY_ALIAS[_lang.name.casefold()] = _lang + if _lang.tts_name is not None: + _BY_ALIAS[_lang.tts_name] = _lang + + +def by_code(value: str | None) -> Language | None: + """Look up by code, display name or model string. None when unknown.""" + if not value: + return None + return _BY_ALIAS.get(str(value).strip().casefold()) + + +def normalize(value: str | None) -> str | None: + """Return the canonical lowercase code for a code or name, else None. + + ``None`` and empty input return ``None``. An unrecognised value also + returns ``None`` rather than raising: a language we do not know is a + finding to record, not a crash (spec 31 section 8). + """ + lang = by_code(value) + return lang.code if lang is not None else None + + +def is_known(value: str | None) -> bool: + """True when we recognise the language at all (voiceable or gap).""" + return by_code(value) is not None + + +def is_voiceable(value: str | None) -> bool: + """True when Qwen3-TTS can synthesise this language.""" + lang = by_code(value) + return lang is not None and lang.tts_name is not None + + +def tts_name(value: str | None) -> str | None: + """The string to hand the TTS server, or None when not voiceable.""" + lang = by_code(value) + return lang.tts_name if lang is not None else None + + +def language_name(value: str | None) -> str: + """Display name for a code. Falls back to the raw value, then 'unknown'.""" + lang = by_code(value) + if lang is not None: + return lang.name + return str(value) if value else "unknown" + + +def refusal_reason(value: str | None) -> str | None: + """Why this language cannot be voiced, or None when it can. + + The wording is used verbatim in the review UI, so a refused render always + says *why* rather than leaving an unexplained gap (spec 31 section 11). + """ + if not value: + return None + lang = by_code(value) + if lang is None: + return f"unrecognised language {value!r} - no voice available" + if lang.tts_name is None: + return f"no Qwen3-TTS voice for {lang.code!r} ({lang.name})" + return None diff --git a/umbraaudio/translations.py b/umbraaudio/translations.py new file mode 100644 index 0000000..c43342a --- /dev/null +++ b/umbraaudio/translations.py @@ -0,0 +1,765 @@ +"""Ingest and query the source-language tag and per-language translations. + +Spec 31. Two axes, deliberately separate: + +* **Source** - ``utterances.language_code`` says what language the original text + is *written in*. A Spanish line in an English book is tagged ``es`` and is + **never translated**: it is story content. "Bob overhears them speaking in + German" - the German is payload the reader is meant to read. +* **Target** - ``utterance_translations`` holds one row per (utterance, target + language). The ``voiced`` flag lives here, because a line can be voiced in + English and not in German (three source sentences collapsing into two). + +The functions here take a plain ``sqlite3.Connection``. Both the web layer +(raw sqlite3) and the CLI/scripts can call them, and the import logic stays +testable without standing up the async database. +""" + +from __future__ import annotations + +import json +import logging +import re +import sqlite3 +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from umbraaudio import languages as lang + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + +# Import rows are stored as 'draft' unless the caller says otherwise. A draft +# is visible and audible but explicitly not approved (spec 30 section 3.2). +STATUS_DRAFT = "draft" +STATUS_CONFIRMED = "confirmed" + + +def _now() -> str: + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _col(row: Any, name: str, index: int) -> Any: + """Read a column from a sqlite3.Row or a plain tuple. + + Callers may not have set ``row_factory``, so both shapes are supported + rather than assuming one. + """ + if row is None: + return None + try: + return row[name] + except (TypeError, IndexError, KeyError): + return row[index] + + +@dataclass +class ImportReport: + """What an import actually did. Counts only - never the text.""" + + accepted: int = 0 + source_tagged: int = 0 + skipped_confirmed: int = 0 + not_voiced: int = 0 + refused_voice: int = 0 + foreign_untouched: int = 0 + unknown_language: list[str] = field(default_factory=list) + problems: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "accepted": self.accepted, + "source_tagged": self.source_tagged, + "skipped_confirmed": self.skipped_confirmed, + "not_voiced": self.not_voiced, + "refused_voice": self.refused_voice, + "foreign_untouched": self.foreign_untouched, + "unknown_language": sorted(set(self.unknown_language)), + "problems": self.problems, + } + + +def _translations_table_exists(conn: sqlite3.Connection) -> bool: + """True when this connection can see the translations table. + + Read-only callers exist (routes opens ``mode=ro`` in places), so the read + helpers must not create the table - they check instead, and report "nothing + translated yet" rather than raising. + """ + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='utterance_translations'" + ).fetchone() + except sqlite3.OperationalError: + return False + return row is not None + + +def ensure_tables(conn: sqlite3.Connection) -> None: + """Create the translations table if an old project DB lacks it. + + The column and index additions live in ``database._migrate`` and + ``web.routes._ensure_utterance_columns``; this is the same statement exposed + for callers that hold a bare connection (scripts, tests). + """ + conn.execute( + """ + CREATE TABLE IF NOT EXISTS utterance_translations ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES project(id), + utterance_id INTEGER NOT NULL REFERENCES utterances(id), + language_code TEXT NOT NULL, + language_name TEXT NOT NULL, + text TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + origin TEXT NOT NULL DEFAULT 'umbratrans', + source_ref TEXT, + delivered_at TEXT, + confirmed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + voiced INTEGER NOT NULL DEFAULT 1, + review_reason TEXT, + UNIQUE(utterance_id, language_code) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt " + "ON utterance_translations(utterance_id)" + ) + + +def source_language(conn: sqlite3.Connection) -> str: + """The project's primary language, defaulting to English. + + Spec 31 section 13.1 flagged this as an open question; until a column + exists, the project's source language is 'en', which is what every existing + row means anyway (NULL on the utterance = this value). + """ + try: + row = conn.execute("SELECT source_language FROM project LIMIT 1").fetchone() + except sqlite3.OperationalError: + return "en" + return lang.normalize(_col(row, "source_language", 0)) or "en" + + +def set_source_language( + conn: sqlite3.Connection, + utterance_id: int, + code: str | None, + origin: str = lang.ORIGIN_UMBRATRANS, + *, + force: bool = False, +) -> bool: + """Record the language an utterance's text is written in. + + Returns True when a change was written. ``code`` is normalised; an + unrecognised language is still stored (spec 31 section 4.3 - the tag is + allowed, the render is refused) but reported by the caller. + + An existing ``operator`` tag is not overwritten by a machine identification + unless ``force`` is set: a human correction outranks a re-delivery of the + same machine guess. + """ + if origin not in lang.ORIGINS: + raise ValueError(f"unknown origin {origin!r}") + + row = conn.execute( + "SELECT language_code, language_origin FROM utterances WHERE id=?", + (utterance_id,), + ).fetchone() + if row is None: + return False + + current_code = _col(row, "language_code", 0) + current_origin = _col(row, "language_origin", 1) + + # An unrecognised language is STORED, not discarded (spec 31 section 4.3: + # "allow the tag, refuse the render"). Collapsing it to NULL would make the + # line look untagged, so it would fall back to the primary language and be + # spoken with the wrong phonology - the silent failure the decision exists + # to prevent. The raw value is kept so the refusal can name it. + if code: + stored: str | None = lang.normalize(code) or str(code).strip().lower() + else: + stored = None + + operator_owns = current_origin == lang.ORIGIN_OPERATOR and origin != lang.ORIGIN_OPERATOR + if operator_owns and not force: + return False + if stored == current_code and origin == current_origin: + return False + + conn.execute( + "UPDATE utterances SET language_code=?, language_origin=? WHERE id=?", + (stored, origin, utterance_id), + ) + return True + + +def foreign_utterance_ids(conn: sqlite3.Connection, primary: str | None = None) -> set[int]: + """Utterances whose text is NOT in the project's primary language. + + These are excluded from translation: they are story content, not something + to render into another language. + """ + primary = lang.normalize(primary) or source_language(conn) + rows = conn.execute( + "SELECT id FROM utterances WHERE language_code IS NOT NULL AND language_code <> ?", + (primary,), + ).fetchall() + return {_col(r, "id", 0) for r in rows} + + +def utterances_to_translate(conn: sqlite3.Connection, primary: str | None = None) -> list[int]: + """Utterances eligible for translation: source-language ones only. + + The complement of :func:`foreign_utterance_ids`. A NULL tag means "the + project's primary language" (spec 31 section 6.1), so untagged rows are + included - which is every existing utterance until UmbraTrans speaks. + """ + primary = lang.normalize(primary) or source_language(conn) + rows = conn.execute( + "SELECT id FROM utterances " + "WHERE language_code IS NULL OR language_code = ? " + "ORDER BY chapter_id, utterance_index", + (primary,), + ).fetchall() + return [_col(r, "id", 0) for r in rows] + + +def import_translations( + conn: sqlite3.Connection, + project_id: int, + payload: dict[str, Any], + *, + origin: str = lang.ORIGIN_UMBRATRANS, + confirm: bool = False, +) -> ImportReport: + """Store one delivery from UmbraTrans. + + ``payload`` shape (spec 31 section 8):: + + {"target_language": "de", + "utterances": [{"utterance_id": 1, "source_language": "en", + "text": "Hallo", "voiced": true}, + {"utterance_id": 2, "source_language": "es"}, + {"utterance_id": 3, "voiced": false, + "reason": "absorbed into utterance 2"}]} + + Three row shapes, all expected: + + * ``source_language`` only - a language *identification*. This is the shape + for a foreign line, which is deliberately left untranslated. Counted, not + complained about. + * ``text`` (+ optional ``voiced``) - a translation into the target language. + * ``voiced: false`` with no text - a collapse orphan: the source sentence was + absorbed into another translated sentence. A row is still written, so the + gap is visible and reconstructable; deleting it would lose the alignment. + """ + ensure_tables(conn) + report = ImportReport() + + target = lang.normalize(payload.get("target_language")) + target_name = lang.language_name(target) if target else None + accepted_status = STATUS_CONFIRMED if confirm else STATUS_DRAFT + now = _now() + + rows = payload.get("utterances") or [] + if not isinstance(rows, list): + raise ValueError("'utterances' must be a list") + + for row in rows: + if not isinstance(row, dict) or "utterance_id" not in row: + report.problems.append(f"row without utterance_id: {json.dumps(row)[:80]}") + continue + + uid = int(row["utterance_id"]) + + # --- source axis: what language the ORIGINAL text is in ------------ + src = row.get("source_language") + if src: + if not lang.is_known(src): + report.unknown_language.append(str(src)) + if set_source_language(conn, uid, src, origin): + report.source_tagged += 1 + if not lang.is_voiceable(src): + # Tag honoured, render refused (spec 31 section 4.3). + report.refused_voice += 1 + + # --- target axis: the translated text ----------------------------- + text = row.get("text") + voiced = bool(row.get("voiced", True)) + + if not target: + # No target language on the delivery: identification only. + if text is not None: + report.problems.append( + f"utterance {uid} carries text but the payload has no target_language" + ) + continue + + if text is None and voiced: + # A foreign line, or one with nothing to translate. Untouched. + report.foreign_untouched += 1 + continue + + reason = row.get("reason") or row.get("review_reason") + + existing = conn.execute( + "SELECT status FROM utterance_translations WHERE utterance_id=? AND language_code=?", + (uid, target), + ).fetchone() + if _col(existing, "status", 0) == STATUS_CONFIRMED: + report.skipped_confirmed += 1 + continue + + conn.execute( + """ + INSERT INTO utterance_translations + (project_id, utterance_id, language_code, language_name, text, + status, origin, source_ref, delivered_at, created_at, updated_at, + voiced, review_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(utterance_id, language_code) DO UPDATE SET + text=excluded.text, + status=excluded.status, + origin=excluded.origin, + source_ref=excluded.source_ref, + delivered_at=excluded.delivered_at, + updated_at=excluded.updated_at, + voiced=excluded.voiced, + review_reason=excluded.review_reason + """, + ( + project_id, + uid, + target, + target_name or target, + str(text) if text is not None else "", + accepted_status, + origin, + row.get("source_ref"), + now, + now, + now, + 1 if voiced else 0, + reason, + ), + ) + report.accepted += 1 + if not voiced: + report.not_voiced += 1 + + if not lang.is_voiceable(target): + # Target language cannot be spoken: stored, but nothing renders. + report.refused_voice += 1 + + conn.commit() + return report + + +def coverage(conn: sqlite3.Connection, language_code: str) -> dict[str, int]: + """How much of the book exists in a target language. + + ``voiced`` counts rows that will actually produce audio, so a collapse + orphan shows as a gap rather than quietly inflating the total. + + A project with no translations table yet reports "nothing delivered" rather + than raising: that is a real state (a book that has never been sent), not an + error. + """ + code = lang.normalize(language_code) or language_code + + def scalar(sql: str, args: tuple = ()) -> int: + return conn.execute(sql, args).fetchone()[0] + + total = scalar("SELECT COUNT(*) FROM utterances") + if not _translations_table_exists(conn): + return { + "total": total, + "delivered": 0, + "voiced": 0, + "not_voiced": 0, + "confirmed": 0, + "missing": total, + } + + delivered = scalar("SELECT COUNT(*) FROM utterance_translations WHERE language_code=?", (code,)) + voiced = scalar( + "SELECT COUNT(*) FROM utterance_translations WHERE language_code=? AND voiced=1", + (code,), + ) + confirmed = scalar( + "SELECT COUNT(*) FROM utterance_translations WHERE language_code=? AND status=?", + (code, STATUS_CONFIRMED), + ) + return { + "total": total, + "delivered": delivered, + "voiced": voiced, + "not_voiced": delivered - voiced, + "confirmed": confirmed, + "missing": max(total - delivered, 0), + } + + +def not_voiced_rows(conn: sqlite3.Connection, language_code: str) -> list[dict[str, Any]]: + """The collapse orphans for a language - what a reviewer must look at. + + These are utterances where the target used fewer sentences than the source, + so a source line has no counterpart. They are *kept*, with a reason, rather + than deleted: deleting loses the alignment (spec 31 section 6.2). + """ + code = lang.normalize(language_code) or language_code + if not _translations_table_exists(conn): + return [] + rows = conn.execute( + """ + SELECT t.utterance_id, t.review_reason, u.chapter_id, u.utterance_index, + u.speaker_name, u.text + FROM utterance_translations t + JOIN utterances u ON u.id = t.utterance_id + WHERE t.language_code = ? AND t.voiced = 0 + ORDER BY u.chapter_id, u.utterance_index + """, + (code,), + ).fetchall() + return [ + { + "utterance_id": _col(r, "utterance_id", 0), + "review_reason": _col(r, "review_reason", 1), + "chapter_id": _col(r, "chapter_id", 2), + "utterance_index": _col(r, "utterance_index", 3), + "speaker_name": _col(r, "speaker_name", 4), + "text": _col(r, "text", 5), + } + for r in rows + ] + + +def refused_render_rows(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """Utterances tagged in a language Qwen3-TTS cannot speak. + + Spec 31 section 11 / decision 4.3: the tag is honoured and the render + refused. This is the list that makes the resulting hole *visible* instead of + silent. Sorted by chapter so it reads like a worklist. + """ + rows = conn.execute( + """ + SELECT id, chapter_id, utterance_index, speaker_name, text, language_code + FROM utterances + WHERE language_code IS NOT NULL + ORDER BY chapter_id, utterance_index + """ + ).fetchall() + out = [] + for r in rows: + code = _col(r, "language_code", 5) + if lang.is_voiceable(code): + continue + out.append( + { + "utterance_id": _col(r, "id", 0), + "chapter_id": _col(r, "chapter_id", 1), + "utterance_index": _col(r, "utterance_index", 2), + "speaker_name": _col(r, "speaker_name", 3), + "text": _col(r, "text", 4), + "language_code": code, + "reason": lang.refusal_reason(code), + } + ) + return out + + +def effective_language( + conn: sqlite3.Connection, utterance_id: int, primary: str | None = None +) -> str: + """The language an utterance should be spoken in. + + NULL means "the project's primary language" (spec 31 section 6.1), so the + caller gets a concrete code rather than a None to branch on. + + A tag that is present but unrecognised is returned **as-is**, never replaced + by the primary language. Falling back would voice a Klingon line as English, + which is exactly the silent wrongness the refuse-render decision prevents. + """ + row = conn.execute( + "SELECT language_code FROM utterances WHERE id=?", (utterance_id,) + ).fetchone() + code = _col(row, "language_code", 0) + if code: + return lang.normalize(code) or str(code) + return lang.normalize(primary) or source_language(conn) + + +def build_export_payload( + conn: sqlite3.Connection, + primary: str | None = None, + *, + include_foreign: bool = True, +) -> dict[str, Any]: + """The per-utterance payload for UmbraTrans (spec 31 section 7). + + ``language_code`` is sent when already known so a re-delivery does not ask + UmbraTrans to re-identify a line the operator corrected. It is null on the + first pass, which is normal. + + ``include_foreign`` defaults to True because UmbraTrans is the *authority* + on identification: it must see every line to identify it. Set it False to + send only the lines that need translating. + """ + primary = lang.normalize(primary) or source_language(conn) + foreign = foreign_utterance_ids(conn, primary) + rows = conn.execute( + """ + SELECT u.id, u.chapter_id, c.chapter_index, u.utterance_index, + u.speaker_name, u.text, u.context_before, u.context_after, + u.language_code + FROM utterances u + LEFT JOIN chapters c ON c.id = u.chapter_id + ORDER BY u.chapter_id, u.utterance_index + """ + ).fetchall() + + utterances = [] + for r in rows: + uid = _col(r, "id", 0) + if not include_foreign and uid in foreign: + continue + utterances.append( + { + "utterance_id": uid, + "chapter_id": _col(r, "chapter_id", 1), + "chapter_index": _col(r, "chapter_index", 2), + "utterance_index": _col(r, "utterance_index", 3), + "speaker_name": _col(r, "speaker_name", 4), + "source_text": _col(r, "text", 5), + "context_before": _col(r, "context_before", 6), + "context_after": _col(r, "context_after", 7), + "language_code": _col(r, "language_code", 8), + } + ) + + return { + "source_system": "umbraaudio", + "book_language": primary, + "utterance_count": len(utterances), + "foreign_count": len(foreign), + "utterances": utterances, + } + + +# --------------------------------------------------------------------------- +# Resolution: where a translation actually comes from +# --------------------------------------------------------------------------- +# +# Two stores hold translations and they answer different questions: +# +# * ``utterance_translations`` - the pipeline's output. What UmbraTrans +# delivered and what the operator confirmed. Authoritative when it holds a +# real translation. +# * ``/samples/translations/*.json`` - the on-disk sample files. These +# are what ``render_lang_test.py`` rendered the per-language sample audio +# from, so the text and the audio on disk agree. +# +# Today the sample files are the only place the sample chapter's translations +# exist; the table holds one placeholder row. A resolver that reads only one +# of the two shows English for a chapter that has a valid Chinese translation, +# which is the bug this layer exists to prevent. + + +@dataclass(frozen=True) +class TranslationEntry: + """One resolved translation for one utterance in one target language.""" + + text: str + status: str = STATUS_DRAFT + origin: str = "sample" + speaker: str = "" + type: str = "" + + @property + def confirmed(self) -> bool: + return self.status == STATUS_CONFIRMED + + +def canonical_code(value: str | None) -> str | None: + """Normalise a language code, tolerating script subtags. + + ``lang.normalize`` only knows the canonical codes, so ``'zh-Hans'`` (what + UmbraTrans writes) and ``'zh_CN'`` both collapse to ``'zh'`` here. + """ + if not value: + return None + raw = str(value).strip() + if not raw: + return None + direct = lang.normalize(raw) + if direct: + return direct + base = re.split(r"[-_]", raw)[0] + return lang.normalize(base) + + +def sample_translations_dir(project_dir: Path) -> Path: + """Where the on-disk sample translations live for *project_dir*. + + ``project_dir`` rather than ``data_dir``: ``.umbra`` is gitignored runtime + state, while the sample translations are reviewable project content that + travels with the book. + """ + return project_dir / "samples" / "translations" + + +def load_sample_translations(project_dir: Path) -> dict[str, dict[tuple[int, int], str]]: + """Read every sample translation file under the project. + + Returns ``{language_code: {(chapter_id, utterance_index): text}}``. A file + that cannot be read or parsed is skipped with a warning rather than taking + the page down - one bad file must not hide the other nine. + """ + out: dict[str, dict[tuple[int, int], str]] = {} + directory = sample_translations_dir(project_dir) + if not directory.is_dir(): + return out + for path in sorted(directory.glob("*.json")): + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + logger.warning("translation sample unreadable: %s", path) + continue + if not isinstance(doc, dict): + logger.warning("translation sample is not an object: %s", path) + continue + code = canonical_code(doc.get("language_code")) + if code is None: + logger.warning("translation sample has no usable language_code: %s", path) + continue + chapter_id = doc.get("chapter_id") + try: + chapter_id = int(chapter_id) + except (TypeError, ValueError): + logger.warning("translation sample has no usable chapter_id: %s", path) + continue + bucket = out.setdefault(code, {}) + for entry in doc.get("utterances") or []: + if not isinstance(entry, dict): + continue + index = _as_index(entry.get("index")) + text = entry.get("text") + if index is None or not isinstance(text, str) or not text.strip(): + continue + bucket[(chapter_id, index)] = text + return out + + +def _as_index(value: Any) -> int | None: + """The sample files store the index as a string ("0"), the DB as an int.""" + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _db_translation_rows( + conn: sqlite3.Connection, +) -> list[dict[str, Any]]: + """Rows from ``utterance_translations`` joined to their utterance. + + The join uses ``utterances.utterance_index`` and ``utterances.speaker_name`` + - the column names that actually exist. (An earlier draft of the language + UI selected ``u.index`` and ``u.speaker``, which do not, so every read + raised ``sqlite3.OperationalError: near "index": syntax error``.) + """ + if not _translations_table_exists(conn): + return [] + try: + rows = conn.execute( + """ + 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 + FROM utterance_translations t + JOIN utterances u ON u.id = t.utterance_id + """ + ).fetchall() + except sqlite3.OperationalError: + logger.exception("utterance_translations could not be read") + return [] + + return [ + { + "chapter_id": _col(r, "chapter_id", 0), + "utterance_index": _col(r, "utterance_index", 1), + "source_text": _col(r, "source_text", 2), + "target_text": _col(r, "target_text", 3), + "status": _col(r, "status", 4), + "origin": _col(r, "origin", 5), + "speaker_name": _col(r, "speaker_name", 6), + "language_code": _col(r, "language_code", 7), + } + for r in rows + ] + + +def resolved_translations( + conn: sqlite3.Connection, + project_dir: Path, + code: str, +) -> dict[tuple[int, int], TranslationEntry]: + """The translation text to show for *code*, per ``(chapter_id, index)``. + + The DB wins where it holds a real translation; the on-disk sample files + fill the gaps. A candidate whose text is identical to the source text is a + placeholder rather than a translation, so it is ignored - otherwise the + page would show English under a Chinese heading and claim it was + translated. + """ + target = canonical_code(code) or str(code).strip().lower() + if not target: + return {} + + resolved: dict[tuple[int, int], TranslationEntry] = {} + + # --- the pipeline's output ------------------------------------------- + for row in _db_translation_rows(conn): + if canonical_code(row["language_code"]) != target: + continue + source_text = row["source_text"] or "" + text = row["target_text"] or "" + if not text.strip(): + continue + if source_text and text.strip() == source_text.strip(): + # The English text stored under a target-language code: a + # placeholder, not a translation. + continue + key = (int(row["chapter_id"]), int(row["utterance_index"])) + resolved[key] = TranslationEntry( + text=text, + status=row["status"] or STATUS_DRAFT, + origin=row["origin"] or "umbratrans", + speaker=row["speaker_name"] or "", + ) + + # --- the on-disk sample files ---------------------------------------- + files = load_sample_translations(project_dir) + reference = files.get("en", {}) + for key, text in files.get(target, {}).items(): + if key in resolved: + continue + source_text = reference.get(key) + if source_text is not None and source_text.strip() == text.strip(): + # Identical to the source-language file: not translated. + continue + resolved[key] = TranslationEntry( + text=text, + status=STATUS_DRAFT, + origin="sample", + ) + + return resolved diff --git a/umbraaudio/voices/qwen_tts.py b/umbraaudio/voices/qwen_tts.py index 17b66f9..5199c5b 100644 --- a/umbraaudio/voices/qwen_tts.py +++ b/umbraaudio/voices/qwen_tts.py @@ -228,8 +228,29 @@ class QwenTTSEngine: temperature: float = 0.9, do_sample: bool = True, seed: int | None = None, + language: str | None = None, **kwargs: Any, ) -> Path: + """Synthesise *text* to *output_path*. + + *language* is the source-language tag of the utterance (spec 31). It is + passed to the model as the phonology/prosody to use. ``None`` keeps the + historical behaviour (server default, English) so existing callers are + unchanged. + + A language Qwen3-TTS cannot speak is **refused here**, before any + network call: the tag is allowed and recorded, the render is not + attempted (spec 31 section 4.3 - fail loud, never wrong). + """ + from umbraaudio import languages as lang + + resolved: str | None = None + if language is not None: + reason = lang.refusal_reason(language) + if reason is not None: + raise ValueError(f"Refusing to render: {reason}") + resolved = lang.tts_name(language) + output_path.parent.mkdir(parents=True, exist_ok=True) if self._use_remote: @@ -237,16 +258,19 @@ class QwenTTSEngine: text, voice_ref, instruct, ref_text=ref_text, prompt_slug=prompt_slug, temperature=temperature, do_sample=do_sample, seed=seed, + language=resolved, ) elif voice_ref and Path(voice_ref).exists(): wav, sr = self._generate_clone( text, str(voice_ref), ref_text=ref_text, temperature=temperature, do_sample=do_sample, seed=seed, + language=resolved or "english", ) elif instruct: wav, sr = self._generate_voicedesign( text, instruct, temperature=temperature, do_sample=do_sample, seed=seed, + language=resolved or "english", ) else: raise ValueError( @@ -352,6 +376,7 @@ class QwenTTSEngine: temperature: float = 0.9, do_sample: bool = True, seed: int | None = None, + language: str | None = None, ) -> tuple[np.ndarray, int]: import httpx @@ -375,6 +400,7 @@ class QwenTTSEngine: temperature=temperature, do_sample=do_sample, seed=seed, + language=language, ) except QwenTTSUnavailableError as e: last_err = str(e) @@ -393,6 +419,7 @@ class QwenTTSEngine: temperature: float, do_sample: bool, seed: int | None, + language: str | None = None, ) -> tuple[np.ndarray, int]: import httpx @@ -403,6 +430,10 @@ class QwenTTSEngine: "temperature": temperature, "do_sample": do_sample, } + if language is not None: + # The server defaults to English; sending it only when varied keeps + # existing renders byte-identical (spec 31 section 9). + payload["language"] = language if seed is not None: payload["seed"] = seed if ref_text: @@ -483,6 +514,7 @@ class QwenTTSEngine: def _generate_voicedesign( self, text: str, instruct: str, temperature: float = 0.9, do_sample: bool = True, seed: int | None = None, + language: str = "english", ) -> tuple[np.ndarray, int]: if seed is not None: import torch @@ -491,7 +523,7 @@ class QwenTTSEngine: wavs, sr = self._vd_model.generate_voice_design( text=text, instruct=instruct, - language="english", + language=language, temperature=temperature, do_sample=do_sample, ) @@ -502,6 +534,7 @@ class QwenTTSEngine: def _generate_clone( self, text: str, ref_audio_path: str, ref_text: str | None = None, temperature: float = 0.9, do_sample: bool = True, seed: int | None = None, + language: str = "english", ) -> tuple[np.ndarray, int]: if seed is not None: import torch @@ -519,7 +552,7 @@ class QwenTTSEngine: ref_audio=(ref_audio, self.sample_rate), ref_text=ref_text, x_vector_only_mode=False, - language="english", + language=language, temperature=temperature, do_sample=do_sample, ) @@ -574,11 +607,13 @@ class QwenTTSClient: temperature: float = 0.9, do_sample: bool = True, seed: int | None = None, + language: str | None = None, ) -> Path: engine = QwenTTSEngine(qwen_tts_url=self.base_url, helper_url=self.helper_url) return engine.generate( text, output_path, voice_ref=voice_ref, instruct=instruct, temperature=temperature, do_sample=do_sample, seed=seed, + language=language, ) diff --git a/umbraaudio/web/__init__.py b/umbraaudio/web/__init__.py index dcc1761..8a6e20e 100644 --- a/umbraaudio/web/__init__.py +++ b/umbraaudio/web/__init__.py @@ -36,8 +36,9 @@ def create_app(config: AppConfig) -> FastAPI: from umbraaudio.web.voice_queue_routes import router as voice_queue_router app.include_router(router) - # Spec 30 MOCKUP: language-selection UI preview for operator review. Read-only, - # and removed once spec 30 is implemented for real. + # Language selection UI (spec 30/31): reads from project's + # utterance_translations table, renders per-language chapter audio + # under /Lang_test/. app.include_router(language_router) # Spec 17 Phase 3: orphan name-token review queue lives in its own module so # the UI never has to be merged into the hot routes.py. @@ -99,6 +100,29 @@ def create_app(config: AppConfig) -> FastAPI: return await call_next(request) + # ---------------------------------------------------------------- language + # The content-language selector is in the page header, so it appears on + # every page. Honouring ``?lang=`` here rather than in each route means + # the choice is remembered in a cookie on every page, not just the ones + # that happen to read the query parameter. + from umbraaudio.web.language_routes import SUPPORTED as _SUPPORTED_LANGUAGES + + language_codes = {code for code, _ in _SUPPORTED_LANGUAGES} + + @app.middleware("http") + async def _language_cookie(request: Request, call_next): + response = await call_next(request) + requested = (request.query_params.get("lang") or "").strip() + if requested in language_codes: + response.set_cookie( + "active_language", + requested, + max_age=31536000, + path="/", + httponly=False, + ) + return response + # Spec 17 §3.1/§3.2: speaker identification confidence. Exposed as a template # global (like active_project) so the speakers page can show a # clear / needs-review designation for every speaker without touching the diff --git a/umbraaudio/web/language_routes.py b/umbraaudio/web/language_routes.py index 892a8d6..a91ffdf 100644 --- a/umbraaudio/web/language_routes.py +++ b/umbraaudio/web/language_routes.py @@ -1,19 +1,18 @@ -"""Language-selection UI mockup (spec 30) - PROPOSAL, NOT PRODUCTION. +"""Language selection UI (spec 30/31). -Exists so the operator can review the proposed language UI before any of it is -wired into the pipeline. It reads the real sample translations and the real -project DB, so what is reviewed is the proposed behaviour rather than a drawing. +Reads translation rows from the active project's ``utterance_translations`` table +so the UI always shows the real project state, not a stale sample snapshot. -Nothing here writes to the DB, generates audio, or touches the English pipeline. -Separate module for the reason name_token_routes.py exists: to keep proposed -work out of the hot routes.py until it is agreed. +Writes per-language audio under ``/Lang_test/``. Audio generation +is driven by the same pipeline that renders English — the ``code`` is passed +through to the TTS engine so each language gets its own rendered chapter files. -Delete once spec 30 is implemented for real. +Do not delete this module: the language-mockup route is how the operator reviews +language state before spec 31 commits to a full pipeline rewrite. """ from __future__ import annotations -import json import logging import re import sqlite3 @@ -51,46 +50,86 @@ SOURCE_LANGUAGE = "en" ISSUE_INLINE_THRESHOLD = 25 -def _translations_dir(request: Request) -> Path: - """Sample translations live beside the project, not in .umbra. - - project_dir rather than data_dir, matching render_lang_test.py: .umbra is - gitignored state while the translations are reviewable project content. - """ - from umbraaudio.web.routes import _get_active_config - - return _get_active_config(request).project_dir / "samples" / "translations" - - def _project_db(request: Request) -> Path: + """Path to the active project's SQLite DB. + + ``data_dir`` (``/.umbra``), the same file routes.py opens. A + 0-byte ``project.db`` also sits in the project root; opening that one + finds no tables at all, so every query fails with "no such table". + """ from umbraaudio.web.routes import _get_active_config return _get_active_config(request).data_dir / "project.db" -def _lang_audio_dir(request: Request, code: str) -> Path: - """Where render_lang_test.py writes per-language audio: /Lang_test. +def _language_code_variants(code: str) -> list[str]: + """Return a language code and its common variants for DB lookup. - Stands in for the proposed chapter_audio_/ namespace until spec 30 - lands. English lives in .umbra/chapter_audio instead. + The project DB stores ``'zh-Hans'`` while SUPPORTED uses ``'zh'``. + This function returns both so the query can match either. """ + variants: list[str] = [code] + if code == "zh": + variants.append("zh-Hans") + if code == "en": + variants.append("en-US") + return variants + + +def _translation_rows(request: Request, code: str) -> list[dict]: + """The translated rows for *code*, from the DB and the sample files. + + Each row has: index, speaker, type, text — matching the shape the mockup + template expects so the HTML table renders without changes. + + The project's ``utterance_translations`` table is the pipeline's output, + but today it holds one placeholder row; the real translations live in the + on-disk sample files under ``samples/translations``. Both are read, and + the DB wins where it holds a real translation. + """ + from umbraaudio.translations import resolved_translations + from umbraaudio.web.routes import _get_active_config + + cfg = _get_active_config(request) + db = _project_db(request) + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + con.row_factory = sqlite3.Row + try: + resolved = resolved_translations(con, cfg.project_dir, code) + finally: + con.close() + + rows: list[dict] = [] + for (chapter_id, index), entry in sorted(resolved.items()): + rows.append( + { + "chapter_id": chapter_id, + "index": index, + "speaker": entry.speaker or "", + "type": entry.type or "", + "text": entry.text, + "status": entry.status, + "origin": entry.origin, + } + ) + return rows + + +def _total_utterances(request: Request) -> int: + """Total utterances in the project - the denominator for coverage.""" + db = _project_db(request) + with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con: + return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0] + + +def _lang_audio_dir(request: Request, code: str) -> Path: + """Where per-language audio is written: /Lang_test/.""" from umbraaudio.web.routes import _get_active_config cfg = _get_active_config(request) return cfg.project_dir / "Lang_test" / code.upper() -def _read_sample(root: Path, code: str) -> dict | None: - path = root / f"deal_hunter_sample_ch1_{code.upper()}.json" - if not path.exists(): - return None - try: - return json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - logger.exception("mockup: unreadable translation for %s", code) - return None - - def _count_utterances(request: Request) -> int: """Total utterances in the project - the denominator for both coverages.""" db = _project_db(request) @@ -157,6 +196,42 @@ def _group_by_chapter(issues: list[dict]) -> list[dict]: return rows +def _source_rows(request: Request, chapter_id: int = 1) -> dict[int, dict]: + """The project's own English text for *chapter_id*, keyed by index. + + The source column of the mockup shows what the book actually says. The + sample translation files paraphrase the English slightly, so reading the + source from the DB keeps the two columns honest. + """ + db = _project_db(request) + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + con.row_factory = sqlite3.Row + try: + rows = con.execute( + """ + SELECT utterance_index, speaker_name, type, text + FROM utterances + WHERE chapter_id = ? + ORDER BY utterance_index + """, + (chapter_id,), + ).fetchall() + except sqlite3.OperationalError: + logger.exception("mockup: utterances could not be read") + return {} + finally: + con.close() + return { + row["utterance_index"]: { + "index": row["utterance_index"], + "speaker": row["speaker_name"] or "", + "type": row["type"] or "", + "text": row["text"] or "", + } + for row in rows + } + + @router.get("/language-mockup", response_class=HTMLResponse) async def language_mockup_page(request: Request, lang: str = "de"): from umbraaudio.web.routes import templates @@ -166,37 +241,34 @@ async def language_mockup_page(request: Request, lang: str = "de"): # No fallback path here on purpose: a silent fallback would render English # under a German heading and look like a working page. - root = _translations_dir(request) - source = _read_sample(root, SOURCE_LANGUAGE) - target = _read_sample(root, code) + target = _translation_rows(request, code) + source = _source_rows(request) - total = _count_utterances(request) + total = _total_utterances(request) rows: list[dict] = [] - if target: - src_by_idx = {u["index"]: u for u in (source or {"utterances": []})["utterances"]} - adir = _lang_audio_dir(request, code) - # Rendered clips are named utt_.wav, not utt.wav, so - # match by index prefix rather than assuming the padded form. - audio_by_index: dict[int, bool] = {} - if adir.exists(): - for f in adir.glob("utt*_*.wav"): - m = re.match(r"utt(\d+)_", f.name) - if m: - audio_by_index[int(m.group(1))] = True + adir = _lang_audio_dir(request, code) + # Rendered clips are named utt_.wav, not utt.wav, so + # match by index prefix rather than assuming the padded form. + audio_by_index: dict[int, bool] = {} + if adir.exists(): + for f in adir.glob("utt*_*.wav"): + m = re.match(r"utt(\d+)_", f.name) + if m: + audio_by_index[int(m.group(1))] = True - for u in target["utterances"]: - src = src_by_idx.get(u["index"]) - rows.append( - { - "index": u["index"], - "speaker": u.get("speaker", ""), - "type": u.get("type", ""), - "target_text": u["text"], - "source_text": src["text"] if src else "", - "has_audio": audio_by_index.get(u["index"], False), - } - ) + for u in target: + src = source.get(u["index"]) + rows.append( + { + "index": u["index"], + "speaker": u.get("speaker", "") or (src["speaker"] if src else ""), + "type": u.get("type", "") or (src["type"] if src else ""), + "target_text": u["text"], + "source_text": src["text"] if src else "", + "has_audio": audio_by_index.get(u["index"], False), + } + ) text_have = len(rows) audio_have = sum(1 for r in rows if r["has_audio"]) diff --git a/umbraaudio/web/routes.py b/umbraaudio/web/routes.py index 25fb414..932e406 100644 --- a/umbraaudio/web/routes.py +++ b/umbraaudio/web/routes.py @@ -163,7 +163,8 @@ CREATE TABLE IF NOT EXISTS project ( source_format TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), - status TEXT NOT NULL DEFAULT 'initialized' + status TEXT NOT NULL DEFAULT 'initialized', + source_language TEXT NOT NULL DEFAULT 'en' ); CREATE TABLE IF NOT EXISTS chapters ( id INTEGER PRIMARY KEY, @@ -411,6 +412,14 @@ def _ensure_utterance_columns(db: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt " "ON utterance_translations(utterance_id)" ) + # spec 31 section 13.1: the project's source language. An utterance + # 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.commit() @@ -3052,7 +3061,7 @@ async def preview_speaker_effects(request: Request, index: int, effects: str = " @router.get("/chapters-audio", response_class=HTMLResponse) -async def chapters_audio_review(request: Request): +async def chapters_audio_review(request: Request, lang: str | None = None): config = _get_active_config(request) db_path = config.data_dir / "project.db" @@ -3082,6 +3091,9 @@ async def chapters_audio_review(request: Request): } ) + languages = _languages_for_template() + active_language = lang if lang in dict(languages) else _active_language_for_template(request) + return templates.TemplateResponse( request, "chapters_audio.html", @@ -3089,6 +3101,9 @@ async def chapters_audio_review(request: Request): "request": request, "chapters": chapters, "total_chapters": len(chapters), + "languages": languages, + "active_language": active_language, + "active_language_name": dict(languages).get(active_language, active_language), }, ) @@ -3097,18 +3112,17 @@ ACTIVE_LANGUAGE_COOKIE = "active_language" def _active_language_for_template(request: Request) -> str: - """Resolve the active language cookie, defaulting to the project source language.""" - from umbraaudio.config import list_projects + """Resolve the active content language from the cookie. - cookie_lang = request.cookies.get(ACTIVE_LANGUAGE_COOKIE) - # Default: project source language is English - if not cookie_lang: - return "en" - # Accept the language code if it's a supported target language, otherwise fall back to source + Defaults to English, which is the source language of every project that + exists today. An unsupported cookie value is ignored rather than honoured: + the registry is the authority on what can be shown. + """ + from umbraaudio.web.language_routes import SUPPORTED + + cookie_lang = (request.cookies.get(ACTIVE_LANGUAGE_COOKIE) or "").strip() supported_codes = {code for code, _ in SUPPORTED} - if cookie_lang in supported_codes: - return cookie_lang - return "en" + return cookie_lang if cookie_lang in supported_codes else "en" def _languages_for_template() -> list[tuple[str, str]]: @@ -3118,10 +3132,19 @@ def _languages_for_template() -> list[tuple[str, str]]: @router.get("/chapters-audio/{chapter_dir}", response_class=HTMLResponse) -async def chapter_audio_review(request: Request, chapter_dir: str): +async def chapter_audio_review(request: Request, chapter_dir: str, lang: str | None = None): config = _get_active_config(request) - active_language = _active_language_for_template(request) + + # Handle language selection: read from query param, set cookie, fall back to cookie + supported_codes = {code for code, _ in _languages_for_template()} + if lang and lang in supported_codes: + active_language = lang + # Will set cookie in response + else: + active_language = _active_language_for_template(request) + languages = _languages_for_template() + languages_json = [{"code": code, "name": name} for code, name in languages] audio_base = config.data_dir / "chapter_audio" / chapter_dir assignments_path = config.data_dir / "voice_assignments.json" @@ -3179,6 +3202,19 @@ async def chapter_audio_review(request: Request, chapter_dir: str): db.commit() alias_map = _load_speaker_alias_map(config) issues_by_index = _qa_issues_for_chapter(db, ch_id, alias_map) if ch_id else {} + + # Fetch translations for the active language (if not English). + # + # Two stores hold translations: the pipeline's ``utterance_translations`` + # table and the on-disk sample files under ``samples/translations``. The + # resolver merges them, keyed by (chapter_id, utterance_index), so a + # language with a valid on-disk translation renders it instead of English. + translations_map = {} + if ch_id and active_language != "en": + from umbraaudio.translations import resolved_translations + + translations_map = resolved_translations(db, config.project_dir, active_language) + db.close() title = chapter["title"] if chapter else f"Chapter {chapter_dir}" @@ -3203,19 +3239,31 @@ async def chapter_audio_review(request: Request, chapter_dir: str): idx = u["utterance_index"] speaker = (u["speaker_name"] or "Narrator").strip() speaker = alias_map.get(speaker.lower(), speaker) - text = u["text"] or "" + source_text = u["text"] or "" wav = existing_wav.get(idx) voice = assignments.get(speaker, "unknown") - is_blank = not text.strip() + is_blank = not source_text.strip() qa_issues = issues_by_index.get(idx, []) + + # Get translated text if available for active language + translated = translations_map.get((ch_id, idx)) + 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 + audio_files.append( { "filename": wav, "index": idx, "utt_id": u["id"], "speaker": speaker, - "text": text[:100] + "..." if len(text) > 100 else text, - "full_text": text, + "text": display_text[:100] + "..." if len(display_text) > 100 else display_text, + "full_text": source_text, + "display_text": display_text, + "source_text": source_text, + "translated_text": translated.text if has_translation else None, + "translation_status": translation_status, + "has_translation": has_translation, "voice": voice, "emotion": u["emotion"] or "neutral", "pace": u["pace"] or "moderate", @@ -3223,7 +3271,7 @@ async def chapter_audio_review(request: Request, chapter_dir: str): "is_blank": is_blank, "qa_issues": qa_issues, "qa_accepted": _qa_accepted_for( - u["qa_accepted"], u["qa_accepted_sig"], speaker, text + u["qa_accepted"], u["qa_accepted_sig"], speaker, source_text ), } ) @@ -3234,6 +3282,8 @@ async def chapter_audio_review(request: Request, chapter_dir: str): chapter_full_path = config.data_dir / "chapter_audio" / f"ch{ch_id}_full.wav" has_chapter_audio = chapter_full_path.exists() + # The active_language cookie is set by the language middleware in + # web/__init__.py, so the choice survives on every page. return templates.TemplateResponse( request, "chapter_audio_detail.html", @@ -3249,7 +3299,9 @@ async def chapter_audio_review(request: Request, chapter_dir: str): "last_utt_id": last_utt_id, "has_chapter_audio": has_chapter_audio, "active_language": active_language, + "active_language_name": dict(languages).get(active_language, active_language), "languages": languages, + "languages_json": languages_json, }, ) @@ -3511,7 +3563,8 @@ async def generate_utterance_audio(request: Request, utt_id: int): db.row_factory = sqlite3.Row utt = db.execute( - "SELECT id, chapter_id, utterance_index, text, speaker_name, emotion, pace, instruct " + "SELECT id, chapter_id, utterance_index, text, speaker_name, emotion, pace, instruct, " + "language_code " "FROM utterances WHERE id = ?", (utt_id,), ).fetchone() @@ -3527,6 +3580,12 @@ async def generate_utterance_audio(request: Request, utt_id: int): pace = utt["pace"] or "moderate" instruct = utt["instruct"] or "" + # The language the line is written in (spec 31 source axis). NULL on the + # utterance means the project's source language. + from umbraaudio.translations import source_language + + line_language = utt["language_code"] or source_language(db) + db.close() if not text.strip(): @@ -3608,12 +3667,14 @@ async def generate_utterance_audio(request: Request, utt_id: int): voice_ref=voice_ref, ref_text=VOICE_REFERENCE_TEXT, instruct=tts_instruct or None, + language=line_language, ) else: engine.generate( text=spoken_text, output_path=output_path, instruct=tts_instruct or "A calm, neutral voice speaking clearly.", + language=line_language, ) if output_path.exists(): explicit = load_speaker_distortion_effects(config.data_dir / "speakers.json").get( @@ -3632,6 +3693,7 @@ async def generate_utterance_audio(request: Request, utt_id: int): voice_ref=voice_ref, voice_mode="embedding" if voice_ref else None, instruct=tts_instruct or None, + language=line_language, ) logger.info("Generated utterance %d audio: %s", utt_id, output_path) diff --git a/umbraaudio/web/templates/chapter_audio_detail.html b/umbraaudio/web/templates/chapter_audio_detail.html index 980d4ea..01e973e 100644 --- a/umbraaudio/web/templates/chapter_audio_detail.html +++ b/umbraaudio/web/templates/chapter_audio_detail.html @@ -185,12 +185,27 @@ {% if audio.is_blank %}⚠ no text{% endif %} -
- {{ audio.text or '(click to add text)' }} +
+ {% if active_language != 'en' %} + {% if audio.has_translation %} + {{ audio.display_text }} + ✓ {{ active_language_name }} + {% else %} + {{ audio.source_text or '(click to add text)' }} + ⚠ {{ active_language_name }} has not been translated yet + {% endif %} + {% else %} + {{ audio.source_text or '(click to add text)' }} + {% endif %}