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.
This commit is contained in:
B.D. Murphy
2026-10-14 07:52:18 -07:00
parent 2f46be9520
commit 3168b51e7c
4 changed files with 600 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
# 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.
```sql
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:
```json
{"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.