Proposal plus a working read-only mockup at /language-mockup for operator
review. German selected by default, as asked.
Measured first, so the proposal reflects reality:
utterances 4,319 rows, no language column - one text per utterance
language absent from the DB entirely; exists only as a filename
convention in the experimental lang-samples page
/v1/tts language: str = Form("english") - free-form, unvalidated
marker utt_XXXX.wav.txt JSON has speaker/text/voice_fp, no language
The load-bearing finding is that audio MUST be namespaced per language
before any second language is generated. staleness.is_current() compares
the marker's recorded text against the utterance text, so German and
English sharing ch6/utt_0001.wav would each mark the other stale and
overwrite it in a loop. That is a correctness requirement, not a tidy-up.
Schema choice: a new utterance_translations table rather than a language
column on utterances. One row can hold only one language, so a column
either forces 10x duplication of speaker/emotion/pace/attribution or
overwrites the source text. UNIQUE(utterance_id, language_code) makes
UmbraTrans re-delivery an update instead of a second competing German.
English stays in utterances.text and stays editable; translations are
confirm-only, matching the agreed asymmetry.
Audio: chapter_audio/ (English, path untouched, zero regression risk)
gains siblings chapter_audio_de/ etc. Marker JSON gains "language" so an
export can prove what it shipped.
The mockup is a separate router for the reason name_token_routes.py exists
- it keeps proposed work out of the hot routes.py until it is agreed. It
writes nothing, generates nothing, and touches the English pipeline not at
all. Verified: 200 on all 10 languages, real German text rendered,
coverage read from the real 4,319-row DB, unknown codes fall back to
German without a traceback, and the auth gate still 303s to /login.
I initially invented a routes.py helper (resolve_project_dir) that does
not exist; the real one is _get_active_config(request).project_dir. I also
removed a silent cwd fallback that would have rendered English under a
German heading and looked like a working page, and made an unreadable DB
show 'could not measure' rather than a misleading 0%.
Suite: 721 passed, 8 pre-existing. ruff clean on new files.
8.6 KiB
Spec 30 — Language as a first-class dimension
Status: PROPOSAL — for operator review. Nothing here is built except the mockup linked at the end.
Context: UmbraTrans will supply translated utterance text; UmbraAudio voices it. Qwen3-TTS speaks exactly 10 languages, which is where we start. The UI chrome stays English; only content changes with the selected language.
1. What exists today
Measured, not assumed:
| Thing | State |
|---|---|
utterances table |
4,319 rows, no language column — one text per utterance, implicitly English |
| Language in the DB | none — language exists only as a filename convention in the lang-samples page |
/v1/tts language |
language: str = Form("english") — free-form, not validated against the supported set |
| Audio path | .umbra/chapter_audio/ch6/utt_0001.wav — no language dimension |
| Staleness marker | utt_0001.wav.txt JSON with speaker/text/voice_fp/instruct — no language |
So the 10 rendered languages today live in a side experiment (Lang_test/), entirely
outside the project's data model. Nothing in the schema can express "this utterance has
a German text".
2. The one hard constraint
is_current() (spec: umbraaudio/generation/staleness.py:91) compares the marker's
recorded text against the utterance's text.
If German and English shared ch6/utt_0001.wav, then:
- German render writes marker
{text: "<German>"} - the English check compares against English text → mismatch → stale → re-renders English, overwriting German
- which makes the German check stale in turn
They destroy each other, forever. Audio must be namespaced per language before any second language is generated. This is not an optimisation; it is a correctness requirement, and it is why the feature has to land before bulk translation does.
3. Schema
3.1 English stays where it is
utterances.text remains the source-language working copy and remains editable.
Translations are confirm-only — they are an imported artifact, not a place to type.
3.2 Translations live in their own table
Rejected: ALTER TABLE utterances ADD COLUMN language TEXT.
One row can then hold only one language. Either you duplicate every utterance per
language (4,319 × 10 = 43,190 rows, each re-storing speaker, emotion, pace, instruct and
attribution — and they would drift), or switching language overwrites the source text.
Both are wrong.
CREATE TABLE utterance_translations (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
language_code TEXT NOT NULL, -- '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, -- external job/row id, for round-trip
delivered_at TEXT,
confirmed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(utterance_id, language_code)
);
UNIQUE(utterance_id, language_code) is what makes re-delivery safe: UmbraTrans can
resend a chapter and it updates in place instead of accumulating a second German text
for the same line. Without it, "which German is the real German?" becomes unanswerable
after one retry.
status exists because the operator's rule is asymmetric — English is editable,
translations are confirm-only. A draft row is visible and audible but is explicitly
not approved; confirmed is the one an export may cite.
4. Audio namespace
.umbra/chapter_audio/ <- English (source) — path unchanged, zero regression risk
.umbra/chapter_audio_de/ <- German
.umbra/chapter_audio_fr/ <- French
A sibling root per language, not a subdirectory, because the English path scheme is
hardcoded across routes.py, generate_all_audio.py and the exporters. Keeping English
exactly where it is means this feature cannot break the pipeline that already works.
The name is derived (chapter_audio + _ + code), so there is no mapping table to keep
in sync.
4.1 The marker records its language
write_marker() gains language, and is_current() gains a matching check:
{"speaker": "...", "text": "...", "language": "de", "voice_fp": "...", "instruct": "..."}
This is what lets an export prove what it shipped. Without it, a German clip is
indistinguishable from an English one at the byte level — the same declared-vs-measured
hole that release.py already pins chapters against.
Note voice_fp needs no new references: per spec 29 characters keep their existing
refs and the narrator uses narrator_ref.wav, so voice_fp is unchanged across
languages.
5. Selection state
A separate cookie active_language, defaulting to the project's source language.
Same mechanism as active_project, deliberately separate so that switching language
can never disturb project selection.
Selection is additive and reversible. Switching to German and back to English must return byte-identical English state — no destructive toggle, no "last language wins".
6. UI
6.1 Selector
Language selector in the header, beside the existing "Active project" badge, because it changes what every page shows. Putting it on one page would make the other pages lie by omission.
6.2 What is translated and what is not
Per the requirement, the split is:
| English (always) | Selected language (content only) |
|---|---|
| Nav, buttons, labels, statuses | Utterance text |
| Column headers, badges | Chapter titles from the book |
| Error and QA messages | Reviewer statements / notes |
So selecting German gives you a German audiobook under English controls. The chrome stays readable to someone who does not speak the target language — which is also who has to operate the review UI.
6.3 Missing translations must be visible
If German is selected and utterance 47 has no German text, the UI shows the English source labelled as a fallback — never English presented as if it were German. A silent fallback is how a reviewer would ship a book that is half untranslated and never know. Coverage is shown as a first-class number, not a footnote.
7. UmbraTrans handoff
Export (UmbraAudio → UmbraTrans), one row per utterance:
{utterance_id, chapter_id, chapter_index, utterance_index, speaker_name, source_text, context_before, context_after}
context_before/after matter: a line like "Yes." is untranslatable in isolation.
Import (UmbraTrans → UmbraAudio) returns the same shape plus language_code and
text. POST /api/import/translations, idempotent via the UNIQUE constraint,
refusing to silently overwrite a confirmed row.
8. Server-side validation
/v1/tts accepts any string as language. An unsupported or misspelled code currently
fails late and confusingly — Qwen3-TTS has exactly 10 codec_language_id keys, and an
unknown one is not a clean error. Validate at the boundary against the supported set and
fail fast with the list.
| Languages | |
|---|---|
| Qwen3-TTS (10) | Chinese, English, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish |
| Gap (5, needs Chatterbox) | Dutch, Norwegian, Polish, Arabic, Hindi |
The gap languages are out of scope for v1 and must be visibly marked as unsupported rather than silently offered.
9. Open questions for the operator
- Does the source chapter title get translated? Titles are book content, so by the rule above yes — but they are also used in filenames. Needs a decision.
- Speaker names —
Kainda Walters Foster Murphyis a proper noun; we transliterated in the samples. Same question. - Does a missing translation block generation, or generate English and mark the clip as fallback? Proposal: block it, so a book cannot be half-German by accident.
10. Build order
utterance_translationstable + migration (additive, no existing behaviour touched)- Audio namespace + marker
languagefield (the §2 correctness fix) - Selection cookie + header selector
- Content rendering in the selected language + fallback labelling
- UmbraTrans export/import
- Server-side language validation
Steps 1–2 must land before any bulk translation, or translated audio has nowhere correct to go.
Mockup
/language-mockup — a working page, German selected, rendering the sample chapter's
12 utterances in each of the 10 languages from the translations already on disk. The
language selector is functional, so switching language shows that language's text.