Per-utterance language flag + a working language selector (spec 31)

The review UI's language dropdown did nothing: every selection kept
rendering the English text. Two independent causes, both fixed.

1. The translations were never being read. The pipeline's
   `utterance_translations` table holds one row in Deal Hunter, and that
   row stores the *English* text under a `zh-Hans` code. The real
   translations live in the on-disk sample files under
   `samples/translations/`. `umbraaudio/translations.py` now resolves both
   sources and merges them by (chapter_id, utterance_index): the table wins
   where it holds a real translation, the sample files fill the gaps. A
   candidate whose text is identical to the source is a placeholder, not a
   translation, so it is ignored - otherwise the page would show English
   under a Chinese heading and claim it was translated.

2. The query that read them was broken. `language_routes.py` selected
   `u.index` and `u.speaker`; the columns are `utterance_index` and
   `speaker_name`, so every read raised `sqlite3.OperationalError: near
   "index": syntax error` and `/language-mockup` returned 500. It also
   opened `<project>/project.db` - a 0-byte decoy - instead of
   `<project>/.umbra/project.db`, the file that actually holds the data.

Also in this commit:

* The chapter review page honours `?lang=`, remembers the choice in a
  cookie (set by a middleware, so it survives on every page), and shows the
  selected language's *name* in the "has not been translated yet" message.
* The page now shows the translation, not the source, and the edit box is
  bound to the source text so saving cannot overwrite the English with the
  Chinese.
* The stray language selector that sat after `</html>` in base.html is
  gone; the chapters-list page's dropdown had zero options because the
  route never passed the language list.
* The language tag is wired into generation: `utterance.language_code`
  (NULL = project source language) reaches `engine.generate()` and the
  staleness marker, so a retagged line is re-rendered instead of keeping
  English phonology behind an unchanged text. The marker records the
  language; a marker written before the tag existed means English, so the
  ~4,300 existing clips are not invalidated.
* `project.source_language` (spec 31 section 13.1) records the book's own
  language, with a migration for existing project DBs.
* A scene-break marker deliberately records no language: it is silence,
  identical in every language, and tagging it would claim a phonology the
  audio does not have.

Tests: 883 passed / 8 failed - the same 8 pre-existing failures
(2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests
cover the resolver and the generation wiring. ruff introduces no new
findings.
This commit is contained in:
B.D. Murphy
2026-11-06 16:55:49 -08:00
parent c10e1ae1fd
commit 2b294d7fa4
20 changed files with 2875 additions and 95 deletions

View File

@@ -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"
}
]
}

View File

@@ -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

View File

@@ -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 <language>`),
- 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.

View File

@@ -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"

View File

@@ -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

101
tests/test_languages.py Normal file
View File

@@ -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)

View File

@@ -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 == {}

366
tests/test_translations.py Normal file
View File

@@ -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
)

152
tests/test_tts_language.py Normal file
View File

@@ -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"

View File

@@ -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'"

View File

@@ -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

View File

@@ -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():

View File

@@ -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

130
umbraaudio/languages.py Normal file
View File

@@ -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

765
umbraaudio/translations.py Normal file
View File

@@ -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.
# * ``<project>/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

View File

@@ -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,
)

View File

@@ -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 <project>/Lang_test/<CODE>.
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

View File

@@ -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 ``<project>/Lang_test/<CODE>``. 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`` (``<project>/.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: <project>/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_<code>/ 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: <project>/Lang_test/<CODE>."""
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<NN>_<Speaker>.wav, not utt<NNNN>.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<NN>_<Speaker>.wav, not utt<NNNN>.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"])

View File

@@ -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)

View File

@@ -185,12 +185,27 @@
</td>
<td>
{% if audio.is_blank %}<span class="badge badge-red" style="color:#ef4444">⚠ no text</span>{% endif %}
<div id="text-display-{{ audio.index }}" onclick="editText({{ audio.index }})" style="cursor:pointer; padding:0.2rem; border-radius:3px" title="Click to edit">
<small style="color:#94a3b8">{{ audio.text or '(click to add text)' }}</small>
<div id="text-display-{{ audio.index }}" onclick="editText({{ audio.index }})" style="cursor:pointer; padding:0.2rem; border-radius:3px" title="Click to edit the source text">
{% if active_language != 'en' %}
{% if audio.has_translation %}
<small style="color:#e0e0e0">{{ audio.display_text }}</small>
<span style="color:#22c55e; font-size:0.7rem; margin-left:0.5rem">✓ {{ active_language_name }}</span>
{% else %}
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
<span style="color:#f59e0b; font-size:0.7rem; margin-left:0.5rem">⚠ {{ active_language_name }} has not been translated yet</span>
{% endif %}
{% else %}
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
{% endif %}
</div>
<div id="text-edit-{{ audio.index }}" style="display:none">
{% if audio.has_translation %}
<small style="color:#64748b; font-size:0.7rem; display:block; margin-bottom:0.2rem">
Source text ({{ active_language_name }} shown above — editing changes the source, not the translation)
</small>
{% endif %}
<textarea id="text-input-{{ audio.index }}" rows="2"
style="width:100%; padding:0.3rem; background:#1a1a2e; border:1px solid #7c3aed; border-radius:3px; color:#e0e0e0; font-size:0.8rem">{{ audio.full_text }}</textarea>
style="width:100%; padding:0.3rem; background:#1a1a2e; border:1px solid #7c3aed; border-radius:3px; color:#e0e0e0; font-size:0.8rem">{{ audio.source_text }}</textarea>
<div style="margin-top:0.25rem; display:flex; gap:0.25rem">
<button class="btn btn-approve" style="font-size:0.7rem; padding:0.15rem 0.4rem" onclick="saveText({{ audio.utt_id }}, {{ audio.index }})">Save</button>
<button class="btn" style="font-size:0.7rem; padding:0.15rem 0.4rem; background:#374151; color:#9ca3af" onclick="cancelEditText({{ audio.index }})">Cancel</button>
@@ -653,7 +668,8 @@ function savePersonalization() {
// Set the selected language from URL param if valid
if (urlLang && select) {
var validOptions = {{ languages.map(function(c, i) { return '{{ code }}'; }|join(',') }}); // simplified
// languages_json is passed from the template as a JSON array of {code, name} objects
var validOptions = {{ languages_json | tojson }};
// Just set the selection - the page will reload on change
select.value = urlLang || 'en';
}