# Changelog All notable changes to **TTS Voice Creator — Clone and Design** are documented here. Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned with [Semantic Versioning](https://semver.org/). --- ## [Unreleased] ### Added - **Untitled magazine entries ("Page 33") are explained, merged on request and given readable titles** (`core/magazine_untitled.py`, `scripts/magazine_untitled.py`). Root cause: the review screen creates a `Page N` stand-in for any page no article covers, and *Approve* without a toggled box saved only the visible page's regions, which cut multi-page features down to one page. On *c't KI Wissen 2026* this left 59 stand-ins, most of them continuation pages. A continuation detector scores each untitled entry against the article before it (lower-case/mid-sentence start, hyphenated break, the narration fusion rule, open ending, adjacent page, column width; a signed-off previous article or the page's own headline counts against). An optional local-LLM check runs only for ambiguous scores. Each proposal comes with a confidence and the evidence. Records whose title repeats the headline of the article they continue are proposed as well. Titles: a deterministic fallback (*Anzeige*, *Bildseite*, *Inhalt*, *Impressum*, *Editorial*, *Textfragment*, the page headline, or the first sentence cut to 8 words), plus optional validated and cached LLM titles in the magazine's language (`magazine_llm_titles`, `--no-llm`). Nothing changes silently. `GET /articles` gains derived `display_title`, `title_source`, `suggested_title`, `page_label`, `untitled_kind`, `merge_suggestion` and `audio_stale`, and `GET /untitled` plus `quality/report → untitled_articles` list everything. Changes happen only through the undoable `POST /articles/{id}/merge-into-previous` and `POST /articles/{id}/accept-title` (never over a real title), `POST /titles/suggest` (suggestion cache only), and the CLI's `--apply` (backup first). A merge keeps reading order, marks the article `audio_stale` (a fresh render clears it) and deletes no audio. See architecture/magazine-ingestion.md, "Untitled entries". - **Distinct, deterministic face profile per character** (`core/face_profile.py`). The cast of one book looked like one family because most sheets name no facial feature and the image model then draws its default face. Each character now gets a compact `face:` clause (face shape, jaw, nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a mark) in the Subject of every character prompt: traits the sheet states (German or English) are kept, the rest comes from species/age/gender/culture-filtered option pools, chosen so that the characters of a book are spread apart (greedy max-distance in creation order; adding characters never changes an existing profile; relatives named in `relationships` share a few traits). Owner override: a `visual_face` text on the sheet. On the real books 02/03 the mean pairwise face-descriptor distance goes from 0.09/0.04 to 0.91 (44/68 characters had no face trait at all). See `architecture/character-visual-identity.md`. - **Dry run for faces:** `POST /api/character-face-preview {book}` lists every character's face profile (what came from the sheet, what was chosen) with a similarity matrix and before/after score; `POST /api/character-prompt-preview` also returns `face_clause`. Nothing is generated or saved. - **Character info is translated to English before image prompts are built** (`core/prompt_translation.py`, `_english_prompt_inputs`). Free-text sheet fields (species, appearance, clothing, age, props, story-moment quote) and the book setting are translated in one batched, low-temperature call through the app's existing LLM, validated per field (non-empty, no error echo, length, names preserved, not still German), and cached in the new `prompt_translation_cache` table keyed by source hash, language, model and prompt version. Records and the displayed German text never change; species/age/dwarf/period checks now run on English. If the LLM is down or output is rejected the original text is used, a warning is logged, `prompt_translation.used_untranslated_text` is reported and generation continues. `PROMPT_TRANSLATION=off` disables it. New read-only dry run `POST /api/character-prompt-preview`. See architecture/character-visual-identity.md. - **Record/replay cache for vision OCR responses** (`core/vision_cache.py`, `MAGAZINE_VISION_CACHE=live|record|replay`). Freezes the raw model response per (model, prompt, image, parameters) in a git-ignored cache directory so OCR/layout/post-processing changes can be compared on identical vision output; replay fails loudly on a miss. `bench_ocr.py --vision-cache` and `compare_magazines.py` (`--baseline-vision-run`, `--new-vision-run`) report mode and models and say whether a comparison is frozen. See architecture/magazine-ingestion.md. - **Driving-mode menu redesigned.** Sections *View*, *Show*, *Playback* and *Theme*. *View* is a radio group with icon, name and indicator: **Picture** (whole picture, no text), **Text** (whole text, lyrics-style scrolling, no picture) and **Split** (picture plus three lines; the former Compact). The on/off options (*Show text*, *Show pictures*, *Minimize text and picture*) are real switches (label left, switch right, thumb position and a tick/cross inside the thumb as well as colour; `role="switch"`). The theme is one segmented control **Light | Auto | Dark** with icons and a tick on the selected segment. Controls are 44 px high, the flyout fits 320 px, text keeps 4.5:1 and switch, segment and radio boundaries and the focus ring 3:1 in both themes; the thumb does not slide with reduced motion. Keyboard: arrows (Up/Down/Left/Right) move over every control, Home/End jump, Space/Enter activate, Escape closes and returns focus to the menu button. Views stored by older builds migrate (image to picture, lyrics to text, compact to split). - **Driving mode plays on automatically.** When an article ends it is marked finished (saved like the playlist does) and the next article that has audio starts (rows without audio are skipped, speed carries over, title, picture, text, index highlight and lock-screen metadata follow, "Now playing: ..." is announced once). At the last article it stops with an *End of magazine* state, no wrap-around. New switch *Play next article automatically* (default on, remembered in the driving preferences); off stops after the article as before. A next article whose audio cannot be loaded is skipped with a brief message. Manual Next, seeks and duplicate `ended` events never advance twice. - **Driving mode: choose how much text is shown.** New menu section *Text in Split view*: **Sentence** (default; only the current sentence, large), **Context** (previous, current, next: the former three lines) or **Paragraph** (the whole paragraph, current sentence bold and underlined, spoken word marked). Stored in the driving preferences (invalid values fall back to Sentence); the Text view still shows the whole text. Tests: `tests/magazine.spec.js` ("Driving mode text fit and text amount"). ### Changed - Prompt wording that pulled every face and head toward one look was removed: "consistent facial construction" (concept style) became "individually distinct faces"; the hair locks no longer force "reaching at least to the ears or collar" / "past the shoulders" when the face clause states the hair; the dwarf body lock no longer says "a thick, full beard" (the beard now comes from the face clause, men only, so dwarf women get none); the Subject limit in the compact render prompts is 900 instead of 700 characters to make room for the clause. - `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before. ### Fixed - **Approving a review page keeps all blocks of a multi-page article.** *Approve* without touching a box saved only the regions shown on the current page, so a multi-page article shrank to that page, and *Approve & Next* then created a `Page N` stand-in on the next page. `selectedBlockIds()` now starts from the article's saved blocks (plus visible ticks) unless ticks were changed; explicit tick selections behave as before. Test: `tests/magazine.spec.js` ("approving a multi-page article page by page"). - **Driving mode text ran out of its frame.** A very long sentence (300+ characters) in the Split view was cut off at the bottom. The text frame now takes the space between picture and controls (flex, `min-height: 0`), the current text fits automatically (1.5 rem down to 1 rem, in rem so text scaling and a 200% root font count), the picture shrinks first, and text that still does not fit scrolls inside the frame and follows the spoken word instead of being clipped. Verified at 320x568, 375x667, 414x896 and 667x375 (also with a 200% root font) in all three amounts. - **"Colors: Dark (night)" looked disabled in the light theme.** The menu item carried `data-theme="dark"`, which is also the hook of the app-wide dark palette (`[data-theme="dark"] {...}`), so the button itself got the dark palette's light text colour on the light menu. The theme buttons now use `data-theme-choice`, so no app theme selector can match them. - **A boy rendered as an adult:** `_character_age_category` read `age`, but sheets store `age_estimate`. - **Characters' data no longer bleeds into each other through shared labels or names.** A real-library audit (270 records) traced Arthag's (book 02) copy of Kolon's species and relationships and 16 whole-sheet copies (Zwerg/Kolon = Arthag, Mann = Fremder, Alchimist = Promos ...) to identity matching on any shared token ("Zwerg", "der Zwerg", "Mann") with "longer text wins" merging, and to `csRosterCanonicalName` renaming a sheet to the first cast name found among its aliases instead of its own name. Now only a real name links two profiles (`static/js/character-identity.js`, `core/character_records.py`), the own name always wins (`csFindMergeKey`, `csRosterCanonicalName`, `clUpsert` writes to the exact-name record before any alias scan, no seed rename onto a name that has its own record). Tests: `tests/character-identity.test.mjs`. - **Other cast members' names are no longer stored as a character's alias or real name** (Kolon carried "Sharraz Garthai"/"Sharraz"): `_charsheets_parse` drops them in cast-target mode, keeping relations such as "Tochter des Promos". - **Model "thinking" no longer lands in profile fields** ("Warte: ...", "Nein, Kolon ist ...", "Der Text sagt ..."): every text field is cut before such markers on extraction. - **The story-moment picture no longer shows another character's scene:** only a source quote that names the character is used, otherwise its own mannerisms (16 of 270 lower panels named only another cast member). - **Age is read from German and English wording:** "Junge", "Mädchen", "boy", "girl", number words and "Sommer/Winter" ages, the estimate's own "im Text als Junge beschrieben" clause, and age-tagged quotes; Marrad ("fast acht Sommer") is now a child. "15 Jahre älter" is no longer read as a 15-year-old. - **New read-only audit and dry-run cleanup for saved characters:** `scripts/audit_character_records.py`, `scripts/clean_character_records.py` (dry run by default; `--apply` writes a backup first). See architecture/character-extraction.md. Tests: `tests/test_character_record_hygiene.py`. - **German "hat" (has) became a hood:** "hat dünne Arme" turned into "soft wool hood" in the prompt; `_period_wording` now only maps English "hat" and leaves German text alone. - **Dwarf body shape and beard no longer leak onto human characters' images.** The dwarf body lock ("one adult dwarf", short legs, thick full beard) was switched on by the word "Zwerg" anywhere in the species field, including extraction notes about other characters. In the real library this hit Marrad (a thin human boy, "Mensch (oder Zwerg, je nach Kontext …)") and Kolon and Arthag in book 02 ("Mensch (… 'Der Zwerg' wird separat erwähnt … Warte: 'Der Zwerg lächelte grimmig' …)"), whose prompt Subject also carried that sentence about another character. Species now comes only from the field's own leading assertion: long or quoting bracket notes are dropped, alternatives ("Mensch oder Zwerg") never count as a dwarf, and a *visual species* override is honoured for the body lock too. Seven of 270 records change; tests in `tests/test_character_species_isolation.py`. --- ## [1.57.0] — 2026-10-03 ### Added - **Night theme for driving mode.** A *Colors* group at the end of the display menu: Auto (follows the system, live; default), Light, Dark; remembered in this browser (storage errors are tolerated) and independent of the app's own theme. Dark uses true black, text at about 85% white, a muted blue accent, dimmed pictures (brightness .85), a themed top bar, menu, index with rows, lyric lines and current-line highlight, spoken-word marker, slider, dock buttons (still perfect circles), zoom buttons, *Tap to play*, toasts and share box, and sets the browser theme colour while driving mode is open (restored on exit). All text pairs are at least 4.5:1 and borders 3:1, including *no audio* rows; status icons differ by shape, not just colour. The overlay is painted dark from its first frame. Tested with Playwright (persistence, Auto vs emulated colour scheme, computed-style contrast ratios, first-frame background, theme-color restore). - **Driving mode index shows what the playlist shows.** Each row has a 44 px round play / resume / replay button, the author ("by Name", smaller and muted, ellipsized), and the three-state listening indicator (not played / partly played / played) with accessible text, updated live as articles play and finish. Shares one state helper (`magListenState`) with the playlist. - **Article index in driving mode.** A new *Index* button in the top bar opens a sheet between the top bar and the controls dock that lists every article of the magazine with its title and length (m:ss or h:mm:ss; articles without audio are dimmed, show *no audio* and cannot be chosen). The playing article is highlighted and scrolled into view; tap or press Enter to jump to an article while driving mode keeps running. Escape, the close button or a tap outside closes it and returns focus to the button; Tab stays inside the sheet. Lengths come from the existing articles endpoint (`audio_seconds`), so there is no new backend call; a 113-article list is a plain list rendered at once. - **Share an article or a magazine as a link that opens straight in driving mode.** A *Share* button in the magazine toolbar (whole magazine), one in the playlist header and one in driving mode (current article) build `/#s-magazine?doc=&article=&mode=drive`. They use the browser's share sheet where there is one (it needs https, so on a plain-http LAN address they copy the link instead, with a polite aria-live toast, or show a box with the link if even copying is refused). Opening such a link, also on a first visit and also pasted into an already-open tab, opens the magazine and then driving mode for that article with audio, pictures and text; when the browser blocks autoplay a large *Tap to play* button appears. The link carries ids only (no key or password) and does not change who may reach the app; the ids are validated against a strict pattern and never put into markup, and anything malformed is ignored. Tested with Playwright (desktop deep link, blocked autoplay, hostile parameters, share sheet at 375 px, copy fallback). - **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard. - **Word-level timing with a forced-aligner sidecar.** A new, separate `tts-aligner` container (`aligner/`, torchaudio MMS_FA, CPU by default, optional `--profile gpu`; weights cached in the `aligner-models` volume, no published port, non-root, read-only filesystem, 3 GB limit) answers `POST /align` and `POST /align/batch` with `[{word, start, end}]`. The app (image unchanged, no torch) measures each sentence's words in a background job the first time an article is played (or via `POST .../articles/{id}/align-words`, progress via `GET`), writes `words: [[start_ms, end_ms], ...]` per segment into `audio/.timings.json` (now version 2; readers of version 1 keep working) and `/timings` returns `words` per sentence. The player marks the word from those times in Compact, Lyrics (new per-word marking) and the playlist, and falls back to the previous length-based estimate when no word times exist or the sidecar is down. Measured on one article: after-pause word starts are within 29 ms on average (max 170 ms) of the audio's energy onsets. ### Changed - **Driving mode index rows are clearly separated.** Each row now has 13 px padding above and below and a thin divider line (the highlighted current row has none), replacing the bare gap, so multi-line titles with the play button, number, duration and status icon read as individual items. Hit areas stay 44 px and the 113-row list still renders at once. - **Driving mode index rows have more breathing room and lighter play buttons.** Rows now have 9 px vertical gap between them, staying tappable and scrollable. Each row's play/resume/replay button is now a light circle with a blue icon (matching the magazine playlist), styled with a surface background, subtle border and the app's primary blue for the icon, instead of a solid blue circle; the visual circle is 34 px with a 44 px accessible touch target maintained via padding. - **Driving mode: one display menu instead of the three-icon switch; fewer controls in the dock.** The top bar now has *Share*, a *menu* button (shows the current view), *Index* and *Exit*. The menu opens a flyout with *Picture*, *Lyrics* and *Compact* plus *Show text*, *Show pictures* (moved from the dock) and *Minimize text and picture*; it works with arrow keys, Home/End, Enter and Escape, closes on an outside tap, keeps 44 px targets, stays inside the screen from 320 px wide and never covers the dock. The playback speed button moved to the end of the slider row, after the time. The dock is now just the slider row and the transport buttons. Picture zoom stays on the picture. - **Driving mode has a new layout that always keeps the controls on screen.** The overlay is now one full-height column: a top bar (share, a *Picture / Lyrics / Compact* switch, minimize, close), a content area that shrinks and scrolls inside itself, and a controls dock pinned to the bottom (position slider, time, previous / -15 / play / +15 / next, speed, text, images). On an iPhone SE (375x667) the controls used to fall below the screen, the share button covered the article title and the top of the title was cut off; now the dock is always fully visible from 320x568 up to tablets, in landscape the picture and the three lines sit side by side, and long titles wrap to two lines. *Picture* fills the area (double-tap, pinch, wheel, +/- keys or the zoom buttons to zoom, drag to pan, Escape or the reset button to reset); *Lyrics* shows the whole text, the spoken sentence large and centered, tap a sentence to jump there, and scrolling by hand pauses following until *Back to current*; *Compact* (default) is the picture plus previous / current / next line. The minimize button shrinks everything to just the current line. The choice is remembered. Transport buttons stay perfect circles. - **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before. ### Fixed - **Driving mode lyrics view no longer leaves the previous line's word highlighted** when the playback moves to the next sentence. - **Sentence timings now also work for interview renders.** Every recorded audio file carries `"interview": true` (set whenever a second voice is configured, even without speaker labels), and the timings endpoint and EPUB export discarded all such records, so the player and the EPUB fell back to estimated sentence positions for every current article. The record is now matched to the text again, for interview renders by the label-stripped length the renderer recorded, so `/timings` returns exact (or anchored) sentence times and speech onsets for these articles too. Records for text edited since the render are still ignored. - **Character image runs are isolated per character.** The profile + reference-sheet job (`POST /api/character-generate-consistent-images`) could hand back an earlier character's job when the same `Idempotency-Key` was reused for a different character; the key is now bound to a fingerprint of the character's name, book, identity fields, style and prompts, and a mismatch is rejected with 409 instead of returning the other character's images. The fingerprint is not exposed in the status payload. New regression tests (`tests/test_character_image_isolation.py`) generate a dwarf and an elf back to back and concurrently and check that each ComfyUI request carries only its own prompts, props, identity image, seed family and result, and that the request data is not mutated. - **Characters table on phones: the rating stars can be tapped again.** At 320-414 px the pinned checkbox, picture and name columns (328 px) covered the stars of the voice rating; on phones they now scroll with the table, the rating column is wide enough for five 44 px stars, and each star has a *Rate n of 5* label. Browser tests check at 320, 375 and 414 px that no star touches a pinned column, is hit-testable, is at least 44 px and that the page itself does not scroll sideways; the phone case of the profile/rating test is no longer skipped. - **Highlight stays in step through seeking, speed changes and background tabs.** The highlighted sentence is now read from the audio's own position on every animation frame (and immediately on seeking, seeked, rate change, play, pause and when the tab becomes visible) using a binary search over the sentence start times, so there is no cached position to go stale; a jump into a silent gap keeps the sentence just spoken, and the marked word follows the sentence's own start and end, weighted by word length. New browser tests cover seeking back and forward, 1.5x and 2x, speed changes and rapid repeated seeks. - **Audio and text now line up when the voice actually starts.** Measured on real rendered articles (an 8-sentence and a 71-sentence MP3): the recorded sentence boundaries are exact (no drift over 10 minutes, no encoder-delay offset: the MP3 carries a LAME/Info header, so browsers trim the encoder delay), but the voice engine pads every sentence with 0.4 to 0.9 s of silence before the first word, so the highlight lit up about 0.7 s early. New renders now record where speech begins in each sentence (`onsets` in `.timings.json`), older audio is measured from the decoded MP3 on request (in memory, nothing is written), and `/timings` returns it as `speech` per sentence. - **The item pictures on the character concept sheet show only objects, never hands, feet or people.** The lower-right tiles are now asked for as "a high-quality concept art prop sheet featuring isolated character accessories" on a plain, neutral studio background, still life, strictly inanimate objects, with no people, hands, faces or body parts; the item name (German names included) and the book's world and style stay in the prompt. A body-part list was also added to the item tile's negative prompt, which like the rest only acts if the cfg is raised above 1.0. Existing sheets need to be regenerated. - **Driving mode and the playlist highlight the sentence that is actually being spoken.** The player guessed where each sentence sits in the MP3 from its text length, so the highlighted line and the picture drifted from the voice (worst after seeking). The renderer already records the exact time of every sentence (`audio/.timings.json`); a new endpoint `GET /api/magazine/{doc}/articles/{id}/timings` returns them (`{available, sentences: [{text, block, block_id, start, end}]}`, seconds, from the same sentence planner the EPUB uses) and the player uses them, falling back to the old estimate when an MP3 has no usable record (older audio, interview audio, text edited since). The transcript is still fetched with captions, but the MP3 is built without them, so a caption now gets no time slot (the estimate no longer spends audio time on captions either). The highlight is derived only from the audio's current time against the timeline, so the slider, skip buttons, tapping a sentence, lock-screen seek and resume all stay in sync. Covered by a backend test (`ArticleTimingsRouteTests`) and a Playwright test with seeking. - **Magazine player buttons stay round on a phone.** In the full-screen player at 375 px wide (iPhone SE) the five transport buttons were 52+76+110+76+52 px plus gaps, wider than the screen, so they shrank unevenly: the big play button became a tall oval and the 15-second skip buttons were squeezed. The buttons now have a fixed 1:1 size that scales down on narrow screens (play 64-84 px, others 44-56 px) and never shrink or stretch, and the player no longer clips its top or bottom when the content is taller than the screen. - **No gaps on the character concept sheet.** After the divider lines were removed, light cream gutters remained between the three poses and above the lower pictures, and the left pose's cape and the right pose's sword were clipped at them. The composer cut the generated triptych into thirds and pasted each into an inset panel (8 px per side), and the lower pictures had 12-18 px padding. The poses are now pasted as one whole image with no cut and no inset, and the lower pictures sit flush under it. The generation prompt never asked for gaps, so it is unchanged. Sheets already stored have the gaps baked in and need to be regenerated. - **No divider lines on the character concept sheet.** The Konzeptbild composer drew light lines between the three poses and between the top and bottom rows, which cut through capes and swords. They are gone. Sheets already stored have the lines baked into the image file and need to be regenerated to lose them. - **The two lower pictures of the concept sheet now show the character in the book's world.** For Marcian (a knight in plate and a red cloak, *Das Jahr des Greifen*) the story picture showed a modern man in a polo shirt with headphones and a camera in a garden, covered in pseudo-text captions, and the item tile a stock crossbow on a white backdrop. The story prompt was about 5,400 characters with the period rules at the very end, which the fast 4-step model never reaches; it opened with "ears clearly visible" and asked for "studio photography by a production photographer with a real actor", and words such as "unlettered", "overlays" and "Output 768x512" invited writing. The negative prompt that lists text and modern clothing had no effect, because these samplers run at cfg 1.0, where ComfyUI ignores it. The story prompt is now short and starts with the world (medieval high fantasy, The Dark Eye, 900-1300 AD, plus the book profile's setting, without the book title, which was read as lettering), then the same character as the turnaround (identity, appearance, costume, what the portrait shows), the moment from the book, a period background, and a hand-made, unmarked world; the item is the character's own, made in the colours and craftsmanship of their costume, on an oak table in a medieval room. Both are worded positively only; text, captions, headphones, cameras and modern clothing and buildings are added to the scene and item negative prompts for when the cfg is raised. Checked with two renders of Marcian on the local ComfyUI: a knight in plate and red cloak with a crossbow in a medieval village, and his crossbow on a table, with no text and nothing modern. Existing sheets need to be regenerated. - **Review opens at the top.** Opening a page in review no longer keeps the scroll position of the page grid, which could leave the sticky page bar covering the top of the page picture. --- ### Performance - **Big sections render lazily, so the page stays inside its DOM-node budget.** The Magazine section's markup is now fetched and injected the first time you open it (or deep-link into it) instead of at startup; the Performance background-jobs history is fetched and drawn only while Performance is shown (it used to add about 470 nodes at startup); and the language pickers keep a single option in the hidden native ``s were moved out of their wrapping `