The remote had one commit this branch did not (03f8a85), which adds the
Apache-2.0 LICENSE, the NOTICE and SPDX headers to every .py and .sh
file. The merge was clean - the headers sit at the top of each file and
my changes are further down.
The new files this branch added (languages.py, translations.py, the
cluster package, the new tests and scripts) were written after the
merge base, so the licence commit never saw them. They now carry the
same header, so the convention holds across the whole tree.
Nothing else changed. 883 tests passed / 8 failed - the same 8
pre-existing failures.
The dropdown was in the HTML all along and still could not be seen. Three
separate causes, found by measuring the rendered element rather than
trusting that the markup existed:
1. Clipped off-screen. The h1 is a flex row with no wrap. Measured at an
800px viewport: the language span started at x=620 and ended at x=810,
past the viewport edge, so only its first few pixels were painted. The
project badge alone took 309px. Adding flex-shrink and truncating the
badge did not fix it - there is genuinely no room in that row. The
selector now gets its own bar directly under the header, and the
project name ellipsises. Verified visible: x=181, right=341 in an
800px viewport, 34px tall.
2. Empty on every other page. base.html rendered the select from a
`languages` variable that almost no page supplies, so the login page
and the rest of the app showed a 38px-wide dropdown with zero options -
effectively invisible, and a dead control. Pages now opt in by
overriding a header_bar block. Verified /, /speakers, /chapters-audio
and /login all still render.
3. Browser cache made my own screenshot lie. It showed a stale Japanese
string containing the word "decisiveness" that is in no file on disk.
A cache-busted fetch confirmed the server is clean. Worth remembering:
a screenshot taken through a warm cache is not evidence.
The screenshot also exposed a data bug. All 12 sample utterances carried
type "narration" - including Kainda, Duchess Victoria, Deal Hunter, Nanny
and Trader Romano, who are all speaking. Every row rendered a narration
badge and the file told UmbraTrans that six characters were not speaking.
SHAPE in build_sample_translations.py now marks 6 dialogue / 6 narration,
and the generated JSONs are regenerated to match.
Also removed a misleading issue. It reported "12 rendered clips have no
staleness marker" as a translation problem; the markers are absent because
render_lang_test.py does not write them, so it was sending the reader after
a defect that does not exist. Replaced with the honest one: only 12 of
4,319 translated and none confirmed - sample only, not publishable.
Suite: 721 passed, 8 pre-existing. ruff clean on changed regions.
The Korean render sounded formal because the Korean translation was in
합쇼체 (-입니다/-합니다/-습니다) - the written formal register, like a
news anchor. Every line used it.
Korean politeness is grammatical, not stylistic: it lives in the verb
endings. 합쇼체 -습니다, 해요체 -이에요/-예요, 해체 -아/-어 are different
morphemes, so no TTS instruction can convert them. The fix has to be in
the text. The same applies to Japanese です/ます and to the T/V languages,
where Sie->du changes the words themselves.
This was not a Korean-only problem. All 9 translated languages were in
formal register while the English source is neutral and friendly:
DE 12 markers FR 13 PT 5 ES 4 IT 3 RU 3 JA polite KO 합쇼체
ZH 3 honorific 您 EN 0 (source)
Applied:
KO 합쇼체 -> 해요체. 해체/반말 deliberately NOT used: speech level is
social, and Nanny speaking 반말 to Kainda would be wrong.
DE/FR/ES/IT/PT Sie/vous/usted/Lei/você -> du/tu/tú/tu/tu
RU kept вы; ты is too intimate for narration. Softened instead.
JA polite-but-warm, fewer ください
ZH 您 -> 你
Re-verified against the formal-pronoun probe (not the earlier loose one,
which flagged possessives: su/suo/seu are 'his', and Japanese です is the
copula while います is existential - both register-neutral).
This is Pass 3 from specs/27 (register and voice consistency), which was
specified and then never applied.
Also fixed a drift trap: build_sample_translations.py wrote to
scripts/translations, a directory nothing reads and nothing tracks, while
the tracked record is samples/translations and the renderer reads
<project>/samples/translations. One regeneration silently left both stale.
It now writes the repo copy and, with --project, the renderer's copy in
the same run.
Re-rendered 120 clips, 0 failed. All 120 bitwise-different from the
formal render, confirming the new text was synthesized rather than a
replay. Korean total 117.8s -> 110.1s, consistent with the shorter 해요체
endings. Previous render kept as Lang_test_formalRegister.
Suite: 721 passed, 8 pre-existing.
Operator direction: English is the source and stays editable; translations
are confirm-only.
- samples/translations/: Deal Hunter sample chapter 1 in all 10 Qwen3-TTS
languages, named deal_hunter_sample_ch1_<LANG>.json. Single-pass LLM
drafts, labelled as such in every file - they are the failure mode the
multi-pass design exists to fix, and they double as a bad-case corpus for
the Pass 4 critic.
- scripts/build_sample_translations.py, with a validator that refuses to
write on stray Latin residue, duplicated utterances or a wrong count. It
caught three defects in my own draft before it would ship.
- specs/27-translation-pipeline-multipass.md - six passes, the point being
that Pass 4 must be a separate critic call, not the same call that drafted.
- specs/28-ui-language-selection.md - confirm-only translations, English
editable, selector scoped to the chapter page, language named on the page
because the cookie is per browser.
- scripts/render_lang_test.py - renders the translations with each speaker's
own voice into <project>/Lang_test/, plus a stitched file per language.
Dry run resolves all 12 utterances x 10 languages; 11 of 12 use a cached
ICL prompt, Trader Romano falls back to ref_audio.
The renderer resolves display names through the alias map rather than by
munging strings: the translations say 'Kainda' and the speaker record says
'Kainda Walters Foster Murphy'. String-munging silently produced no voice for
Kainda in the first draft - the same failure mode as the speaker-review
audit.
NOT DONE: no audio rendered. Qwen3-TTS generation on this machine is stalled
- /v1/tts accepts the connection and never completes (zero POSTs logged across
17 minutes after a clean restart), while /v1/voice-prompt and /load return 422
immediately, so requests do reach the handler and the block is inside
generation. Swap is at 16GB of 17.4GB. Left as a known blocker rather than
worked around.
Suite: 721 passed, 8 pre-existing.