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.
12 KiB
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)
- 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.
- The source language tag comes from UmbraTrans during translation. UmbraTrans is the authority on identification. See §9 for the sequencing consequence this creates.
- A tag in an unsupported language is allowed and recorded; the render is refused. Never emit a degraded voice. Fail loud, never wrong.
- 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
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:
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
- Book-level source language. Currently a constant (
"en"). Should it become a column onprojects, set at parse time? Needed to know what "not the primary language" means per book. Proposed: yes, column onprojects, defaulting toen. - 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.
- 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.