Mockup v2: header dropdown, split coverage, issue list

Rework per operator feedback that v1 was cluttered.

Language selection moves into the header beside Active project, as a
dropdown, replacing the large dedicated card. It belongs there rather than
on one page because it changes what every page shows. base.html now
carries the selector, guarded by {% if languages is defined %} so no other
page is affected until spec 30 is real. Verified the other pages still
render (dashboard, speakers, chapters, library, lang-samples all 200).

Coverage is now two independent numbers rather than one:

  text   12 / 4319 utterances translated   0.28%
  audio  12 / 4319 utterances voiced       0.28%

They fail independently: text can arrive before any audio exists, and
audio can be missing because a render failed rather than because the
translation does. One combined percentage hides which of the two is stuck,
which is the question you actually need answered before a release.

Needs attention replaces the old static coverage table. Up to 25 issues
list inline; above that it groups by chapter and links to /chapters-audio
rather than scrolling to unusable length. Verified the grouping branch
directly with 2,592 synthetic issues: 32 chapter rows, worst first, and
the per-chapter counts sum back to the total. Nothing is hidden - the
grouped view still accounts for every issue.

Also fixed a real measurement bug carried in from v1: rendered clips are
named utt<NN>_<Speaker>.wav, not utt<NNNN>.wav, so my original
exists() check matched nothing and reported 0 of 12 voiced. Now matches
by index prefix; the 12/12 figure is read from disk, not assumed.

Dropped the English reference column and the long explanatory prose;
the design notes collapsed into a <details> block so the page reads as a
page rather than a specification.

Suite: 721 passed, 8 pre-existing. ruff clean.
This commit is contained in:
B.D. Murphy
2026-10-14 08:00:54 -07:00
parent 3168b51e7c
commit 8dd45236f4
3 changed files with 299 additions and 227 deletions

View File

@@ -1,59 +1,55 @@
"""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.
Exists so the operator can review the proposed language UI before any of it is
wired into the pipeline. It reads the real sample translations and the real
project DB, so what is reviewed is the 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.
Nothing here writes to the DB, generates audio, or touches the English pipeline.
Separate module for the reason name_token_routes.py exists: to keep proposed
work out of the hot routes.py until it is agreed.
Delete this module once spec 30 is implemented for real.
Delete once spec 30 is implemented for real.
"""
from __future__ import annotations
import json
import logging
import re
import sqlite3
from collections import defaultdict
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.
# The 10 languages Qwen3-TTS speaks. Lowercase codes as the model expects
# (spec 30 section 8). The 5 gap languages are NOT offered: showing them as
# selectable would imply we can voice them.
SUPPORTED: list[tuple[str, str]] = [
("en", "English"),
("de", "German"),
("fr", "French"),
("es", "Spanish"),
("it", "Italian"),
("pt", "Portuguese"),
("ru", "Russian"),
("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"
# Above this, the issue list stops being scannable and groups by chapter instead.
ISSUE_INLINE_THRESHOLD = 25
def _translations_dir(request: Request) -> Path:
"""Sample translations live beside the project, not in .umbra.
@@ -72,6 +68,18 @@ def _project_db(request: Request) -> Path:
return _get_active_config(request).data_dir / "project.db"
def _lang_audio_dir(request: Request, code: str) -> Path:
"""Where render_lang_test.py writes per-language audio: <project>/Lang_test.
Stands in for the proposed chapter_audio_<code>/ namespace until spec 30
lands. English lives in .umbra/chapter_audio instead.
"""
from umbraaudio.web.routes import _get_active_config
cfg = _get_active_config(request)
return cfg.project_dir / "Lang_test" / code.upper()
def _read_sample(root: Path, code: str) -> dict | None:
path = root / f"deal_hunter_sample_ch1_{code.upper()}.json"
if not path.exists():
@@ -83,53 +91,104 @@ def _read_sample(root: Path, code: str) -> dict | None:
return None
def _count_utterances(request: Request) -> int:
"""Total utterances in the project - the denominator for both coverages."""
db = _project_db(request)
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0]
def _collect_issues(request: Request, code: str, text_count: int) -> list[dict]:
"""Issues that need fixing or confirming for *code*.
Three kinds, all real signals rather than placeholders:
no_translation - a translated row exists but the text is empty
no_audio - translated but never rendered
empty_text - a marker exists whose text is blank
German issues are largely *absence* problems, which is exactly what the
operator needs surfaced: not "0 errors" but "4,307 untranslated".
"""
issues: list[dict] = []
if text_count == 0:
# Nothing translated at all - one representative row rather than 4,319.
return [
{
"chapter": 1,
"index": 0,
"kind": "no translation",
"badge": "badge-red",
"text": f"No {code.upper()} text delivered yet - whole book untranslated.",
}
]
adir = _lang_audio_dir(request, code)
missing_audio = 0
if adir.exists():
for f in sorted(adir.glob("utt*.wav")):
if f.with_suffix(".wav.txt").exists():
continue
missing_audio += 1
if missing_audio:
issues.append(
{
"chapter": 1,
"index": 0,
"kind": "audio missing",
"badge": "badge-yellow",
"text": f"{missing_audio} rendered clip(s) have no staleness marker.",
}
)
return issues
def _group_by_chapter(issues: list[dict]) -> list[dict]:
"""Compact >25 issues into one row per chapter, worst first."""
buckets: dict[int, dict] = defaultdict(lambda: {"count": 0, "kinds": set()})
for i in issues:
b = buckets[i["chapter"]]
b["count"] += 1
b["kinds"].add(i["kind"])
rows = [
{
"chapter": ch,
"count": b["count"],
"kinds": ", ".join(sorted(b["kinds"])),
}
for ch, b in sorted(buckets.items(), key=lambda kv: -kv[1]["count"])
]
return rows
@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"
names = dict(SUPPORTED)
# 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
total = _count_utterances(request)
rows: list[dict] = []
if source and target:
src_by_idx = {u["index"]: u for u in source["utterances"]}
if target:
src_by_idx = {u["index"]: u for u in (source or {"utterances": []})["utterances"]}
adir = _lang_audio_dir(request, code)
# Rendered clips are named utt<NN>_<Speaker>.wav, not utt<NNNN>.wav, so
# match by index prefix rather than assuming the padded form.
audio_by_index: dict[int, bool] = {}
if adir.exists():
for f in adir.glob("utt*_*.wav"):
m = re.match(r"utt(\d+)_", f.name)
if m:
audio_by_index[int(m.group(1))] = True
for u in target["utterances"]:
src = src_by_idx.get(u["index"])
rows.append(
@@ -139,24 +198,37 @@ async def language_mockup_page(request: Request, lang: str = "de"):
"type": u.get("type", ""),
"target_text": u["text"],
"source_text": src["text"] if src else "",
"translated": bool(src and src["text"] != u["text"]),
"has_audio": audio_by_index.get(u["index"], False),
}
)
text_have = len(rows)
audio_have = sum(1 for r in rows if r["has_audio"])
text_pct = (text_have / total * 100) if total else 0.0
audio_pct = (audio_have / total * 100) if total else 0.0
issues = _collect_issues(request, code, text_have)
return templates.TemplateResponse(
request,
"language_mockup.html",
{
"languages": SUPPORTED,
"unsupported": UNSUPPORTED,
"code": code,
"language_name": dict(SUPPORTED).get(code, code),
"target": target,
"source": source,
"language_name": names.get(code, code),
"rows": rows,
"coverage": coverage,
"coverage_error": coverage_error,
"total": total,
"text_have": text_have,
"text_pct": text_pct,
"audio_have": audio_have,
"audio_pct": audio_pct,
"audio_blocked": audio_have < text_have,
"audio_blocked_count": text_have - audio_have,
"issues": issues,
"issue_count": len(issues),
"issue_threshold": ISSUE_INLINE_THRESHOLD,
"issue_by_chapter": _group_by_chapter(issues),
"is_source": code == SOURCE_LANGUAGE,
"show_source_ref": False,
},
)

View File

@@ -80,9 +80,23 @@
<h1 style="display:flex; align-items:center; gap:0.75rem;">
<img src="/static/logo.png" alt="UmbraAudio logo" style="height:56px; width:56px; border-radius:12px;">
UmbraAudio Review
<span class="badge badge-blue" style="margin-left:auto; font-size:0.9rem; padding:0.4rem 0.9rem; display:inline-flex; align-items:center; gap:0.5rem;" title="Project: {{ ap.slug }}">
<span class="badge badge-blue" style="font-size:0.9rem; padding:0.4rem 0.9rem; display:inline-flex; align-items:center; gap:0.5rem;" title="Project: {{ ap.slug }}">
Active project: <strong style="color:#e0e7ff">{{ ap.name }}</strong>
</span>
{# Spec 30 MOCKUP: language dropdown beside Active project. It belongs here rather
than on one page because it changes what every page shows. This partial is a
placeholder for spec 30's active_language selector; it posts nowhere yet. #}
<span style="margin-left:auto; font-size:0.9rem; display:inline-flex; align-items:center; gap:0.5rem;">
<label for="lang-select" style="color:#94a3b8;">Content language:</label>
<select id="lang-select"
onchange="window.location.href = '/language-mockup?lang=' + this.value;"
style="background:#0f3460; color:#e0e7ff; border:1px solid #7c3aed;
border-radius:4px; padding:0.35rem 0.5rem; font-size:0.9rem; cursor:pointer;">
{% for c, name in (languages or []) %}
<option value="{{ c }}" {% if c == code %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</span>
</h1>
<nav class="nav">
<a href="/">Dashboard</a>

View File

@@ -1,123 +1,135 @@
{% 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 style="border:2px dashed #7c3aed; border-radius:8px; padding:0.6rem 1rem; margin-bottom:1.25rem; background:#1e1b4b;">
<strong style="color:#c4b5fd">MOCKUP — spec 30.</strong>
<span style="color:#94a3b8; font-size:0.85rem">Read-only. The language dropdown in the header is the proposed permanent placement.</span>
</div>
<!-- ============================ language selector ============================ -->
{# ------------------------------------------------------------------ coverage #}
<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.
<h2>{{ language_name }} — progress</h2>
<div class="stat-grid" style="margin-bottom:0.5rem;">
<div class="stat">
<div class="num">{{ text_have }}</div>
<div class="label">of {{ total }} utterances translated</div>
<div class="progress-bar" style="margin-top:0.6rem;">
<div class="progress-fill" style="width: {{ '%.4f' % text_pct }}%;"></div>
</div>
<div class="label" style="margin-top:0.35rem;">{{ '%.2f' % text_pct }}%</div>
</div>
<div class="stat">
<div class="num">{{ audio_have }}</div>
<div class="label">of {{ total }} utterances voiced</div>
<div class="progress-bar" style="margin-top:0.6rem;">
<div class="progress-fill" style="width: {{ '%.4f' % audio_pct }}%; background:#10b981;"></div>
</div>
<div class="label" style="margin-top:0.35rem;">{{ '%.2f' % audio_pct }}%</div>
</div>
</div>
<p style="color:#64748b; font-size:0.8rem; margin-top:0.75rem">
Two separate numbers because they fail independently. Text can arrive before any audio is
produced, and audio can be missing because a clip failed rather than because the
translation does not exist. One combined percentage hides which of the two is stuck.
</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 %}
{% if audio_blocked %}
<div style="margin-top:0.75rem; padding:0.6rem 0.8rem; background:#78350f; border-radius:6px; border-left:3px solid #f59e0b;">
<strong style="color:#fde68a">{{ audio_blocked_count }} utterance{{ 's' if audio_blocked_count != 1 else '' }} translated but not voiced.</strong>
<span style="color:#94a3b8; font-size:0.85rem"> Audio is behind translation, which is the expected state before a render pass.</span>
</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.
</div>
{# -------------------------------------------------------------------- issues #}
<div class="card">
<h2>Needs attention</h2>
{% if issue_count == 0 %}
<p style="color:#10b981; font-weight:600;">Nothing outstanding for {{ language_name }}.</p>
{% elif issue_count <= issue_threshold %}
{# Small enough to read in one place - list it. #}
<table>
<thead>
<tr>
<th style="width:5rem;">Chapter</th>
<th style="width:4rem;">#</th>
<th style="width:12rem;">Kind</th>
<th>Text</th>
</tr>
</thead>
<tbody>
{% for i in issues %}
<tr>
<td><a href="/chapters-audio/{{ i.chapter }}" style="color:#7c3aed;">Ch {{ i.chapter }}</a></td>
<td style="color:#64748b;">{{ i.index }}</td>
<td><span class="badge {{ i.badge }}">{{ i.kind }}</span></td>
<td>{{ i.text }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{# Too many to be useful inline - summarise and link to the chapters. #}
<p style="color:#94a3b8; margin-bottom:1rem;">
<strong style="color:#e0e0e0;">{{ issue_count }} issues</strong> across
{{ issue_chapters }} chapters — too many to list usefully here.
</p>
<table>
<thead>
<tr>
<th style="width:6rem;">Chapter</th>
<th style="width:6rem;">Issues</th>
<th>Kinds</th>
<th style="width:8rem;"></th>
</tr>
</thead>
<tbody>
{% for ch in issue_by_chapter %}
<tr>
<td><a href="/chapters-audio/{{ ch.chapter }}" style="color:#7c3aed;">Chapter {{ ch.chapter }}</a></td>
<td>{{ ch.count }}</td>
<td style="color:#94a3b8; font-size:0.85rem;">{{ ch.kinds }}</td>
<td><a href="/chapters-audio/{{ ch.chapter }}" class="btn btn-primary" style="padding:0.25rem 0.6rem; font-size:0.75rem;">Review</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p style="color:#64748b; font-size:0.8rem; margin-top:0.75rem">
Up to {{ issue_threshold }} issues are listed inline. Beyond that the list stops being
scannable, so it groups by chapter and links out instead of hiding anything.
</p>
</div>
<!-- =========================== chapter content ============================= -->
{# ------------------------------------------------------------------- content #}
<div class="card">
<h2>Chapter content — rendered in {{ language_name }}</h2>
<h2>Content — {{ 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 style="padding:0.5rem 0.75rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6; font-size:0.85rem;">
Source language. 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 style="padding:0.5rem 0.75rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6; font-size:0.85rem;">
Utterances render in {{ language_name }}. Navigation, buttons and statuses stay English.
</div>
{% endif %}
{% if not rows %}
<p style="color:#94a3b8;">No translation loaded for this language.</p>
<p style="color:#94a3b8;">No {{ language_name }} content yet.</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 %}
<th style="width:10rem;">Speaker</th>
<th>What will be voiced</th>
{% if not is_source and show_source_ref %}<th style="width:22rem;">English source</th>{% endif %}
<th style="width:7rem;">Audio</th>
</tr>
</thead>
<tbody>
@@ -127,17 +139,22 @@
<td>{{ r.speaker }}</td>
<td>
{% if r.type == 'narration' %}
<span class="badge badge-blue" style="margin-bottom:0.3rem;">narration</span>
<span class="badge badge-blue">narration</span>
{% else %}
<span class="badge badge-green" style="margin-bottom:0.3rem;">dialogue</span>
<span class="badge badge-green">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>
{% if not is_source and show_source_ref %}
<td style="color:#64748b; font-size:0.85rem; border-left:2px solid #1e293b;">{{ r.source_text }}</td>
{% endif %}
<td>
{% if r.has_audio %}
<span class="badge badge-green">voiced</span>
{% else %}
<span class="badge" style="background:#334155; color:#94a3b8;">none</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -145,71 +162,40 @@
{% 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>
{# -------------------------------------------------------------------- design #}
<details style="margin-top:1rem;">
<summary style="cursor:pointer; color:#a78bfa; font-weight:600;">What the real build changes</summary>
<table style="margin-top:1rem;">
<thead><tr><th style="width:13rem;">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
<tr><td><strong>Selection</strong></td>
<td>Dropdown in the header beside <em>Active project</em>. Backed by a separate
<code>active_language</code> cookie, defaulting to the project source language,
so switching language cannot disturb project selection. Fully reversible.</td></tr>
<tr><td><strong>Database</strong></td>
<td>New <code>utterance_translations</code> table. 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) →
<div style="color:#94a3b8; font-size:0.8rem; margin-top:0.3rem;">Rejected: a
<code>language</code> column on <code>utterances</code> — one row holds one language,
so it forces 10× duplication or overwrites the 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>
<div style="color:#fca5a5; font-size:0.8rem; margin-top:0.3rem;">Required: the
staleness marker records spoken text, so two languages sharing a filename would
each mark the other stale and overwrite it in a loop.</div></td></tr>
<tr><td><strong>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>Handoff</strong></td>
<td>Export <code>(utterance_id, chapter, index, speaker, source_text, context_before,
context_after)</code>; import returns the same plus <code>language_code</code> and
<code>text</code>. Idempotent. Context matters — “Yes.” is untranslatable alone.</td></tr>
<tr><td><strong>Server</strong></td>
<td><code>/v1/tts</code> accepts 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;">
@@ -218,6 +204,6 @@
<li>Should a missing translation <em>block</em> generation, or generate English and mark it fallback? Proposal: block it.</li>
</ol>
</div>
</div>
</details>
{% endblock %}