diff --git a/specs/30-language-selection.md b/specs/30-language-selection.md new file mode 100644 index 0000000..3cb1c05 --- /dev/null +++ b/specs/30-language-selection.md @@ -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: ""}` +- 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. \ No newline at end of file diff --git a/umbraaudio/web/__init__.py b/umbraaudio/web/__init__.py index 9c48cdd..dcc1761 100644 --- a/umbraaudio/web/__init__.py +++ b/umbraaudio/web/__init__.py @@ -30,11 +30,15 @@ def create_app(config: AppConfig) -> FastAPI: from umbraaudio.config import list_projects from umbraaudio.pipeline_manager import PipelineManager from umbraaudio.voices.generation_manager import GenerationManager + from umbraaudio.web.language_routes import router as language_router from umbraaudio.web.name_token_routes import router as name_token_router from umbraaudio.web.routes import NoActiveProjectError, router from umbraaudio.web.voice_queue_routes import router as voice_queue_router app.include_router(router) + # Spec 30 MOCKUP: language-selection UI preview for operator review. Read-only, + # and removed once spec 30 is implemented for real. + app.include_router(language_router) # Spec 17 Phase 3: orphan name-token review queue lives in its own module so # the UI never has to be merged into the hot routes.py. app.include_router(name_token_router) diff --git a/umbraaudio/web/language_routes.py b/umbraaudio/web/language_routes.py new file mode 100644 index 0000000..8273272 --- /dev/null +++ b/umbraaudio/web/language_routes.py @@ -0,0 +1,162 @@ +"""Language-selection UI mockup (spec 30) - PROPOSAL, NOT PRODUCTION. + +This module exists so the operator can review the proposed German UI before +any of it is wired into the pipeline. It reads the real sample translations +already on disk and renders them through a functional language selector, so +what is being reviewed is the actual proposed behaviour rather than a drawing. + +Nothing here writes to the project DB, generates audio, or alters the English +pipeline. It is a separate router for the same reason name_token_routes.py +exists: to keep the proposed work out of the hot routes.py until it is agreed. + +Delete this module once spec 30 is implemented for real. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +if TYPE_CHECKING: + # Annotation-only; `from __future__ import annotations` makes this safe. + from pathlib import Path + +logger = logging.getLogger(__name__) +router = APIRouter() + +# The 10 languages Qwen3-TTS speaks. Lowercase codes as the model expects them +# (spec 30 section 8). The 5 gap languages are listed but not offered, because +# offering them would imply we can voice them. +SUPPORTED: list[tuple[str, str]] = [ + ("en", "English"), + ("zh", "Chinese"), + ("ja", "Japanese"), + ("ko", "Korean"), + ("de", "German"), + ("fr", "French"), + ("it", "Italian"), + ("es", "Spanish"), + ("pt", "Portuguese"), + ("ru", "Russian"), +] + +UNSUPPORTED: list[tuple[str, str]] = [ + ("nl", "Dutch"), + ("no", "Norwegian"), + ("pl", "Polish"), + ("ar", "Arabic"), + ("hi", "Hindi"), +] + +SOURCE_LANGUAGE = "en" + + +def _translations_dir(request: Request) -> Path: + """Sample translations live beside the project, not in .umbra. + + project_dir rather than data_dir, matching render_lang_test.py: .umbra is + gitignored state while the translations are reviewable project content. + """ + from umbraaudio.web.routes import _get_active_config + + return _get_active_config(request).project_dir / "samples" / "translations" + + +def _project_db(request: Request) -> Path: + from umbraaudio.web.routes import _get_active_config + + return _get_active_config(request).data_dir / "project.db" + + +def _read_sample(root: Path, code: str) -> dict | None: + path = root / f"deal_hunter_sample_ch1_{code.upper()}.json" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + logger.exception("mockup: unreadable translation for %s", code) + return None + + +@router.get("/language-mockup", response_class=HTMLResponse) +async def language_mockup_page(request: Request, lang: str = "de"): + from umbraaudio.web.routes import templates + + code = lang.lower() if lang.lower() in dict(SUPPORTED) else "de" + + # No fallback path here on purpose: a silent fallback would render English + # under a German heading and look like a working page. + root = _translations_dir(request) + + source = _read_sample(root, SOURCE_LANGUAGE) + target = _read_sample(root, code) + + # Coverage is measured against the real project, not the 12-row sample, so + # the number the operator sees is the honest one. + coverage: list[dict] = [] + try: + import sqlite3 + + db = _project_db(request) + with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con: + total = con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0] + for c, name in SUPPORTED: + doc = _read_sample(root, c) + have = len(doc["utterances"]) if doc else 0 + coverage.append( + { + "code": c, + "name": name, + "have": have, + "total": total, + "pct": (have / total * 100) if total else 0.0, + "selected": c == code, + } + ) + except Exception: + # Surfaced in the template rather than silently rendering 0%, which + # would read as "nothing translated" instead of "could not measure". + logger.exception("mockup: coverage unavailable") + total = 0 + coverage_error = True + else: + coverage_error = False + + rows: list[dict] = [] + if source and target: + src_by_idx = {u["index"]: u for u in source["utterances"]} + for u in target["utterances"]: + src = src_by_idx.get(u["index"]) + rows.append( + { + "index": u["index"], + "speaker": u.get("speaker", ""), + "type": u.get("type", ""), + "target_text": u["text"], + "source_text": src["text"] if src else "", + "translated": bool(src and src["text"] != u["text"]), + } + ) + + return templates.TemplateResponse( + request, + "language_mockup.html", + { + "languages": SUPPORTED, + "unsupported": UNSUPPORTED, + "code": code, + "language_name": dict(SUPPORTED).get(code, code), + "target": target, + "source": source, + "rows": rows, + "coverage": coverage, + "coverage_error": coverage_error, + "total": total, + "is_source": code == SOURCE_LANGUAGE, + }, + ) diff --git a/umbraaudio/web/templates/language_mockup.html b/umbraaudio/web/templates/language_mockup.html new file mode 100644 index 0000000..d9d5d7e --- /dev/null +++ b/umbraaudio/web/templates/language_mockup.html @@ -0,0 +1,223 @@ +{% extends "base.html" %} +{% block content %} + +
+ MOCKUP — spec 30, not built. + + Read-only preview of the proposed language UI. Nothing here writes to the database, + generates audio, or changes the English pipeline. + +
+ + +
+

Language selection

+

+ Sits in the page header beside Active project in the real build, because it + changes what every page shows. Controls below stay in English always — only the + content column changes. +

+ +
+ Content language: + {% for c, name in languages %} + + {{ name }} + + {% endfor %} +
+ +
+ + Not offered yet (Qwen3-TTS cannot voice them — spec 26 §gap): + {% for c, name in unsupported %}{{ name }}{% endfor %} + +
+
+ + +
+

Translation coverage

+ {% if coverage_error %} +
+ Coverage could not be measured. + The project database was not readable, so these figures + are absent rather than reported as zero. +
+ {% else %} +

+ Measured against the real project ({{ total }} utterances), not the 12-row sample. + In the built version this is the number that tells you whether a book is safe to + export — a book that is accidentally half-German must be impossible to miss. +

+ {% endif %} + + + + + + {% for c in coverage if not coverage_error %} + + + + + + + {% endfor %} + +
LanguageDeliveredCoverageStatus
+ {% if c.selected %}{{ c.name }} + {% else %}{{ c.name }}{% endif %} + {{ c.have }} / {{ c.total }} +
+
+
+ {{ '%.3f' % c.pct }}% +
+ {% if c.code == 'en' %} + source · editable + {% elif c.pct == 0 %} + not started + {% elif c.pct >= 99.9 %} + complete · unconfirmed + {% else %} + partial + {% endif %} +
+

+ English is editable. Translations are confirm-only — + they arrive from UmbraTrans, so they are reviewed and marked confirmed, never typed over. +

+
+ + +
+

Chapter content — rendered in {{ language_name }}

+ + {% if is_source %} +
+ Source language selected. Content is the editable original — no translation layer involved. +
+ {% else %} +
+ Selected language is {{ language_name }}. + Chapter title, utterance text and review statements render in it. Navigation, buttons and + status labels stay English. +
+ {% endif %} + + {% if not rows %} +

No translation loaded for this language.

+ {% else %} + + + + + + + {% if not is_source %}{% endif %} + + + + {% for r in rows %} + + + + + {% if not is_source %} + + {% endif %} + + {% endfor %} + +
#Speaker{{ language_name }} — what will be voicedEnglish source (reference only)
{{ r.index }}{{ r.speaker }} + {% if r.type == 'narration' %} + narration + {% else %} + dialogue + {% endif %} +
{{ r.target_text }}
+
+ {{ r.source_text }} +
+ {% endif %} +
+ + +
+

What the real build changes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaChange
Database + New utterance_translations table + (utterance_id, language_code, text, + status, origin). English stays editable in + utterances.text; translations are confirm-only. +
+ Rejected: a language column on utterances — one row + can hold only one language, so either 10× duplication or overwritten source. +
+
Audio on disk + chapter_audio/ch6/utt_0001.wav (English, unchanged) → + chapter_audio_de/ch6/utt_0001.wav. +
+ Required, not optional: the staleness marker records the spoken text, so two + languages sharing one filename would each mark the other stale and overwrite + it in a loop. +
+
Staleness marker + Marker JSON gains "language": "de", checked by + is_current(), so an export can prove what it shipped. +
Selection state + Separate active_language cookie, defaulting to the project source + language. Separate from active_project so switching language cannot + disturb project selection. Fully reversible. +
UmbraTrans handoff + Export (utterance_id, chapter, index, speaker, source_text, context_before, + context_after); import returns the same shape plus + language_code + text. Idempotent on re-delivery. + Context matters — “Yes.” is untranslatable alone. +
Server + /v1/tts takes any string as language today. Validate + against the 10 supported codes and fail fast rather than producing wrong audio. +
+ +
+ Open questions +
    +
  1. Chapter titles are content, so they would translate — but they are also used in filenames.
  2. +
  3. Speaker names are proper nouns; samples transliterate them. Same treatment?
  4. +
  5. Should a missing translation block generation, or generate English and mark it fallback? Proposal: block it.
  6. +
+
+
+ +{% endblock %} \ No newline at end of file