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.
|
||||
@@ -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)
|
||||
|
||||
162
umbraaudio/web/language_routes.py
Normal file
162
umbraaudio/web/language_routes.py
Normal file
@@ -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,
|
||||
},
|
||||
)
|
||||
223
umbraaudio/web/templates/language_mockup.html
Normal file
223
umbraaudio/web/templates/language_mockup.html
Normal file
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div style="border:2px dashed #7c3aed; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.5rem; background:#1e1b4b;">
|
||||
<strong style="color:#c4b5fd">MOCKUP — spec 30, not built.</strong>
|
||||
<span style="color:#94a3b8; font-size:0.85rem">
|
||||
Read-only preview of the proposed language UI. Nothing here writes to the database,
|
||||
generates audio, or changes the English pipeline.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ============================ language selector ============================ -->
|
||||
<div class="card">
|
||||
<h2>Language selection</h2>
|
||||
<p style="color:#94a3b8; font-size:0.9rem; margin-bottom:1rem">
|
||||
Sits in the page header beside <em>Active project</em> in the real build, because it
|
||||
changes what every page shows. Controls below stay in English always — only the
|
||||
content column changes.
|
||||
</p>
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center; margin-bottom:1rem;">
|
||||
<span style="color:#a78bfa; font-weight:600; margin-right:0.5rem;">Content language:</span>
|
||||
{% for c, name in languages %}
|
||||
<a href="/language-mockup?lang={{ c }}"
|
||||
class="btn"
|
||||
style="{% if c == code %}background:#7c3aed; color:white; font-weight:700;{% else %}background:#0f3460; color:#c4b5fd;{% endif %}">
|
||||
{{ name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="padding:0.6rem 0.8rem; background:#16213e; border-radius:6px; border-left:3px solid #475569;">
|
||||
<span style="color:#94a3b8; font-size:0.85rem">
|
||||
Not offered yet (Qwen3-TTS cannot voice them — spec 26 §gap):
|
||||
{% for c, name in unsupported %}<span class="badge" style="background:#334155; color:#94a3b8; margin-right:0.3rem;">{{ name }}</span>{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================== coverage ================================= -->
|
||||
<div class="card">
|
||||
<h2>Translation coverage</h2>
|
||||
{% if coverage_error %}
|
||||
<div style="padding:0.6rem 0.8rem; background:#7f1d1d; border-radius:6px; margin-bottom:1rem; border-left:3px solid #ef4444;">
|
||||
<strong>Coverage could not be measured.</strong>
|
||||
<span style="font-size:0.85rem;">The project database was not readable, so these figures
|
||||
are absent rather than reported as zero.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#94a3b8; font-size:0.9rem; margin-bottom:1rem">
|
||||
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.
|
||||
</p>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Language</th><th>Delivered</th><th>Coverage</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in coverage if not coverage_error %}
|
||||
<tr {% if c.selected %}style="background:#1e1b4b;"{% endif %}>
|
||||
<td>
|
||||
{% if c.selected %}<strong style="color:#c4b5fd;">{{ c.name }}</strong>
|
||||
{% else %}{{ c.name }}{% endif %}
|
||||
</td>
|
||||
<td>{{ c.have }} / {{ c.total }}</td>
|
||||
<td style="min-width:180px;">
|
||||
<div class="progress-bar" style="width:160px;">
|
||||
<div class="progress-fill" style="width: {{ '%.4f' % c.pct }}%;"></div>
|
||||
</div>
|
||||
<span style="font-size:0.75rem; color:#94a3b8;">{{ '%.3f' % c.pct }}%</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if c.code == 'en' %}
|
||||
<span class="badge badge-green">source · editable</span>
|
||||
{% elif c.pct == 0 %}
|
||||
<span class="badge" style="background:#334155; color:#94a3b8;">not started</span>
|
||||
{% elif c.pct >= 99.9 %}
|
||||
<span class="badge badge-yellow">complete · unconfirmed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">partial</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="color:#94a3b8; font-size:0.8rem; margin-top:0.75rem">
|
||||
English is <strong>editable</strong>. Translations are <strong>confirm-only</strong> —
|
||||
they arrive from UmbraTrans, so they are reviewed and marked confirmed, never typed over.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- =========================== chapter content ============================= -->
|
||||
<div class="card">
|
||||
<h2>Chapter content — rendered in {{ language_name }}</h2>
|
||||
|
||||
{% if is_source %}
|
||||
<div style="padding:0.6rem 0.8rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6;">
|
||||
Source language selected. Content is the editable original — no translation layer involved.
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding:0.6rem 0.8rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6;">
|
||||
Selected language is <strong style="color:#c4b5fd;">{{ language_name }}</strong>.
|
||||
Chapter title, utterance text and review statements render in it. Navigation, buttons and
|
||||
status labels stay English.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not rows %}
|
||||
<p style="color:#94a3b8;">No translation loaded for this language.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:3rem;">#</th>
|
||||
<th style="width:11rem;">Speaker</th>
|
||||
<th>{{ language_name }} — what will be voiced</th>
|
||||
{% if not is_source %}<th style="width:26rem;">English source (reference only)</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td style="color:#64748b;">{{ r.index }}</td>
|
||||
<td>{{ r.speaker }}</td>
|
||||
<td>
|
||||
{% if r.type == 'narration' %}
|
||||
<span class="badge badge-blue" style="margin-bottom:0.3rem;">narration</span>
|
||||
{% else %}
|
||||
<span class="badge badge-green" style="margin-bottom:0.3rem;">dialogue</span>
|
||||
{% endif %}
|
||||
<div style="margin-top:0.35rem;">{{ r.target_text }}</div>
|
||||
</td>
|
||||
{% if not is_source %}
|
||||
<td style="color:#64748b; font-size:0.85rem; border-left:2px solid #1e293b;">
|
||||
{{ r.source_text }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ====================== what is proposed, in short ====================== -->
|
||||
<div class="card">
|
||||
<h2>What the real build changes</h2>
|
||||
<table>
|
||||
<thead><tr><th style="width:15rem;">Area</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Database</strong></td>
|
||||
<td>
|
||||
New <code>utterance_translations</code> table
|
||||
(<code>utterance_id</code>, <code>language_code</code>, <code>text</code>,
|
||||
<code>status</code>, <code>origin</code>). English stays editable in
|
||||
<code>utterances.text</code>; translations are confirm-only.
|
||||
<div style="color:#94a3b8; font-size:0.8rem; margin-top:0.3rem;">
|
||||
Rejected: a <code>language</code> column on <code>utterances</code> — one row
|
||||
can hold only one language, so either 10× duplication or overwritten source.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Audio on disk</strong></td>
|
||||
<td>
|
||||
<code>chapter_audio/ch6/utt_0001.wav</code> (English, unchanged) →
|
||||
<code>chapter_audio_de/ch6/utt_0001.wav</code>.
|
||||
<div style="color:#fca5a5; font-size:0.8rem; margin-top:0.3rem;">
|
||||
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.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Staleness marker</strong></td>
|
||||
<td>
|
||||
Marker JSON gains <code>"language": "de"</code>, checked by
|
||||
<code>is_current()</code>, so an export can prove what it shipped.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Selection state</strong></td>
|
||||
<td>
|
||||
Separate <code>active_language</code> cookie, defaulting to the project source
|
||||
language. Separate from <code>active_project</code> so switching language cannot
|
||||
disturb project selection. Fully reversible.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>UmbraTrans handoff</strong></td>
|
||||
<td>
|
||||
Export <code>(utterance_id, chapter, index, speaker, source_text, context_before,
|
||||
context_after)</code>; import returns the same shape plus
|
||||
<code>language_code</code> + <code>text</code>. Idempotent on re-delivery.
|
||||
Context matters — “Yes.” is untranslatable alone.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Server</strong></td>
|
||||
<td>
|
||||
<code>/v1/tts</code> takes any string as <code>language</code> today. Validate
|
||||
against the 10 supported codes and fail fast rather than producing wrong audio.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top:1rem; padding:0.75rem 1rem; background:#16213e; border-radius:6px; border-left:3px solid #f59e0b;">
|
||||
<strong style="color:#f59e0b">Open questions</strong>
|
||||
<ol style="color:#94a3b8; font-size:0.85rem; margin-top:0.4rem; padding-left:1.2rem;">
|
||||
<li>Chapter titles are content, so they would translate — but they are also used in filenames.</li>
|
||||
<li>Speaker names are proper nouns; samples transliterate them. Same treatment?</li>
|
||||
<li>Should a missing translation <em>block</em> generation, or generate English and mark it fallback? Proposal: block it.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user