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:
211
specs/30-language-selection.md
Normal file
211
specs/30-language-selection.md
Normal 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.
|
||||
Reference in New Issue
Block a user