Files
UmbraAudio/specs/30-language-selection.md
B.D. Murphy 3168b51e7c Spec 30: language as a first-class dimension, with a UI mockup
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.
2026-10-14 07:52:18 -07:00

8.6 KiB
Raw Blame History

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

  1. 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.
  2. Speaker names — Kainda Walters Foster Murphy is a proper noun; we transliterated in the samples. Same question.
  3. 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

  1. utterance_translations table + migration (additive, no existing behaviour touched)
  2. Audio namespace + marker language field (the §2 correctness fix)
  3. Selection cookie + header selector
  4. Content rendering in the selected language + fallback labelling
  5. UmbraTrans export/import
  6. 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.