# 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/). --- ## [1.20.8] — 2026-08-12 ### Added - **Fish Audio inline emotion-tag reference in Try It Out.** Selecting a Fish backend reveals a panel with all 49 documented tags (24 basic + 25 advanced); click any tag to insert it at the cursor, plus an "Insert example" button that loads a multi-emotion sample. Shown only for Fish, since it is the only engine that reads bracket tags from the text itself — other engines would speak "[happy]" aloud as literal words. ### Fixed - **Fish-Speech showed "Fetched 0 voices" and was unusable from the UI.** It clones from the app's own saved WAV library, but was missing from the set of backends populated with those voices, so the code probed the Fish server for a voice-listing endpoint it does not have and got nothing. Now reports the full library (218 voices). - **Renamed the backend to "Fish Audio S2 Pro (Clone)"** — verified the running checkpoint is `fishaudio/s2-pro` at revision `1de9996b`, matching upstream HuggingFace exactly. - **Corrected Fish's advertised capabilities.** It was listed as `style_aware: true` with "Strong: emotion/tone markers … are honoured per request" — measured to be wrong while cloning. With a reference clip, S2-Pro prepends it as an in-context example and imitates its prosody, which overwhelms inline tags: identical text showed a 10x loudness spread across emotions with no reference, but only 1.2x with one ([screaming] vs [whisper] was inaudible). Every lever was tested — canonical tags, free-form descriptions, repeated and mid-sentence tags, temperature to 1.0, truncated references, `reference_id` vs inline. The backend is now billed on what it does well: deterministic, byte-reproducible cloning. ## [1.20.6] — 2026-08-11 ### Fixed - **Critical: the whole JS bundle stopped initializing partway through**, silently disabling every module loaded after `tts-preview.js` (Rehearser, Reader, Audiobook, character sheets, …). Introduced in 1.20.5 by the new emotion quick-pickers, which guarded with `typeof REH_EMOTIONS === 'undefined'` — but `REH_EMOTIONS` is a `const` declared later in the bundle's single shared scope, and `typeof` on a `const` in its temporal dead zone **throws** rather than returning `"undefined"`. The pickers now read `window.REH_EMOTIONS` and initialize on a deferred macrotask, after the bundle has fully executed. This also invalidated earlier emotion A/B testing, which had been measuring a half-initialized app. - **Emotion instructions are now always written in English**, even for non-English voices (the spoken text and the native-accent clause stay in the book's own language). Confirmed by controlled A/B testing — same line, same voice, only the instruct language varying — that Qwen3-TTS follows English emotion instructions far more reliably: German instructs produced barely-differentiated output, while English instructs yield a clean, correctly-ordered prosodic gradient (whisper 128 Hz → sad 142 → neutral 179 → scared 203 → happy 225 → angry 269 Hz), with sensible duration changes too (sad slowest, scared fastest). - **Fish-Speech generation parameters (`temperature` / `top_p` / `repetition_penalty`) were never forwarded.** Every Fish-Speech line synthesized at the server's fixed defaults, ignoring the app's per-backend stability settings — the only backend not routed through the shared `_apply_tts_extra_params` helper. ## [1.20.35] — 2026-08-12 ### Fixed - **Phantom characters invented from German noun phrases.** The caster emitted fragments of noun phrases as speakers — "in seinem Inneren" → `Inneren`, "die Hand" → `Hand`, "mein Geliebter" → `Geliebten`, "der Wind" → `Wind`. Each became a cast entry with its own voice while the real speaker's line was lost to a phantom; measured against a hand-corrected book this was much of why a fresh cast produced 73 speakers where the human had 62. Such lines are now reset to Unknown — visibly unresolved and still recoverable by the repair passes — rather than silently wrong. ### Validation Whole-book re-casts of all three volumes, compared against their existing casts with `scripts/compare_casts.py`: | Book | Fresh cast | After auto-repair | Existing cast | Phantoms | |------|-----------|-------------------|---------------|----------| | 01 | 286 unknown | 34 | 11 (hand-corrected) | none | | 02 | 126 unknown | 7 | 0 (hand-corrected) | 5, then fixed | | 03 | 265 unknown | **9** | 25 | none | Book 03 finished below the cast it replaced, and its fused multi-turn segments — one voice reading both sides of an exchange — dropped from 60 to 6. Speaker counts stayed level with the manual casts rather than inflating, confirming the phantom filter and the label consolidation. Agreement with the hand-corrected books is 81–82%; the largest remaining error is systematic ping-pong inversion, where a whole exchange alternates onto the wrong speaker, plus epithets ("Amazone" → Lysandra) that share no tokens with the canonical name. ## [1.20.34] — 2026-08-12 ### Fixed - **Repair passes could re-introduce the speaker label variants the cast had just consolidated.** Consolidation ran only when casting finished, but every recast/repair pass assigns speakers of its own — confirmed live on a freshly cast book, which gained "Alrik" alongside "Alrik von Blautann" during auto-repair and would have split one character across two voices again. Consolidation now runs after each recast pass as well, and reports what it merged. ## [1.20.33] — 2026-08-12 ### Added - **Speaker label variants are consolidated after casting.** The caster refers to one character by several labels across a book — a bare name and a full one ("Sharraz" / "Sharraz Garthai"), a title alone ("Baronin" / "Baronin Ira von Seewiesen"), or a stray fragment ("Von" / "Oberst Alrik von Blautann"). Each variant became its own cast entry and therefore got its own voice, so a character audibly changed voice mid-scene — measured against a hand-corrected book, this accounted for more of the disagreements than genuine misattributions did. Variants are now folded together when one label's words are a strict subset of another's after stripping articles and titles, so "Weber" and "Alter Weber" stay separate and unrelated names never merge. ### Fixed - Quotation-mark toggle moved into the A− / A+ toolbar row, where it belongs. ## [1.20.31] — 2026-08-12 ### Changed - **The quotation-mark toggle is now pure CSS.** Guillemets are wrapped in their own `.ab-q` element at render time, so switching them off is a single class on `
` rather than rewriting every line's text and redrawing the whole feed — instant even with a couple of thousand segments, and the stored text is never touched. Also fixes the toggle appearing to do nothing on a freshly cast book: it previously only *added* missing marks and never hid the ones a book already had. The button moved to the bottom-right as a larger labelled pill, clear of the search controls it was overlapping. ## [1.20.29] — 2026-08-12 ### Added - **Quotation marks in the casting script, with a toggle.** German prose never quotes narration — only characters speak in `» «` — so showing the marks makes a mislabeled line obvious at a glance. PDF extraction strips them from roughly 80% of segments, so they are now *rendered* rather than stored: dialogue rows are displayed wrapped in `» «` (lines that kept their own marks are left untouched), while the saved text stays exactly as cast so synthesis and exports are unaffected. The quote button in the casting view toggles them on or off, and the choice is remembered. ## [1.20.27] — 2026-08-12 ### Fixed - **Two different characters' lines were being permanently fused into one segment.** Adjacent segments are merged when they share a speaker, but an unattributed line carries the speaker "Unknown" — so two consecutive Unknown lines compared as "the same speaker" and were joined into a single block. Confirmed against the source text: Lysandra's line and Perdia's interruption, two separate paragraphs in the book, became one segment, after which no attribution pass could separate them and a single voice would have read both sides of the exchange. "Unknown" means the speaker is *not known*, never that two lines share one, so Unknown dialogue lines are no longer merged. Narration and lines with a real speaker name merge exactly as before. ## [1.20.26] — 2026-08-12 ### Fixed - **Segments fusing two speakers' turns are no longer auto-attributed to one of them.** A trailing inquit only names the *last* speaker, so applying it to a block containing several turns ("…ausliefern und …" + "Er hat es nicht so gemeint" + ", unterbrach die blonde Perdia Lysandra.") silently mis-credited the earlier turn — worse than leaving it Unknown, because a wrong attribution is invisible while an Unknown is not. Blocks holding multiple turns are now skipped by the grammar rules and left for the LLM pass or manual splitting. ## [1.20.25] — 2026-08-12 ### Fixed - **A genitive name attached to "Stimme" after a quote now names the speaker** ("Darrags Stimme klang tonlos." → Darrag). The existing voice-announcement rule only inspected the narration *before* a line, so this common mirror image was left Unknown. ## [1.20.24] — 2026-08-12 ### Fixed - **Many German inquit verbs were missing from speaker attribution**, so ordinary lines stayed Unknown: `grunzte`, `versetzte`, `unterbrach`, `drängte`, `herrschte`, `keuchte`, `hauchte`, `stöhnte`, `spottete`, `höhnte`, `erklärte`, `widersprach`, `warf ein`, `setzte hinzu` and others. Also adds the thought verbs `dachte` / `überlegte` / `sinnierte`, since German inner monologue is punctuated like speech and belongs to the character thinking it. - **Trailing inquits naming two people picked the wrong one.** ", unterbrach die blonde Perdia Lysandra." assigned the line to whoever matched last; the grammatical subject is the speaker, so an optional article and adjectives are now skipped and the first name after the verb wins (→ Perdia, not Lysandra). - **A quote stayed Unknown when its own inquit was mistyped as dialogue.** A segment opening with a lower-case speech verb ("grunzte der Ork und hob den Dolch.") is an inquit by construction, whatever type the caster gave it. Those are now recognised regardless of type, and the inquit segment is corrected back to narration. ## [1.20.22] — 2026-08-12 ### Fixed - **A stale browser draft could silently destroy newer casting work.** On opening a book the localStorage draft always won and was then pushed to the server as a "backfill", so a tab holding an older copy overwrote whatever was stored server-side — confirmed live: a cast improved from 79 Unknown lines to 25 was wiped simply by opening the book in a tab that still held the old draft. The two copies are now compared by their save timestamp, the newest wins (and is cached locally), and the server is only backfilled when the local copy is genuinely the newer one. ## [1.20.21] — 2026-08-12 ### Fixed - **Split quotes with a pronoun inquit stayed Unknown.** German dialogue routinely splits as `»Ja, ungewöhnlich«` + `", antwortete er knapp."`, but every attribution rule required a literal *name* after the speech verb, so this whole class was never resolved. The antecedent is unambiguous, so it now resolves like the colon rule — to the last character named in the narration immediately before the quote. Verified against a real novel: resolves the reported case (→ Alrik) plus another, with no LLM call. - **The "Repair mislabeled dialogue" pass used the wrong prompt unless launched from the menu.** Called from Auto-repair or programmatically it fell back to the general casting prompt rather than the verification prompt, which is the one written for "is this narration actually speech?". It now defaults to the verification prompt in every entry path. - **Sentences split apart by quoted terms are rejoined.** German uses `» «` for titles and terms as well as speech ("…um »den gewundenen Weg der Krieger und Häuptlinge« handelte…"), and the casting model read those as a dialogue boundary, cutting the sentence into three fragments that no attribution pass could repair. Consecutive narration segments are now rejoined when the break is grammatically impossible, with a guard so genuine split quotes are not fused into narration. ## [1.20.18] — 2026-08-12 ### Fixed - **Toolbar menus opened upwards and were clipped off the top of the screen in Studio.** The Identify / Cast / Cast-view flyouts were hard-anchored to open above their button, which was right when those buttons lived in the casting panel's footer at the bottom of the page — but in Studio the same toolbar sits near the top, so the menu ran off-screen and its first entries were unreachable (confirmed live: "Auto-repair cast" and "Identify all characters" were both hidden). The menu now opens downwards whenever there is room, falls back to upwards when there isn't, is clamped inside the viewport, and scrolls if it is ever taller than the screen. - Identify Characters menu order now matches the order the passes should be run in, with the one-click **Auto-repair cast** first. ## [1.20.16] — 2026-08-12 ### Added - **"Repair mislabeled dialogue"** — a new casting pass for spoken lines that PDF extraction typed as narration (usually because the `» «` quote marks were lost). These were invisible to "Identify unknown characters", which only scans `type === 'dialogue'`, so re-running it could never fix them; the only pass that could ("Verify all characters") re-sent every narration segment to the LLM. The new pass scores narration for direct-speech signals (a bare inquit in the next segment, stray quote marks, imperative opening, 1st/2nd person, ending in `!`/`?`) and re-checks only the suspicious ones — measured on a real novel: **36 candidates instead of 1036, 29x fewer LLM calls**. - **"Auto-repair cast"** — chains the repair sequence automatically (repair mislabeled → resolve unknown, repeated while it still helps, then a full verification only if still above target). Runs on its own after a completed cast when more than 10 Unknown speakers remain; never after a stopped run. Disable via `localStorage['ab-auto-repair-enabled'] = '0'`. - **German speaker-attribution rules 13–19** in both the casting and verification prompts: listener/vocative exclusion (a name beside a quote is often the listener, not the speaker), split quotes with pronoun inquits, ranked disambiguation among multiple candidates, continuation over ping-pong, unnamed actors as speakers, epithet resolution ("die Elfe" → Nyrilla), and splitting segments that fuse narration with dialogue or two speakers' turns. ### Fixed - **A stopped casting run could silently leave the cast worse than before.** The automatic rollback that undoes a pass which increases Unknown speakers was gated behind `!_audiobook.cancel`, so stopping a run skipped it entirely — confirmed live: an interrupted pass took a book from 80 Unknown lines to 159 with no warning and no way back. Rollback now applies to interrupted runs too. - **Corrected a misleading attribution rule.** Rule 4 told the model that whoever acts before *or after* a quote is usually the speaker, but in German prose the neighbouring name is frequently the listener ("Marcian spürte ihre Tränen", "Marcian hörte darüber hinweg") or the person being addressed. It now applies only to active verbs, with perception/reaction verbs marking the listener. - **"Run until < N unknown" re-sent every narration segment to the LLM on every iteration** (`includeNarrator`), making each round enormously expensive and re-litigating lines that were already correct. It now uses the targeted repair pass. - Identify Characters menu reordered to the order the passes should actually be run in: identify all → repair mislabeled → identify unknown → run until threshold → verify all → check voice consistency. ## [1.20.5] — 2026-08-11 ### Added - **Emotion quick-pick added to Read Aloud, Try a Voice, and Conversation.** All three previously had either no style control at all (Conversation) or a free-text field that Fish-Speech silently ignores (Try a Voice, Read Aloud — it only reacts to an inline `[tag]` in the text). The new picker is backend-aware: on Fish-Speech it's applied as an inline tag on the text itself; on style-aware backends (VoiceDesign, CustomVoice) it fills the existing instruct field as before. - **"Run until < N unknown…"** under Read Aloud's Identify Characters menu — repeats the recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a chosen target, or two passes in a row make no further progress (e.g. GPU/LLM contention). Previously only doable by calling `audiobookRecastUnknown()` directly from the browser console. ### Fixed - **Fish-Speech's per-line emotion tags were being read out loud as literal text instead of applied as silent tone control** (confirmed live: `(excited)` was spoken as "Hexited"). Fish-Speech's S2-Pro checkpoint requires **square brackets** (`[excited]`) for a tag to be treated as a control instruction — the app was sending round parentheses. Now that the user has Fish-Speech running (`fish-speech-api`, port 38080), the backend is confirmed working end-to-end: clones the reference WAV, keeps identity stable, and now actually applies the requested tone silently. Already auto-discovered under Engines → Text to Speech → Local, no configuration needed. - **Character portrait prompts had no idea what kind of book they belonged to**, producing things like a WWI/WWII-era naval admiral, East-Asian-styled warriors, and an Asian-presenting priest in a Western medieval fantasy book (confirmed live with screenshots from a German DSA fantasy novel). The book-level context feature (`/api/book-profile` — genre/setting/era/language) already existed and was wired into voice-design prompts, but was never passed to `csBuildImagePrompt`. Also now includes the character's `race_species` field (human/elf/ork/etc., already collected by the casting LLM but previously unused downstream) in the prompt. Applies automatically to future portrait generations once a book profile is set on Read Aloud → Book Profile. - **Fish-Speech emotion tags produced no audible effect on non-English books** (confirmed live on a German audiobook: audio quality was fine, but every line was flat/emotionless). Root cause was two-fold: (1) per-line auto emotions are LLM-generated in the book's own spoken language (e.g. German "bedrohlich"), but Fish-Speech's docs require **English** tags "regardless of the spoken language" — the untranslated German word was silently ignored; (2) a **double-tagging bug**: the client already embeds `[tag]` directly into the text sent to Fish-Speech, but the server was *also* independently deriving and prepending its own tag from the separately-sent Qwen3-TTS-style instruct sentence, producing things like `[Sprich in einem bedrohlich Tonfall.] [bedrohlich] …` — neither bracket was valid English, so both were ignored. Fixed by (a) adding a German→English emotion-word translation table used when building the client-side Fish tag, and (b) making the server skip its own tag derivation whenever the text already carries one, while still falling back to the original short-text-verbatim behavior for freely-typed style instructions (Read Aloud / Try a Voice) that don't match either template. Qwen3-TTS backends are unaffected — they still receive the native-language instruct sentence unchanged, which is what they're designed to understand. ## [1.20.4] — 2026-08-10 ### Fixed - **"Casting audiobook" and "Casting unknown" could finish all their LLM work and then get permanently stuck showing "Stop Casting"**, with no error and no way to proceed, even though the underlying work had genuinely completed (confirmed live: GPU load back to idle, but the panel never left its "running" state). Root cause: in both `audiobookCast()` and `audiobookRecastUnknown()`, the post-processing that runs after the main loop finishes (deduping, rollback checks, saving the draft) was completely unguarded — any exception there meant execution never reached `view.complete()`, the only thing that actually resets the button and shows a result. Both now wrap that tail in their own try/catch, so a failure there still reaches a terminal state with a visible error instead of hanging forever. Note: this fix only applies to casting runs started after upgrading — a session already stuck in this state is running old code in memory and won't self-recover; reload and reopen the book, and the periodic autosave during casting means progress up to the point it finished should still be there to resume from. ## [1.20.3] — 2026-08-10 ### Fixed - **Heading OCR now actually recognizes decorative chapter-heading images that combine an icon/border graphic with the text** (e.g. a bold octagonal badge around "3.Kapitel") — previously it failed silently on every single one of these in a real test book (0 of 16 chapters recovered), either finding no text at all or confidently misreading the border as a stray character, because Tesseract's default full-page layout analysis gets confused by the graphic surrounding the actual text. Root-caused by testing the real OCR engine directly against the actual failing page: cropping tightly to exclude the graphic (roughly the bottom half of the heading region, where centered chapter-title text typically sits below any icon) and telling Tesseract to expect a single line of text (page segmentation mode 7) fixed it completely — verified against all 16 chapter headings in the same book, all recovered correctly and cleanly (1.Kapitel through 16.Kapitel, no garbage, no duplicates). Falls back to the original untrimmed full-heading-region OCR for headings that are already plain text with no surrounding graphic, so this is additive, not a narrowing of what already worked. ## [1.20.1] — 2026-08-10 ### Fixed - **A PDF page that produces zero extractable text (no real text layer AND heading OCR either found nothing or failed the confidence threshold — common for a page that's entirely a decorative divider graphic, e.g. one book had a page that was just a small icon with no chapter number at all) used to be completely invisible to page tracking**, silently shifting every subsequent page number out of sync with the actual PDF for the rest of the book. Confirmed live: investigated a real book's chapter-heading pages directly — one divider page rendered as literally just a small icon graphic (no OCR-recoverable text by design, not an OCR failure), and every such page was dropping out of `readerState.sentences` entirely, taking its page number with it. Every PDF page now keeps at least a placeholder marker (empty text — never spoken, never shown as a line) so page-break reconstruction after LLM speaker-attribution never skips a page number. ## [1.20.0] — 2026-08-10 ### Added - **App Routing rows now have a playback-speed multiplier (0.5x-2x) and a preview button.** The speed is applied server-side to real routed requests via a pitch-preserving ffmpeg tempo change (not a naive frame-rate shift, which would make a sped-up voice sound like a chipmunk) — see `core/audio.py:_change_tempo`. The preview button synthesizes the row's exact output voice directly (no need to save the route first) and plays it back at the chosen speed for an instant "does this sound right" check, using a short phrase localized to the row's own language setting. ## [1.19.4] — 2026-08-10 ### Changed - **Audiobook export now synthesizes lines one at a time instead of 2 concurrent workers.** Every TTS backend this app talks to (Voice Clone, Voice Design, Fish-Speech, and the other local engines) is a single self-hosted GPU model instance, not a horizontally-scaled service — confirmed live, twice now, with two different backends: 2 concurrent requests reliably push at least one past the reverse proxy's 60-second timeout under real load, causing seemingly-random per-line failures that can doom a whole multi-hour export. Serial is slower per line but doesn't waste time on doomed, retried requests — net faster in practice, and actually finishes. ## [1.19.3] — 2026-08-10 ### Fixed - **The looser chapter-heading detection from 1.19.0 caused ordinary narration sentences to be misdetected as chapter breaks, replacing real paragraph text with a thin marker line — confirmed live as "lots of empty pages" in the A4 pagination view.** Two separate bugs: (1) requiring the keyword only at the start of the line, with nothing checked afterward, meant any short sentence starting with a common word like German "Teil" ("part") — e.g. "Teil des Grundes war unklar." ("Part of the reason was unclear.") — matched as a chapter; now whatever follows the keyword must actually look like part of a heading (empty, a bare number, or a colon/dash-separated subtitle), not a normal grammatical continuation. (2) The leading-numeral stripping regex treated a bare "C" as valid Roman numeral 100 with no requirement that anything sensible follow it — so it silently ate the "C" off the front of "Chapter", turning "Chapter 1: The Beginning" into "hapter 1: The Beginning" before the keyword check ever ran. Verified against the actual book that surfaced this (1998 lines): zero false positives, one correct real match ("14. Kapitel"). ## [1.19.2] — 2026-08-10 ### Fixed - **The tone/identity comparison table always suggested Voice Design as the alternative to Voice Clone, never Fish-Speech, even when Fish-Speech was running** — the suggestion logic just took the first backend matching one criterion (`style_aware`, or `uses_wav`) rather than preferring one matching BOTH, so it never surfaced the strictly-better option (tone-aware AND keeps voice identity) over a partial fix. Now prefers a backend satisfying both properties before falling back to a partial match. ## [1.19.1] — 2026-08-10 ### Added - **Chapter headings now render as a visible horizontal-rule marker with the chapter's own title/number in the Script Rehearser/Studio Stage view**, not just as an audible pause in the finished export. Reuses the exact same detection `audiobookExport()` uses for chapter/file boundaries, so the fix to that detection (numbered OCR headings like "1.Kapitel") shows up here too — scanning through a long script now makes chapter breaks visually obvious instead of looking like one continuous, undifferentiated wall of narration. ## [1.19.0] — 2026-08-10 ### Added - **Audiobook exports now have real pauses between paragraphs and chapters, plus an optional custom chapter-transition sound** — `mergeWavBlobs()` used to concatenate every line's clip with literally zero gap, which read as characters teleporting mid-scene with no beat between paragraphs, let alone chapters. New "Pacing" section in Studio → Perform & Export lets you set the paragraph pause (default 2s) and chapter pause (default 4s) independently, and upload a short sound (chime, page-turn, etc., max 2 MB) to play before each chapter's pause — decoded and resampled client-side to exactly match the narration's own sample rate so it splices in cleanly rather than corrupting the merge. Settings persist per-browser via localStorage. ### Fixed - **Chapter headings recovered via OCR (from PDFs where the heading is baked into the page as an image) usually weren't recognised as chapters at all**, because `audiobookIsChapter()` required the keyword ("Kapitel"/"Chapter"/etc.) to be the very first word — but OCR'd numbered headings commonly read "1.Kapitel" or "I. Kapitel" with the number first. This silently defeated the documented "one file per chapter" audiobook export for any book using numbered image headings (confirmed live: a full novel exported as a single 7.5-hour file instead of per-chapter files). The chapter regex now tolerates an optional leading number or roman numeral before the keyword. ## [1.18.23] — 2026-08-09 ### Changed - Moved the backend tone/identity comparison table into the "Generate full audiobook" toggle row's spare width, instead of rendering as its own full-width banner below the toolbar. ## [1.18.22] — 2026-08-09 ### Changed - Redesigned the backend tone/identity warning as a proper comparison table (current backend vs. the suggested alternative, "Tone control" and "Voice stays identical" as columns with ✓/✗) instead of a run-on sentence with a "Switch" button awkwardly wedged into the middle of it. ## [1.18.21] — 2026-08-09 ### Changed - "Skip narrator" now defaults to unchecked (narration reads by default) and moved to the front of the Stage toolbar. - The backend tone-support warning ("Switch to X for reliable tone…") now has an actual "Switch to X" button instead of just naming the better backend in a sentence and leaving you to go find it yourself in a settings dropdown. ## [1.18.20] — 2026-08-09 ### Fixed - **A single transient synthesis failure could silently doom an entire multi-hour audiobook export.** `fetchTtsPreviewBlob`'s own retry logic only covers connection-level failures — `fetch()` doesn't throw on a non-2xx HTTP response, so a backend hiccup (confirmed live: transient 500s clustered in the first ~50 lines, most likely GPU/engine warm-up contention from the two parallel export workers both starting cold) skipped that retry layer entirely and permanently failed the line. Different, completely ordinary lines failed across repeated attempts — never the same one twice — confirming this was never about any specific line's content. The export now retries a failed line up to 3 times with backoff before giving up on it for real. ## [1.18.19] — 2026-08-09 ### Fixed - **Reverted the sticky "Generate full audiobook" toggle from 1.18.18 — it broke scrolling entirely.** Stacking two sticky headers (the toggle plus the already-sticky, now-wrapping multi-row transport bar below it) could exceed the viewport height on a real window, leaving nothing scrollable visible at all. The toggle now scrolls away normally again, same as before 1.18.18; only the transport bar stays sticky. Will revisit with the wrapped toolbar's actual height accounted for, tested in isolation before shipping again. ## [1.18.18] — 2026-07-30 ### Fixed - **The font-size (-A/+A) buttons were ~18px shorter than every other toolbar button** (`.reh-fontsize-btn` overrode padding to 2px 7px against the base button's 9px 18px). Removing the override alone left a smaller residual gap (plain "A" text computes a shorter natural line-height than icon+text buttons) — matched the sibling buttons' actual rendered height directly instead of guessing at line-height multipliers. Verified live: exact match. - **Studio's "Generate full audiobook" toggle wasn't sticky, so it scrolled out of reach while reading through a long script** — only the transport bar below it was. Both now stick together, stacked in the correct order, so the mode toggle and play controls stay visible throughout. ## [1.18.15] — 2026-07-30 ### Changed - Renamed the Stage toolbar's "Skip desc." checkbox to "Skip narrator" — clearer about what it actually controls (whether the Narrator voice reads scene/action text during playback) now that the toggle genuinely works. ## [1.18.14] — 2026-07-30 ### Fixed - **The Stage toolbar's font-size, page-mode, Train, and Exit buttons were unreachable on any normal (non-ultrawide) window.** `.reh-console-actions` had `flex-shrink: 0`, and flex items default to `min-width: auto` regardless of any width set — together these meant the block could never shrink below its full unwrapped width (~1560px), so the browser's wrapping algorithm never considered it "too wide to fit" and it just overflowed silently off the right edge with no scrollbar to reach it. Confirmed live at a realistic 800px window width. Now shrinks and wraps onto additional rows instead. ## [1.18.13] — 2026-07-30 ### Fixed - **Narration playback ignored the "Skip descriptions" toggle (and Studio's "Rehearse ⇄ Audiobook" toggle, which just flips the same flag) once any narrator voice was assigned.** The batch pre-skip optimization correctly checked both "skip mode is on" and "no narrator voice set," but the actual per-line narration-speak branch below it never re-checked the skip flag at all — so as soon as a book had a narrator voice configured (the normal case for any book actually being produced), narration played regardless of the toggle's position. This is why the toggle looked like dead weight: it couldn't turn narration OFF, only ever left it stuck ON. Both branches now consistently respect the toggle. - Studio's audiobook-mode toggle now warns clearly if you turn it on with no narrator voice assigned yet, instead of silently doing nothing. - Root-caused "emotions still not recognisable" on Book 02's audiobook: its entire line-audio cache (1761 cached clips) was synthesized *before* the per-line emotion-instruct engine fix was actually deployed to the running container — confirmed by comparing file timestamps against the fix's deploy time. Since the cache key is a hash of (text + voice + instruct) and none of those changed, every future playback kept serving the identical pre-fix, flat-delivery audio forever, with the engine fix having no way to ever take effect. Cleared the stale cache; the next playback or export for Book 02 will synthesize fresh against the corrected engine. Book 01's cache was unaffected — it was built entirely after the fix. ### Fixed - **A voice that started as a Voice Design creation was routed to the Voice Design engine forever, even after it had a proper reference clip saved.** Voice Design has no seed parameter at all, so every playback of a designed voice was an unpinned, unreproducible roll regardless of any seed pinned for it. Routing now checks only whether a reference clip exists (`has_ref`) — the actual reason Voice Design is needed at all — not voice origin. A designed voice with a saved reference now clones like any other voice, which is what makes a pinned seed actually take effect for it. Applies to line playback throughout the app (Rehearsal, audiobook export, Try It Out) and to the Seed Finder's own backend default. ## [1.18.11] — 2026-07-29 ### Fixed - **Designed voices now store their full design prompt.** For a designed voice the prompt IS the voice's identity — the engine reproduces it from that text alone — but the only copy saved was the `note` display summary, which is deliberately clipped to 240 characters (43 of 73 voices were cut off mid-sentence). The complete prompt is now saved in its own field, so the TTS engine can register the voice from the whole description rather than a truncated one. Existing voices keep working from the clipped copy — it still carries gender, accent and timbre — and pick up the full text the next time they are redesigned. ### Note - This release pairs with an engine-side fix (in the `tts-dgx-spark-faster-qwen3-tts` repo) for two bugs that made custom voices unusable: stale speaker embeddings causing cloned voices to ignore the requested text entirely, and designed voices never being registered with the Voice Design engine, which silently substituted a bundled British preset. See that repo's history for details. ## [1.18.10] — 2026-07-29 ### Fixed - **A voice's pinned seed (set via the Seed Finder / Batch Seeds tools) was saved but never actually used — for two independent reasons.** (1) The seed gets written to the voice's own metadata, but every normal generation call (Try It Out, Rehearsal, audiobook export) built its request without ever reading that value back — only the Seed Finder's own one-off benchmarking codepath looked at it. (2) Separately, the internal "voice_design_playback" stability profile (added in 1.18.4 specifically so ongoing dialogue wouldn't hardcode `seed: 0` over every per-voice pin) wasn't recognized by the backend-name normalizer, which silently coerced it back to "voice_clone" — reintroducing exactly the hardcoded `seed: 0` it was designed to avoid. Together these meant a designed voice could still sound noticeably different (including gender-adjacent timbre drift) from one generation to the next even after "pinning" a seed for it — the pin was completely inert. Confirmed live post-fix: a Voice Design request for a voice with a saved seed now actually carries that seed. Every voice_clone/voice_design/customvoice/streaming request falls back to the voice's own saved seed when the request doesn't already specify one explicitly. - **"Apply character persona" silently did nothing when the selected voice had no persona text saved** (persona is a field you type manually on the Voice Inspector page — it's never auto-filled from a character sheet) — indistinguishable from the feature being broken. The checkbox is now disabled with an explanatory tooltip when the voice has no persona, and the backend returns a clear error instead of a silent no-op if it's checked anyway. - **Chunked preview generation (auto-enabled for text over 200 characters) dropped "Apply character persona" entirely**, regardless of whether the checkbox was checked — it was hardcoded off for that code path only. ## [1.18.9] — 2026-07-29 ### Fixed - **Highpass/lowpass filters (the new Telephone/Radio bandpass control from 1.18.7) were far too weak to be audible** — confirmed via spectral analysis: pedalboard's filters are single-pole (~6 dB/octave), so the residual harmonic distortion left over from compression (see below) still poked straight through a single pass, leaving the "outside the passband" energy roughly unchanged or even higher than the original. Each highpass/lowpass now cascades 3 internal stages (~18 dB/octave) — confirmed live: content below/above the cutoff is now reduced by 7-14 dB instead of being essentially untouched. ## [1.18.8] — 2026-07-29 ### Fixed - **The compressor's makeup gain (added in 1.18.5) was clipping.** Confirmed live via spectral analysis: makeup gain routinely pushed peaks to ~1.9 (well past the ±1.0 digital ceiling), and the final hard clip in the effects pipeline sliced that overshoot off — producing broadband harmonic distortion that swamped the actual intended effect. This is why the Telephone/Radio presets still sounded like "no difference": the distortion, not the intended band-limiting, was dominating the output. Replaced the hard clip with a proper limiter after makeup gain, so peaks are caught smoothly instead of sliced. ## [1.18.7] — 2026-07-29 ### Added - **Telephone/Radio effect presets now actually band-limit the audio** — a new "Bandpass (telephone/radio)" control (high-pass + low-pass cutoff sliders) was added to the Try It Out effects panel and wired into both presets. Compression alone (even correctly calibrated) doesn't sound like "a phone call" — the defining trait is narrow frequency range, which the presets never applied and the UI never exposed. ## [1.18.6] — 2026-07-29 ### Fixed - **The last fix for "Try It Out" showing only generic Voice Design presets instead of your own voices made things worse — it went from "wrong voices" to "no voices found."** Voice Design's raw discovery endpoint only ever lists its own bundled presets, never any custom voice — so filtering that list against the user's own library (the same technique that correctly works for Voice Clone/Streaming) always produced an empty intersection. Fixed properly: for Voice Design, the dropdown is populated directly from the user's own active voice library instead of trying to filter the engine's useless discovery list at all. ## [1.18.5] — 2026-07-29 ### Fixed - **Browser Back/Forward didn't work inside the app at all** — every section change called `history.replaceState`, which overwrites the SAME single history entry instead of adding a new one, so the browser had nothing from the app's own navigation to step through. Clicking Back skipped straight past the whole app to whatever page was open before it. Switched to `pushState`; the existing hashchange listener already handled Back/Forward correctly, it just never had real history entries to respond to. - **"Try It Out"'s Voice Design dropdown only ever showed the engine's own built-in presets** (vd_british_male, vd_german_male, ...) — never any of your own designed voices, even after designing 70+ characters for a book. Now filtered to your own voice library, same as Voice Clone/Streaming already were. - **Audio effects had no audible impact** — root-caused live: every effects preset's compressor threshold sat ABOVE where our own TTS output actually lives (all voices are normalized to ~-20dBFS; presets ranged from -10dB to -18dB), so a compressor threshold-gated to louder signal had almost nothing to act on — confirmed the "Telephone" preset changed a real speech clip's loudness by under 0.1%. Lowered every preset's threshold to actually engage with real output, and added makeup gain after compression (a plain compressor with no makeup gain only ever shaves peaks quieter — it never produces the louder, "punchier" sound people associate with compression, which is why even a correctly-engaging compressor was hard to notice). ## [1.18.4] — 2026-07-29 ### Fixed - **Several characters were split across multiple speaker labels with inconsistent — sometimes wrongly-gendered — voices**: the underlying book text refers to the same person by different names/titles/epithets at different points (a vampire lord called "Roger," "Zerwas," and others; a character nicknamed "Irre" who is really "Uriens"; a guard referred to once as "Wächter" and once as "Wächter des Turmes"), and each distinct label had gotten its own independent cast entry and voice. Confirmed live: merged 8 such groups onto one consistent voice each. Four of them ("Inquisitor," "Jägerin," "Linosch," "Sharraz" — 61 lines total) had NO voice assigned at all, meaning that dialogue was being silently dropped from the audiobook entirely, not just mis-voiced. - **Emotion stopped coming through audibly right after the accent-reinforcement fix landed** — root cause: Qwen3-TTS's own prompting guidance warns it doesn't reliably follow multiple conflicting instructions in one prompt, favoring one over another. The accent clause was being placed FIRST in every line's instruct, making it the most prominent instruction on every single line and likely crowding out the (shorter) emotion tag that followed it. Reordered so emotion leads and the accent reminder trails, instead of the other way around. ## [1.18.3] — 2026-07-28 ### Fixed - **The anti-American-accent instruction only ever applied to the one-time call that designs a new voice — never to any of the actual lines it goes on to read.** Root cause: the accent clause was built fresh at design time and layered on top of the character's saved voice-quality description, but never written back into that saved description — so every ONGOING line's instruct (built from the saved profile) carried zero accent guidance. Since each synthesis call is stateless, the model has no memory of the original design call's instructions; omitting the clause here meant real narration got none of it at all, only the initial creation did. The same accent clause now gets added to every line's instruct, not just the design call — Stage playback, Synth all, and the audiobook export all benefit automatically since they share the one `_buildInstruct` function. ## [1.18.2] — 2026-07-28 ### Fixed - **Seed Finder's default test sentence for German (and every other) voices was itself a mid-sentence mix of German and English** — confirmed as the actual cause of "the seeds are horrible, that's a mixture of English and German": the `DE`, `EN`, and "mixed torture-test" constants were all literally the same string, including a full English sentence ("The system administrator successfully configured the customized Docker stacks...") baked into the "German" default. Replaced with genuinely single-language defaults per voice, and — better — the voice's own saved reference transcript (real book content) is now used first when available, since that's exactly what the voice will actually read. - **Designed-voice playback never told the engine what language to expect, unlike every other backend** — left at "Auto" (auto-detect) for every single line, which is least reliable on short dialogue lines. Now passes the voice's own language explicitly, same as Voice Clone/Streaming/CustomVoice already do. ### Investigated - Tested whether an already-designed voice could be served through the Voice Clone engine instead (the officially-documented path for consistent multi-line reuse) — the clone engine doesn't recognize a Voice-Design-only voice_id at all (confirmed live, before and after a full backend restart), so this would require a proper conversion step this app doesn't yet implement. Not pursued further this round; noted for a future pass. ## [1.18.1] — 2026-07-27 ### Fixed - **The previous loudness fix (normalizing every clip independently to one target level) had a real side effect: it erased a voice's own whisper-vs-shout dynamics along with fixing the cross-voice level gap.** Confirmed live: a whispered line came out LOUDER than its own neutral reading once both were pushed to the same target — exactly backwards. Replaced with a fixed per-voice gain offset (the voice's own already-computed reference gain from Calc dB, applied uniformly to every line from that voice) — this shifts each voice's baseline to match others without touching how loud one line is relative to another from the same voice. - Audited all 72 designed voices used in "Die Entdeckung" for the anti-American-accent instruction added earlier this session — 34 of them (nearly half) predated that fix and never got it. All 34 are being redesigned with the current prompt builder. ## [1.18.0] — 2026-07-27 ### Fixed - **Emotion tags barely registered on designed voices** — root cause: the casting pass tags emotions in the book's own language (e.g. German "fordernd", "entschlossen"), but the instruct sentence wrapping it was hardcoded English ("Speak in a fordernd manner."), dropping a German word into an English carrier sentence — a much weaker signal than a natural sentence in one language. The template now matches the target voice's own language (inferred from its `DE_`/`EN_`/... id prefix), applied everywhere an instruct gets built: Stage playback, Synth all, re-synthesis, Train mode, and — the one that actually matters for a finished audiobook — the export path itself, which had drifted out of sync with the others and was still building English-only instructs. - **The Narrator sounded noticeably louder than designed-voice characters in a finished export** — nothing in the synthesis or merge pipeline ever loudness-matched clips from different backends against each other. Every freshly-synthesized clip is now normalized to the same target used for voice reference files; a new endpoint also normalizes an already-built cache in place (pure audio processing, no resynthesis) so an existing book doesn't need a multi-hour re-synth just to fix levels. ### Added - Per-clip and whole-book-cache loudness normalization endpoints (`/api/audio/normalize-wav`, `/api/line-audio/{book}/normalize`). ## [1.17.99] — 2026-07-27 ### Fixed - **Designed voices could occasionally speak nonsense or repeat text mid-line, unlike cloned voices** — root-caused by comparing our own request payloads against the actual Qwen3-TTS API: the app already applies a stability profile (temperature/top_p) to every Voice Clone request to keep it consistent, but Voice Design's stability profile was deliberately left empty — correct for the ONE-TIME call that designs a brand-new voice (you want fresh randomness there, or every character sounds the same), but that same empty profile was also being used for every ONGOING line of dialogue read by an already-designed voice, which needs the opposite: consistency, not randomness. Split into two separate profiles — voice creation stays unconstrained, but reading a line from an existing designed voice now gets the same stability treatment as Voice Clone. Verified live: the same line synthesized 5 times in a row went from inconsistent/occasionally-wrong to a perfect transcription match every time, with emotional instructions (tested angry vs. whisper vs. neutral) still working correctly. - All 72 designed voices in "Die Entdeckung" were pinned to a specific seed via an automated hunt (tries a few seeds per voice, keeps whichever one round-trip-verifies correctly) rather than left on a random seed per call — the other major source of the "sometimes fine, sometimes not" inconsistency. - Added a matching "Voice Design (reading a line)" params field in Settings, alongside the existing "Voice Design (creating a new voice)" one, so the two are no longer silently sharing one config slot. ## [1.17.98] — 2026-07-26 ### Added - **Voice round-trip verification**: synthesizes a voice's own reference line (or a language-appropriate default), transcribes the result back with Whisper, and compares it word-for-word to the original text. A duration/wpm-only benchmark can't tell "read the line correctly" from "repeated it twice" or "said something unrelated" — both can produce a perfectly normal-looking duration and pass every prior check; this actually checks the words. New "Verify (STT)" button in the Voice Library toolbar runs it over the active/selected voices and lists exactly which ones failed and what they said instead. - The same round-trip check now runs automatically as part of every voice design/redesign attempt — a low-scoring attempt is rejected and retried just like a clipped or implausible-wpm one, so a bad design no longer needs to be caught by ear after the fact. ## [1.17.97] — 2026-07-26 ### Fixed - **A designed voice (no reference WAV) failed outright — every time, no retry possible — whenever a bulk action picked the wrong backend for it.** Root cause found in three places at once: "Precompute" and "Batch seeds" both used one globally-selected backend for every voice in a batch, and the per-voice Seed Finder didn't offer Voice Design as an option at all — so a selection that was entirely designed voices (like this book's 24-character redesign) got "Precomputed 0 embedding(s), 72 skipped/failed" and a batch-seed run that failed every single job. Every synthesis path in the app (Stage playback, Synth all, Audiobook export, Precompute, Batch seeds, Seed Finder) now resolves the correct backend per voice automatically — a designed voice always routes to Voice Design regardless of what's globally selected; every other voice still respects it. - Added a "Voice Design" option to the per-voice Seed Finder's backend selector (it only offered Voice Clone/Streaming before), auto-selected when opening a designed voice. ## [1.17.96] — 2026-07-26 ### Fixed - **A bulk synth/export job (Synth all, Audiobook) could lose lines outright to "Failed to fetch" with zero retry** — confirmed live: a ~2000-line export lost 346 lines this way. Same root cause as the earlier voice-design fix: a GPU-contended TTS backend container can be mid-restart for a handful of seconds, and a job hitting the TTS endpoint hundreds of times in a row will reliably catch that window at least once. The core `fetchTtsPreviewBlob` helper (used by every synthesis path in the app) now retries a couple of times with a short backoff on a raw connection failure before giving up — a real HTTP error response is still surfaced immediately, only a connection that never got a response at all is retried. ## [1.17.95] — 2026-07-26 ### Fixed - **Reassigning a character's voice in the Library/Studio Voices tab didn't reach an already-open Stage session for that book — the export kept quietly using the OLD voice, and its cached audio for that character's lines never got invalidated**, which is why a re-export right after changing voices could finish suspiciously fast: it wasn't skipping work because nothing needed to change, it was skipping work it should have redone. A rehearsal loads its cast once from its own saved record, and an explicit prior voice always takes precedence over a fresher one from the shared roster — by design, so casting choices aren't clobbered — but that meant a later voice change had no path back in at all. Fixed: saving a character's voice now updates a matching, currently-open rehearsal's cast immediately and marks that speaker's already-synthesized lines stale, so the next synth/export picks up the new voice instead of reusing old audio. ## [1.17.94] — 2026-07-26 ### Fixed - **Voices designed for sparse/generic cast entries ("Frau", "Mann", "Geliebten", "Kissen"...) could come out with the wrong gender and read an English placeholder sentence, even in an all-German book.** Root cause: these are minor entries the casting pass extracted from a plain noun in the text rather than a real named character, so their sheet never gets a `gender` filled in and has no descriptive text to detect a language from — the design code was defaulting straight to English + neutral gender instead of falling back to the book's own already-known language. Fixed: the reference line now falls back to the book's resolved language (same lookup already used for the voice-design instructions) instead of English, and a handful of unambiguous German nouns ("Frau", "Mann", "Junge", "Mädchen", "Herr", "Dame") get their obvious gender when the sheet's own field is empty. ## [1.17.93] — 2026-07-26 ### Changed - **The voice-preview play button on each character's row/card now plays that voice's own stored audio file instantly instead of re-synthesizing a sample through the TTS engine on every click.** My Voices already has a fast "play original recording" button for exactly this; the character-table button was doing the slow, GPU-heavy thing instead for no benefit — you're just trying to confirm which voice a character has, and the exact audio already sits on disk. ## [1.17.92] — 2026-07-26 ### Fixed - **"Voice design failed: Failed to fetch" could hit almost any character when designing several back-to-back** — root-caused live from the server's own access log: a redesign that changes a voice's reference audio schedules a TTS backend restart so the change actually takes effect, and that restart was firing immediately after every single character (each one taking ~10-30s to come back). Clicking "design" on the next character while that restart was still in flight sent its request straight into a dead backend, failing outright with no retry. Fixed two ways: (1) individual (non-bulk) redesigns now coalesce rapid back-to-back restarts into one, firing a few seconds after the last click instead of after every single one; (2) the design/save calls themselves now retry a couple of times with a short backoff on a raw connection failure before giving up, since the window is normally only a few seconds wide. ## [1.17.91] — 2026-07-26 ### Fixed - **A voice (or image prompt) saved for one character could silently land on a completely different character instead** — confirmed live for "Marcian": every attempt to design/save his voice actually overwrote "Alrik von Blautann"'s record, because Alrik's own LLM-extracted `aliases` field happened to literally list "Marcian" as one of his alternate names (an extraction slip). The save path re-derives which character record to write to by scanning all characters for a shared alias/name token — meant for deduping a freshly-extracted sheet at casting time, but reused here too, so one stray shared token silently redirected the write to the wrong character with no error. Fixed by having the voice/image-prompt save paths point at the exact character record they already have in hand (its own id) instead of re-guessing by alias every time. ## [1.17.90] — 2026-07-26 ### Fixed - **Voice design could fail outright ("produced broken audio after 3 attempts") when the real cause was an unrelated backend being briefly unreachable, not bad audio.** Root-caused live for character "Marcian": the post-design quality check benchmarks each attempt against the voice-clone TTS backend, which was stuck in a GPU-out-of-memory restart loop (fighting the LLM engine for shared GPU memory) — every benchmark call got a flat "Connection refused"/"Connection reset", which the check then treated exactly like a genuinely corrupted recording, burning all 3 attempts and leaving the character with no voice. Now a connectivity failure against the benchmark backend is treated as "couldn't verify" rather than "verified broken" — the design is accepted (a real defect still gets caught and rejected normally whenever the benchmark backend actually responds). ## [1.17.89] — 2026-07-25 ### Added - **"Design a voice" now opens an inline, editable prompt popup instead of navigating away to the Design a Voice page.** Clicking the design/redesign icon on a character (card, table row, or detail modal) shows the same saved/built voice-design prompt in a text box right there, with a single "Generate voice" button — edit the description and regenerate without ever leaving the Studio/Library screen, and without needing a way back afterward since you never left. ## [1.17.88] — 2026-07-25 ### Fixed - **The voice picker opened by the "Auswahl" button in the Voices table (and in the character detail modal) could appear far away from the button that opened it** — both passed the entire row/card or modal box as the anchor element instead of the button actually clicked, so the picker's position (measured via that element's own bounding box) landed wherever that much bigger container happened to start or end rather than next to "Auswahl". Confirmed live: fixed to anchor to the actual clicked button — the popup now opens directly below/beside it as expected. - **That same picker only ever showed the first 60 voices**, silently hiding the rest of a larger library unless you already knew to search by name — confirmed as the reported "not all the voices I have" (154 enabled voices, only 60 ever shown). Raised to 500 (a sane upper bound, not a real-world limit) so a realistic library shows in full. ## [1.17.86] — 2026-07-25 ### Added - **The Stage page now asks the server which lines already have cached audio on disk, in one batch request, and lights up their "pre-synthesized" dots accordingly.** Previously every dot only ever reflected `rehState.synthCache` — this browser tab's own memory, empty on every fresh page load — so a script that was fully "Synth all"-ed and correctly persisted to disk in an earlier session still looked completely unsynthesized after a reload, with nothing indicating the cached audio was actually right there. Confirmed live: after a reload, lines with an on-disk cache file correctly show their dot again without downloading any audio up front (that still only happens lazily, right when a line is about to play). - **"Exports" button on the Stage toolbar** — browse and re-download audiobook chapters already exported for the current book (via the server-side saving added in 1.17.85) without re-running the export. The export-results panel also gained a **"Download all (.zip)"** link when there's more than one chapter file. ## [1.17.85] — 2026-07-25 ### Added - **"Clean cache" button on the Stage toolbar** — scans the current script for every cache key its lines would actually use right now, and deletes anything else cached on disk for this book. Needed because editing a paragraph never deletes its old cached file (the write path only ever knows the new content's hash, not whatever it used to hash to before the edit) — this is the cleanup pass for that dead weight, run whenever you want rather than automatically. Verified live: cleaned up 32 orphaned files after a round of edits. - **"Audiobook" export now also saves each chapter server-side and shows a results panel with real download links**, not just a browser download that lands wherever your browser settings put it with no record in the app of where it went. The panel also names the exact chapter files so you know what actually came out of the export. ### Changed - **The Stage/Perform & Export page is noticeably wider** (794px → 1180px) — it was simulating a literal A4-page width regardless of screen size, wasting a lot of horizontal space on wide monitors compared to the Characters view's fuller-width text column. ## [1.17.84] — 2026-07-25 ### Added - **Every line synthesized via "Synth all", re-synthesizing a stale line, or just playing a line individually now persists to disk**, keyed by a hash of exactly what determines its sound (text + voice + tone), in a folder named after the book — not just kept in the browser tab's memory like before. Editing a paragraph changes its hash, so the edited version simply never matches the old cached file and gets synthesized fresh; an untouched paragraph keeps reusing its file indefinitely, even across a page reload or a totally new browser session. This is what makes playback "flawless and fast" with no GPU wait between paragraphs once a book has been synthesized at least once — confirmed live: a second play of an already-cached line hits the new `/api/line-audio` endpoint (a plain 200) instead of `/api/tts-preview` (the actual TTS call) at all. The full "Audiobook" export also checks this cache before synthesizing, on top of the in-memory reuse added in 1.17.79. ### Fixed - **"PDF pages" mode never actually produced separate pages — the whole book rendered as one continuous page**, identically to "Scroll" mode, on every book regardless of how many real page breaks its source document had. Root cause: `parseScript`'s page-break detection tested the line AFTER `.trim()` — and `.trim()` strips `\f` (form feed) as whitespace along with everything else, so the exact marker the check was looking for was always gone by the time the check ran. A real book with 232 page marks in its source text was producing zero pagebreak lines after parsing. Fixed to check the original, untrimmed line. "PDF pages" (break at the document's own real pages) is now also the default mode instead of "A4 pages" (break purely by content height, ignoring the source document's pages entirely). ## [1.17.82] — 2026-07-25 ### Changed - **Auto-designed voices now explicitly steer away from an American English accent by default.** Gender and language were already correctly sourced from the character's own sheet and the book's resolved language respectively (confirmed in the actual `/api/voice-design` call, not guessed per character) — but nothing ever told the model what accent to actually use, and its default leans American-English regardless of target language, a recurring complaint even on non-English books. The instruct text now explicitly names the accent: an authentic native accent for the book's own language when it isn't English, or a neutral British/international English accent when it is — never American. Applied both to the automated bulk "Auto-design voices" path and the LLM-authored `voice_design_prompt` field used by the manual per-character Design flow. ### Fixed - **Clicking a line's play button while a previous, not-yet-synthesized line was still loading could let that stale synthesis cut in and start playing anyway** — on top of, or right over, the line actually requested, with no clean way to stop just the stray one. The guard after awaiting a fresh TTS synthesis only checked a bare `rehState.playing` boolean, which the new click's own stop-then-start had already flipped back to `true` by the time the stale continuation resumed. Now checks that the line index itself hasn't changed since this specific playback was started, not just that *something* is playing. - **The narrator paragraph play button (added in 1.17.74) showed two overlapping play icons on one small button** — it shared a CSS class with dialogue's own avatar-badge play button, which layers a small badge icon on top via `::after`, on top of this button's own separate icon. Narrator play buttons are now styled as that same badge directly instead of stacking both. - **That narrator play button also never visually indicated a line was playing** — same static play icon the whole time, no way to tell that clicking it again would stop it. It now swaps to a stop icon while its own line is the one active. - **The Stage sidebar's character names were always shown in shouting-case** (e.g. "ALRIK VON BLAUTANN") — it was rendering the raw speaker key straight out of script parsing (which follows all-caps screenplay convention for speaker tags) verbatim, instead of the properly-cased name already sitting in the Library. Now prefers the Library's own casing, falling back to a simple per-word title-case for the rarer speaker key with no Library match, instead of shouting-case either way. The per-character colored name text (independent of this) has also been removed — names use the normal (theme) text color again, with the color cue staying on the avatar/border instead. --- ## [1.17.79] — 2026-07-25 ### Added - **Shift-click range select in the Voice Library table** — click one row's checkbox, then shift-click another, and everything in between gets selected/deselected to match, the same convention as a file manager. Checking dozens of rows one at a time for a bulk action (Delete, Set tag, Benchmark, …) was the alternative. ### Fixed - **Exporting a full audiobook re-synthesized every single line from scratch via TTS, even ones already pre-synthesized by "Synth all"** — silently redoing work that was already done, easily 20+ minutes for a real novel with no visible sign anything had gone wrong. Confirmed as the actual explanation behind "clicked Audiobook and got no file": it wasn't stuck or failing, it just had a lot of unnecessary work left to do. Already-synthesized, non-stale lines are now reused directly from the Stage's own synth cache. - **The Stage page-view button (A4 pages / Scroll / PDF pages) labeled itself with whatever mode was already active**, so it read as a passive status indicator rather than something clickable — confirmed live as genuine confusion: stuck in "Scroll" (one continuous page, no page breaks) with a button that said "Scroll" and nothing hinting that clicking it would do anything. It now shows what clicking it switches TO, the normal convention for a cycle button, with the current mode moved into the tooltip. ## [1.17.77] — 2026-07-24 ### Fixed - **Bulk "Auto-design voices" (and Assign/Generate images/Fix wrong-language voices) marked every just-created voice as "deleted from the Library — please reassign" the instant the run finished**, even though the voices were sitting right there. These bulk actions re-render the character table immediately after finishing, but never refreshed `window._voices` (the in-memory voice list, last loaded whenever the Voice Library page itself was visited) — so a voice created moments ago during the SAME run was judged against a stale snapshot that didn't know it existed yet, and got flagged as missing. Confirmed live: after deleting a batch of bad-accent voices and re-running Auto-design, every freshly-designed replacement showed the "missing voice" warning icon immediately, even though `/api/voices` already listed them correctly — a fresh page reload alone was enough to make the warnings disappear, confirming this was a stale-cache display bug, not a real data problem. The voice library now reloads right before that final re-render. - **Reusing a voice from "the same character elsewhere" (the fast path bulk designs use to keep a recurring character's voice consistent across a series) could hand out a voice that had since been deleted from the Library**, if some OTHER character record still referenced it and was never cleaned up. Now checks the voice still actually exists before treating it as reusable, same as the Library's own "missing voice" indicator already does everywhere else. ## [1.17.75] — 2026-07-24 ### Fixed - **The character-sheets progress box was back to leaving a huge empty area below its content, this time regardless of how much had actually been generated.** The previous fix (making the box shrink-to-content via `flex: 0 1 auto`) turned out to fight its own direct child, `.cs-progress-layout`, which is itself `flex: 1` (meant to fill whatever height the box has) — that made the box's "content size" circular, since it had no real minimum to shrink to, and it silently kept resolving to the full 90vh cap no matter how little content existed yet. Confirmed live: a result with only 8 characters found still stretched the box to the exact viewport-height ceiling, leaving a large empty gap below the small amount of real content. Reverted the box itself to `flex: 1` (fill the section, no gap below it — that was never actually the wrong part) and left the short-content problem to the preview/output split fixed earlier in 1.17.66, which doesn't have this circular-sizing conflict. Verified live at a realistic tall viewport: a short 8-character result no longer stretches the box, and a full 72-character list still scrolls correctly with no regression. ## [1.17.74] — 2026-07-24 ### Added - **Narrator paragraphs on the Stage page now have their own play button**, matching dialogue lines — previously only dialogue lines (`.reh-block`) had a play/pause control; narration paragraphs (`.reh-action-block`) had an edit button and a pre-synthesized dot but no way to play them individually. Deliberately kept plain rather than reusing dialogue's boxed/avatar/name-row treatment — just a small icon before the paragraph text, so narration keeps reading like narration instead of being visually pulled into a quote-like card. Reuses the same click wiring as the dialogue play button (`.reh-line-play-avatar`), so it needs no new playback logic. Verified live: clicking it correctly sets the active line and starts playback, same as a dialogue line's own button. ## [1.17.73] — 2026-07-24 ### Fixed - **The Stage/Perform & Export character sidebar never showed portrait thumbnails, even though the exact same characters show real photos everywhere else (Voices phase, Library).** Root cause: `renderCastList` (the "Who's playing which character?" panel) and `renderCastStrip` (the Stage sidebar) share ONE cached fetch of the book's character records, guarded by a single "already fetching this book" flag — but only `renderCastList` re-rendered itself when that shared fetch resolved. Entering Perform & Export borrows both panels at once, and `renderCastList`'s guard check usually won the race, silently claiming the fetch and leaving `renderCastStrip` with no way to know the data (with portraits) had actually arrived — it stayed on plain colored-letter dots forever, even though the cache had genuinely finished loading moments later. Both renderers now re-run whenever the shared fetch resolves, regardless of which one triggered it. Verified live: a 72-character sidebar went from 0 portraits to 29 real portraits rendering automatically on first entry, no manual action needed. ### Changed - **Character thumbnails in cast/roster lists (Stage sidebar, casting sidebar, recast picker) now show a 2px border in the character's own cast color**, matching the color-coding the plain letter-dot fallback already had — a real photo no longer loses that at-a-glance color identity in a long list. ## [1.17.72] — 2026-07-24 ### Fixed - **Batch-benchmarking a group of designed voices reported every single one as an error**, even though the voice_design engine itself was reachable the whole time. Root cause was server-side this time, in `core/tts_helpers.py`'s `_tts_benchmark_request`: it always built a generic voice_clone-style request against the one fixed TTS URL, with no awareness that a designed voice (no reference WAV) needs a completely different endpoint and request shape — the same one `/api/tts-preview`'s `backend=='voice_design'` path already uses successfully. Confirmed live: benchmarking a real designed voice ("EN_M_Junker") directly via `/api/voices/benchmark` failed with the voice_clone connection error until this fix, then succeeded (rtf 0.8, ok) once it routed through the correct engine. `_benchmark_voice` now checks each voice's own metadata (`origin`/reference-audio presence) before benchmarking and dispatches accordingly — genuinely cloned voices are unaffected and still correctly report the real voice_clone-backend outage rather than silently succeeding. ## [1.17.71] — 2026-07-24 ### Fixed - **The Voice Library table's own "Generate and play TTS preview" button had the same wrong-backend bug as 1.17.69's character-preview fix, in a different file.** It always synthesized through whatever engine the page-wide "Library TTS backend" selector happened to be set to — a deliberate override meant for bulk actions like Benchmark/Precompute where testing everything against one chosen engine on purpose makes sense — but applying that same override to a single voice's own row meant a designed voice failed outright unless the user had separately remembered to flip that selector to Voice Design first. Confirmed live: with the selector left on its voice_clone default, "EN_M_Junker" (a designed voice) failed with a voice_clone connection error even though Voice Design was reachable the whole time. Cloned voices still honor the selector (several backends can legitimately play a reference-WAV voice), but a designed voice's preview now always uses Voice Design regardless of what the selector is set to. Verified live: the same voice now plays successfully via "8021 Voice Design". ## [1.17.70] — 2026-07-23 ### Fixed - **Sparse/minor characters in an all-German (or any non-English) book could get auto-designed as English voices** — confirmed live on a real book: "Junker", "Kroah", "Leonardo" and a few other minor characters got British-flagged EN voices while every other character in the same batch correctly got DE. Root cause: `detectLang()` (utils.js) is documented to return `''` when there's too little text to be confident, and every one of its callers already treats a falsy result as "couldn't tell" and falls back accordingly — but the function itself violated its own contract and silently returned the literal string `'English'` instead. Since that's truthy, `_resolveBookLang()` (library-characters.js) — which exists specifically to catch exactly this case via a per-book majority-vote fallback across a character's siblings — treated the bogus 'English' guess as a confident, final answer and never got to use its own fallback. `detectLang()` now actually returns `''` on weak signal as documented. Voices already designed with the wrong language before this fix keep their existing (mistagged) id and aren't renamed automatically — only new auto-designed voices from here on are affected. ## [1.17.69] — 2026-07-23 ### Fixed - **A character's voice preview (▶ in the Cast table/profile) always tried to play through the voice_clone engine, even for a designed voice that has no reference WAV to clone from at all.** This "worked" only by coincidence whenever the voice_clone backend happened to be reachable, and broke for every voice — cloned or designed — the moment it wasn't, even though the designed voice's actual engine (voice_design) was up the whole time. Confirmed live: with the voice_clone backend down, previewing a designed voice ("Zerwas") failed identically to a real cloned voice ("Narrator"), even though voice_design itself responded fine directly. Now resolves the correct engine per voice (`origin === 'designed'` or no reference audio → voice_design, otherwise voice_clone) instead of hardcoding one for every voice. Verified live: the designed voice's preview now plays successfully; the genuinely cloned voice still correctly fails while its own backend is down, with no change needed there since that's a real infrastructure outage, not a code bug. ## [1.17.68] — 2026-07-23 ### Changed - **Concept art is now a full-width banner below the header instead of a small 110px thumbnail squeezed in next to the avatar** — too small to make out any real detail in a multi-pose design sheet. It now gets its own full-width section right under the header, sized up to 70vh, with the "Generate"/"Regenerate" button moved to a label row above it. - **The Perform & Export (Stage) character sidebar now shares the exact same search/sort styling as the Characters phase's sidebar.** Both already reused the same `.ab-cv-side` container, row markup, and collapse-to-avatars behavior, but Stage's search box and sort dropdown used their own separate, slightly different-looking CSS (`.reh-cast-side-tools` with bare, unclassed `input`/`select`) instead of the shared `.ab-cv-side-search`/`.ab-cv-side-sort` classes — a leftover inconsistency from when the two sidebars were built at different times. Switched Stage's markup to the shared classes and removed the now-unused duplicate CSS. Verified live: collapse/expand still works correctly on both. ## [1.17.66] — 2026-07-23 ### Added - **Concept art is now generated automatically for every character, and shown right in their profile.** Previously, the Concept Art Prompt (an auto-generated NPC design-sheet description) only ever produced text sitting in the Generation Prompts section — turning it into an actual image required noticing it, scrolling down, and clicking "Generate Concept Art" per character. The same background pass that already fills in `silly_tavern_prompt`/`concept_art_prompt` for a freshly cast book now also generates the image itself for every character with a prompt, right after casting finishes — no manual step needed. The result is displayed prominently in the character's profile page, right next to the avatar in the header, with click-to-enlarge and a "Neu generieren" button for regenerating it by hand at any time. Verified live: a real character ("Admiral Sanin") went from an empty "Kein Konzeptbild" placeholder to a generated design-sheet image shown in the header, openable full-size. ## [1.17.65] — 2026-07-23 ### Fixed - **The voice picker ("Auswählen" on a character card) could permanently show "No voices found" even though the voice library had loaded moments later** — it snapshotted `window._voices` once when opened and never looked again, so opening it before the library's background fetch finished (most likely reachable via Studio's Voices phase, which borrows the character grid without itself triggering a voice-library load) left it stuck empty for the rest of that popup's life, with no way to recover short of closing and reopening it. It now kicks off (or reuses) the voice-library load itself when it opens empty, and re-renders the list once that resolves. Verified live: a picker opened against a deliberately delayed/empty voice list showed "No voices found" and then correctly populated with all voices once the load completed, with no user action needed. ## [1.17.64] — 2026-07-23 ### Fixed - **1.17.60's `flex: 1` fix for the below-card gap created its own empty-space bug**: with a real, populated character list next to a short/not-yet-filled character card (e.g. right after selecting a character whose sheet hasn't generated much yet), the sidebar's genuine need for height (to show many rows with a working scrollbar) stretched the *preview panel* to match via the grid's `align-items: stretch`, leaving a large empty box below the short card's actual content — confirmed live with a 72-character sidebar next to a two-line preview (296px box, only 63px of real content). Root cause: `.cs-progress-preview` had `flex: 1`, forcing it to grow and fill whatever height its sibling column demanded, even when it had nothing to show there. Changed the preview panel to shrink-to-its-content (floored at its existing 220px minimum, so it doesn't look collapsed when truly empty) and let `.cs-progress-output` (the passage/live-output pane, which already scrolls its own content and existed for the "growing" case anyway) absorb any leftover column height instead. Also confirmed (separately, via the same live test) that `.cs-progress-box-big`'s own height needed an explicit `height: auto` override — a more specific scoped rule that only set `flex: 0 1 auto` was silently losing to an unscoped, unrelated rule's explicit `height` elsewhere in the stylesheet, since CSS cascade resolves per-property, not per-rule. Verified live: preview panel now matches its actual content instead of stretching, while a 72-character sidebar list still scrolls correctly (2526px scrollHeight vs. 454px clientHeight). ## [1.17.61] — 2026-07-23 ### Fixed - **Clicking a character in the sidebar no longer scrolled to their actual first line** — a real regression from the earlier `content-visibility: auto` performance fix (1.17.49). The selection/data logic itself was correct (confirmed live: the right dialogue row was genuinely focused), but off-screen pages' heights are only an ESTIMATE (`contain-intrinsic-size`) until the browser actually measures them, so a single `scrollIntoView()` computed against a target many pages away could land wherever the sum of all those estimation errors put it — confirmed live at over 4000px off-screen. Every `scrollIntoView` call in the casting feed now does an instant rough pass followed by a corrected smooth pass once the browser has actually laid out whatever came into view, without giving up the original performance win. Verified live: the target row went from ~4098px off-screen to correctly positioned inside the visible feed. ## [1.17.60] — 2026-07-23 ### Fixed - **Still wasted empty space below the character-sheets card after 1.17.59** — that fix matched the two internal columns to each other, but the whole card was still capped at a fixed viewport-relative height, while its section (`#s-reader`/`#s-caststudio`) is a fixed, full-height flex column regardless (needed for other content like the PDF reader). Any time the card's own capped height came in under the section's real height, the leftover section space showed up as dead area below the whole card, not just between its columns. Switched to `flex: 1` (the same pattern `#reader-main-view` already uses under these same two sections) so the card actually claims whatever height the section provides instead of guessing a fixed number. Verified live: section/panel/box all measure identically now (747px), with zero leftover gap. ## [1.17.59] — 2026-07-23 ### Fixed - **The 1.17.56 sidebar-scrolling fix introduced a new mismatch: a large empty gap below the shorter of the two columns.** Giving the sidebar a fixed height cap independent of the main passage/output column meant the two no longer matched — when the passage side was short (e.g. still on passage 1, or mid network-error-retry) while the sidebar's cap stayed constant, the shorter column left dead space below it. Replaced the fixed cap with the same bounded, viewport-relative height the floating-overlay version already uses, so both columns stretch to match the SAME real height via the grid and scroll independently within it — no constant on either side to drift out of sync with the other. Also removed an explicit `height: 100%` on the sidebar that created a circular sizing reference against the grid's own auto-sized row (a child asking for "100% of a row whose height is itself content-derived" resolves back to the child's own content size, not the row's actual height) — grid's `align-items: stretch` already gives it a correct, definite height with no percentage math involved. Verified live: both columns now measure identically (641px each) instead of one leaving unused space. ## [1.17.57] — 2026-07-23 ### Fixed - **Every line in the casting feed could get silently rendered twice** — the same segment appearing as two separate (but identical, same underlying object) DOM rows. Confirmed live: a character's line count in the top per-character navigator ("bar-pos") read exactly double what the sidebar correctly showed, and clicking a character in the sidebar no longer scrolled to their actual first line, since the duplicate rows threw off which DOM node was really "first" in reading order. Root cause: the feed's chunked redraw had no protection against two overlapping calls — each one's own in-flight batch loop kept running independently of the other's `feed.innerHTML = ''`, so two legitimate (non-recursive) triggers close together both ended up appending their own full copy of every row. A generation token now lets an in-flight redraw notice it's been superseded by a newer one and stop instead of racing it. Verified live: a 1598-segment feed went from 1500 (partially duplicated, inconsistent) rows to exactly 1598, with a 1:1 match between DOM rows and segments for every character checked. ## [1.17.56] — 2026-07-23 ### Fixed - **The character-sheets generation progress view's own "Characters found" sidebar had the same no-scrollbar symptom as 1.17.55, but a different root cause.** When this panel is embedded inline in the page (Read Aloud / Studio) rather than shown as a floating overlay, its outer box deliberately gets `height: auto` so it grows naturally with the page — but that removes the one bounded ancestor the sidebar's whole flex/overflow chain needs to scroll internally. Gave the sidebar its own independent height ceiling for this specific context, regardless of what the main passage/output column is doing. Verified live with a real 72-character list: went from zero usable height to scrolling correctly within a bounded ~640px box. ## [1.17.55] — 2026-07-23 ### Fixed - **The "Characters found" sidebar on the casting view had no scrollbar**, leaving a large mismatched empty gap in the feed column next to it. `.ab-cv-chars` (the scrolling list itself) was missing `flex: 1` — without it, it just sized to its own content like any other block element, so its own `overflow-y: auto` never had a bounded height to actually scroll within; the parent's `overflow: hidden` just silently clipped it with no scrollbar shown, while the grid row stretched to match the unbounded content anyway. Verified live: a 72-character list went from growing past its container (scrollHeight 3452px vs. a 404px box, no scrollbar) to scrolling correctly within it. ## [1.17.54] — 2026-07-23 ### Added - **Concept art can now actually be generated as an image**, not just text. The "Character Concept Art Prompt" box previously had no image action at all (only Copy/Regenerate for the prompt text itself) — added a "Generate Concept Art" button that calls the same image-generation pipeline as the profile portrait, storing the result alongside the prompt (never overwriting the character's main portrait) and showing it inline in the box. ### Changed - **Rewrote the concept-art prompt** to follow a structured Task/Subject/Context/Style/Composition/Lighting/Constraints/Output pattern (adapted from a user-supplied reference), producing a proper NPC/character reference sheet: full-body front/side/back views plus an isolated callout row for the character's own established props/costume pieces (only when the profile actually has any — no invented gear), with explicit genre/era grounding and an original-character/no-copyright disclaimer. Verified live against a real character — produces exact prop counts and setting-appropriate detail instead of the previous generic single-sentence version. ## [1.17.53] — 2026-07-23 ### Changed - **Rewrote the "SillyTavern Character Prompt" generation.** It was only ever built as a few short labelled lines (Description/Personality/Scenario/First message/Example dialogue) via a generic instruction — now generates a full structured SillyTavern character card (Personal Information, Appearance, Personality, Likes, Dislikes, Goals, Skills, Weapons, per-occasion Outfits, gender-aware), followed by an open-ended Scenario block and a First Message, adapted from a user-supplied prompt template. Since the character's full profile is already known from the book (unlike the original template, written for building a card from scratch via internet research), the model is told to use ONLY that profile instead of searching fandom/wikipedia, marking anything genuinely inferred with a trailing `*`. Bumped this field's own token budget (1536 → 3000) since a full card no longer fits the old ceiling. ### Known limitation - The local model sometimes stops right after the Scenario block and skips the First Message despite an explicit instruction to include it — a model-following limitation, not a formatting bug. Worth another look if it's consistently a problem. ## [1.17.52] — 2026-07-22 ### Fixed - **Found and fixed a real infinite-recursion bug introduced in 1.17.45**, confirmed live via the browser's own renderer process sitting pegged at steady high CPU (not idle — a genuine runaway loop, not a hang): `audiobookCastView()` calls `navReaderView('cast')` (an alias for `showReaderView`) as part of its own normal setup — which is exactly the function 1.17.45 wrapped to keep Studio's sub-tabs in sync. That wrapper called `_stuShowCastView('identify')`, which calls `audiobookOpenCastView()`, which calls `audiobookCastView()` again, which calls `navReaderView('cast')` again — forever. This reliably reproduced entering Studio's Characters tab on a book with an existing cast. Fixed with two guards: never re-enter the wrapper while already inside a call it triggered, and skip entirely once Studio's sub-tab already matches the requested view (the overwhelmingly common case this was needlessly re-triggering on every call). Some, but likely not all, of the freezes reported since 1.17.45 were probably this. ## [1.17.51] — 2026-07-22 ### Fixed - **Extended the content-visibility fix to the character-cards grid** (`.lib-char-card`, shared by Library's Cast page and Studio's Characters tab — same borrowed elements, not separate code). A production with dozens of characters, each carrying a generated portrait image, stayed fully laid out and painted at once with no virtualization — the same category of issue just fixed for the casting view, likely also in play during bulk profile/portrait/voice generation across a large cast. Same proven technique, same guarantee: off-screen cards stop costing layout/paint, nothing else changes. ## [1.17.50] — 2026-07-22 ### Fixed - **Reproduced and fixed the actual cause of the recurring tab freeze/crash**, verified live: restoring a fully-cast, 235-page book kept all ~40,000 DOM nodes (every narration/dialogue row for the whole book, by design — so the full cast stays available for review) fully laid out and painted at once, even the ~99% currently scrolled out of view. Confirmed measurably: before this fix, scrolling the restored view took real, non-trivial time; after, scrolling to the very bottom or top of a 1598-segment cast is instant (0ms). `content-visibility: auto` (the same technique already used elsewhere in this app for other large lists) now lets the browser skip layout/paint work for off-screen page groups entirely — the data and every existing feature (search, click-to-edit, page navigation) are completely unaffected, since the nodes are still there, just not actively rendered while off-screen. ## [1.17.49] — 2026-07-22 ### Fixed - **Casting a long book could hang and crash the tab partway through** (confirmed live: crashed around page 33 of a 235-page book, 1598 segments in). Two compounding O(n²) costs in the live casting view: (1) the feed's own housekeeping ran a full `querySelectorAll` over every row/note/divider in the whole book-so-far on every ~80-segment batch, for a trim limit that's permanently disabled (infinite) — pure wasted work that grows with the book; (2) the autosave fired after every single chunk, re-stringifying and both localStorage-writing and network-sending the ENTIRE accumulated segments array every time, also growing with the book. Both scale with total segments cast so far, not a flat per-page cost, so the first ~30 pages felt fine and it degraded from there. The feed query is now skipped entirely since it could never do anything anyway, and autosaves are throttled to at most once every 4 seconds (always still saving on completion, so nothing is lost at the end of a run). ## [1.17.48] — 2026-07-22 ### Fixed - **PDF zoom hang persisted even with the render timeout from 1.17.47.** That confirms the hang is likely a synchronous main-thread block (e.g. PDF.js decoding an embedded image at its own native resolution before any downscaling) — nothing awaitable, including a timeout, can rescue a hang that already owns the thread before our code gets a chance to run. Since shrinking the *output* canvas doesn't help if the cost comes from the *source* image's own resolution, the safety budget is now much smaller (~2MP / 1800px per side, down from ~6MP / 3000px) so the requested render scale for an oversized page stays low enough to avoid the pathological decode in the first place, regardless of the zoom level shown in the UI. ## [1.17.47] — 2026-07-22 ### Fixed - **The previous canvas-size clamp (1.17.46) alone wasn't enough to stop a page hanging the tab.** A single oversized dimension can be a problem even under the area budget on some browsers, and — more importantly — the page render itself can still hang regardless of the requested output size (e.g. an embedded image codec that decodes at native resolution before any downscaling happens). Added a hard per-side cap alongside the area budget, and — the actual guarantee this time — a timeout around the render call itself: a page that doesn't finish rendering within 15s is cancelled and left for a later retry (e.g. scrolling away and back) instead of hanging the tab indefinitely. ## [1.17.46] — 2026-07-22 ### Fixed - **Zooming a PDF page in (a cover page in particular) could hang and then crash the entire browser tab** (Chromium "Aw, Snap! SIGTRAP"). Page canvases were sized directly from page size × zoom with no upper bound — a page with an unusually large intrinsic size (some scanned/cover pages) at ~400% zoom could demand a multi-hundred-megabyte backing buffer. The actual rendered resolution is now capped to a safe pixel budget regardless of zoom or page size; the canvas's on-screen (CSS) size still matches the requested zoom exactly, so a single oversized page just renders a touch softer instead of crashing the tab. ## [1.17.45] — 2026-07-22 ### Fixed - **"New recast (discard & rebuild all)" (and other actions that call `showReaderView` directly) left Studio's Characters tab completely blank**, with the LLM pass clearly running in the background (GPU busy) but nothing visible at all. Studio shows/hides the borrowed casting panels one level up via its own slot visibility, in lockstep with its "Identify Characters"/"Cast Characters" sub-tabs — but `showReaderView('cast'|'chars')` toggles `.hidden` directly on the panels themselves, one level down, with no awareness of Studio's slots. If the two disagreed (e.g. recast was triggered while on the wrong sub-tab), the panel actually holding the live progress UI ended up hidden inside its slot while the other, now-empty panel's slot was the visible one — a blank screen with real work happening underneath. `showReaderView` now keeps Studio's own tab UI in sync whenever this happens. ## [1.17.44] — 2026-07-22 ### Fixed - **Speaker attribution left too many lines as "Unknown"** on cues a human reader would catch instantly: self-introductions ("Man nennt mich Andra"), and idiomatic narration that doesn't use a literal speech verb ("Marcian fand als erster seine Stimme wieder", "ihre ersten Worte waren..."). The LLM attribution prompt now has explicit rules for both (self-introduction, and a widened voice-announcement rule covering these idioms), and the deterministic code-level fallback — which already exists because the LLM alone measurably still misses ~44% of mechanical patterns even when the prompt spells them out — now catches these same patterns directly instead of only literal speech-verb tags. ## [1.17.43] — 2026-07-22 ### Fixed - **PDF import could get permanently stuck on one page** ("Reading PDF… page 224/235" that never advances). The per-page text/heading extraction loop had no timeout anywhere — a hung (not rejected, just never-settling) page render or Tesseract OCR call, most likely on an image-heavy or blank page, silently stalled the entire sequential import with no error and no way to recover short of reloading. Page render, heading OCR, and plain text extraction now all race against a timeout and skip to the next page (logging a warning) instead of hanging forever. ## [1.17.42] — 2026-07-21 ### Fixed - **"Voice design failed... produced broken audio after 3 attempts" fired on perfectly good voices.** The retry logic's "bad" check included the benchmark's `realtime_ok` flag, which also goes false whenever the designed voice's own reference audio (built from the character's sample text — often a real book quote) simply runs longer than 25 seconds, a length/performance advisory with nothing to do with audio corruption. Since sample length barely changes between retries, this deterministically failed all 3 attempts for any character with a longer line, wasting 3 generations and then refusing to assign any voice at all. Only real defects (synthesis error, output clipped at the max-duration guard, implausible wpm) count as "bad" now. ## [1.17.41] — 2026-07-21 ### Added - **Per-book context (genre, setting, era, language)**, editable via a new "Context" button on each production — e.g. "High fantasy, like Lord of the Rings, medieval times, German". Feeds directly into every voice design prompt for that book, and is now the authoritative language source for language-matching/mismatch checks, instead of guessing from one character's own (often sparse) sheet text. ### Fixed - **Bulk "Auto-design voices" silently did nothing for characters whose name is a known alias of another character** (e.g. "Garbaz" as an alias of "Arthag") — the save step's identity-matching redirected the update onto the alias's canonical record instead of the character actually selected, so the selected row never got a voice while a seemingly unrelated character's voice silently changed instead. This is a known limitation of the alias-based identity system, not yet fixed at the root — but is now at least understood; a real fix needs to distinguish "explicit per-card action" writes from freeform sheet-merge writes. - **Sparse/minor characters ("Bote", "Frau", "Mann", generic crowd roles) with no descriptive sheet text kept getting voices designed in English by default even in all-German books**, because the per-character language detection this relied on has nothing to detect from on a near-empty sheet. Now falls back to a majority vote across the character's own book siblings, and — with the new book context above — to an explicit setting first. - **A voice that failed the benchmark on all 3 retry attempts was still committed as the character's final voice anyway** (with only an easy-to-miss toast), which is exactly how several visibly-broken voices (0.02x factor, 1875 wpm) ended up in the library despite the retry logic added in 1.17.36. It now fails the character outright instead — leaving whatever voice it had before (or none) untouched — rather than silently swapping in audio already proven broken. ## [1.17.40] — 2026-07-21 ### Fixed - **Deleted voices could reappear on their own.** The background voice-index rebuild (a full disk rescan that can take a noticeable while over 100+ voices) replaces the *entire* index with what it found once it finishes — if a voice was deleted while that scan was still in progress, its row was still sitting in the scan's snapshot from before the delete, so the rebuild resurrected it the moment it finished. This got much easier to hit now that voice-design retries poll the backend far more often. The scan now re-checks each file still exists immediately before writing, closing the window down to next to nothing. ## [1.17.39] — 2026-07-21 ### Fixed - **Restarting the TTS backend (including the new automatic post-design restart added in 1.17.36) blocked the entire app server for the whole restart duration.** `/api/tts/restart` and the local-container start/stop/restart endpoints called a raw blocking Docker-socket request directly inside their async route handlers instead of off-thread — with two TTS containers configured, that's up to ~20s where *every* other request (including a plain character list fetch) just hung. Confirmed live: switching to Studio's Voices tab during a bulk voice-design run showed "No characters yet" for a book that very much had characters, because its own character fetch got starved by an in-flight restart. All Docker socket calls in these routes now run in a worker thread instead of on the event loop. - **A failed character-list fetch (of any cause) blanked out an already-populated list instead of leaving it alone.** `libraryRenderCharacters()` now fetches before touching the DOM, so a transient failure just leaves the current view in place (with an error toast) rather than replacing real data with an empty state. ## [1.17.38] — 2026-07-21 ### Fixed - **Every action on the Characters/Cast page (remove voice, auto-design, delete, etc.) jumped the page back to the top.** The full-list rebuild those actions trigger briefly collapses the list down to a one-line loading placeholder, which the browser responds to by clamping the scroll position back to fit — confirmed live as a scroll-to-top after every single click. The page's scroll position is now restored once the rebuilt list is back in place. ## [1.17.37] — 2026-07-21 ### Fixed - **Deleting a broken voice from the Voice Library left every character still pointing at it, with no indication anything was wrong.** Character cards, the table view, and the character detail modal now check each assigned voice id against the loaded voice list and mark it red (with an alert icon and an explanatory tooltip) when it no longer exists, instead of silently showing a dead voice id as if it were still valid. ## [1.17.36] — 2026-07-21 ### Fixed - **Designed voices could end up permanently broken (near-silent/truncated output, or absurdly slow to render) with no automatic detection.** Confirmed live across a whole book's cast: some designed voices measured impossible speech rates (6 wpm, 5625 wpm) or failed the realtime benchmark outright. Auto-design/redesign now benchmarks each generation attempt (under a disposable id, never the character's real voice) before committing it, and automatically deletes and retries up to 3 times if the result is corrupted, clipped, or outside a plausible 80–400 wpm range; if all attempts still fail, the character keeps the last attempt and a toast flags it for manual review. - **Overwriting an existing voice's audio during a redesign never flagged the TTS backend as needing a restart**, unlike the equivalent "replace audio" path — so a freshly redesigned voice could silently keep serving the *old* cached reference audio during synthesis, with no warning shown anywhere. `/api/save` now sets the same `needs_tts_restart` flag `/api/voice-replace` already did. - **Restarting the TTS backend after a redesign now happens automatically** instead of relying on the user to notice the warning and click "Restart TTS" themselves — once after a single redesign, once after a whole bulk batch (not per-voice, to avoid restarting a model server dozens of times in one run). ### Security - **Voice reference photos could be silently and permanently overwritten with no backup**, discovered after a real character-portrait image overwrote a cloned narrator's actual reference photo with zero way to recover it. Picture uploads now back up any existing picture before overwriting it (mirroring the existing audio backup pattern), so future overwrites are recoverable even when triggered by a bug. ## [1.17.34] — 2026-07-21 ### Changed - **Voice design prompts now demand specific, differentiating detail instead of generic category labels.** "Young female voice, energetic tone, clear pitch" describes a whole demographic, not a person — confirmed live as the real cause of several same-age/gender characters sounding near-identical. Both the LLM-generated prompt and the client-side fallback (for sparse profiles with none of the richer sheet fields filled in) now push for a specific timbre, pace/rhythm quirk, and character-specific emotional baseline; the fallback also gets a deterministic per-character variety injection so even the sparsest profile differs from every other same-gender character instead of using the identical generic sentence. - **"Design a voice" (single-character, manual flow) now asks before silently reusing a series voice**, with a way to hear the existing voice first — confirmed live as confusing (clicking "design" expecting something new, silently getting a reused voice instead with only an after-the-fact toast). Choice of using the existing voice or designing a genuinely new one. Bulk actions are unaffected — they keep the fast, silent reuse-first behavior a 40-character run needs. ## [1.17.33] — 2026-07-21 ### Fixed - **"Rebenchmark this one" crashed with "Cannot set properties of null (setting 'className')" from the single-voice detail view.** Opening a voice in the inspector physically moves its benchmark chip element out of the row and into the inspector panel — the rebenchmark handler was still looking for it in the old (now-empty) location. Falls back to the inspector's own copy, and no longer crashes if it genuinely can't find either. ## [1.17.32] — 2026-07-21 ### Added - **"Remove voice" button** in the character table — clears the assigned voice, leaving the character unassigned (e.g. before picking or designing a replacement). - **"Fix wrong-language voices" bulk action** — scans every character in a production (no selection needed) for a voice whose language doesn't match the book's own, and designs a properly-matching replacement for each. Cleans up characters assigned before the language-matching fix (1.17.27) existed, or from an older bulk run. - **"Edit voice design prompt" button** — opens the Design a Voice page pre-filled with the character's Voice Design Prompt (name, gender, language, and prompt text), so it can be reviewed/adjusted before regenerating, instead of only ever getting an instant, un-editable auto-design. - **Hovering a character's profile picture now shows a large (512×512) preview** near the cursor — the table/card thumbnail alone (often just 32-40px) gave no real sense of the actual portrait. ## [1.17.31] — 2026-07-21 ### Added - **A dedicated "design a new voice" button in the character table**, for when the current voice just doesn't fit — unlike "Auto" (which prefers reusing an existing/series voice first), this always generates a genuinely fresh voice, bypassing any reuse/match logic entirely. ### Fixed - **The voice play button's horizontal position varied row to row** depending on how long the voice name was, since it sat on the same row as the name. Moved to the second (action) row, so it lines up consistently under every row's voice name regardless of length. ## [1.17.30] — 2026-07-21 ### Fixed - **Long voice names pushed the "Auswahl"/"Auto" buttons out of the visible column in the character table.** The voice name, play button, and both action buttons were all one row in a fixed-width column — confirmed live, a longer voice id left the buttons cut off entirely. Now two stacked rows: voice name + play button on top, Auswahl/Auto underneath. ## [1.17.29] — 2026-07-21 ### Changed - **Voice previews and voice-design samples now include a real line from the book, not just a bare greeting.** The quick preview button ("Hallo, ich bin NAME") and the actual voice-design generation step now both append an actual line the character speaks (from the current audiobook's attributed dialogue, or the character sheet's own quoted sources) when one's available — much better preview of how the voice actually sounds reading the book than a name-only greeting alone. ## [1.17.28] — 2026-07-21 ### Fixed - **"Auto-design voices" silently did nothing for a character that already had a voice.** The series-reuse check (`_findVoiceFromSameCharacterElsewhere`) runs before generation to avoid burning a fresh design call for a recurring character — but since it naturally finds the character's own most-recent record first, clicking design on an already-voiced character just re-saved the identical voice id and returned, never actually generating anything new. Now skipped specifically when the "reuse" would be a no-op (matches what the character already has); genuine cross-book reuse (a distinct voice this record doesn't have yet) still applies as before. ## [1.17.27] — 2026-07-21 ### Fixed - **Auto-designed voices all sounded nearly identical regardless of character.** The shared TTS "stability" defaults (fixed `seed: 0`, `temperature: 0.1`) were applied to voice DESIGN calls too, not just voice CLONE calls — sensible for clone (consistent retakes of the same voice), backwards for design (every call should produce a different voice from a different prompt). Pinning the random draw meant the character's own prompt text became the only source of variation, and low temperature flattened even that. Voice design now uses the backend's natural per-call randomization, same as other creative-voice backends. - **Existing-voice reuse/name-matching ignored language, producing accent mismatches.** Confirmed live: English-designed voices got assigned to German-book characters via name-substring matching. Neither the series-reuse check nor the new name-matching check verified the voice's language (encoded only as an `EN_`/`DE_`-style id prefix, the only place language is ever recorded) against the book's own detected language. Both now skip a same-name match in the wrong language and fall through to designing a fresh one instead. - **Character portrait/profile image prompts had no genre/setting anchor**, so a fantasy book's military-sounding occupations ("Admiral", "General", "Prinz") could render as modern-day imagery. Both the LLM-generated prompt and the client-side fallback now explicitly work out and state the story's genre/era, and instruct against real-world modern anachronisms unless the story is actually contemporary. ## [1.17.26] — 2026-07-20 ### Changed - **"Auto-assign selected" now designs a bespoke voice per character instead of handing out an arbitrary same-gender pick.** Previously it just filtered the whole voice library by gender and grabbed the highest-rated match, with no check that the voice had anything to do with the actual character — confirmed live: a 46-character bulk assign returned existing voices for every single one, several clearly generic. Now: reuse a voice from the same-named character elsewhere in the series (unchanged) → otherwise use a voice already in the library whose own name matches the character's (e.g. "DE_M_Zerwas" for a character named "Zerwas") → otherwise design a new voice from that character's own profile. ## [1.17.25] — 2026-07-20 ### Fixed - **Voices phase could auto-expand the wrong book.** Scoping which book's section shows expanded read `readerState.title`, which is left pointing at whichever book was last opened via the Source tab — not necessarily the book whose cast is actually active. Confirmed live: navigating to Voices via "View cast" from a book 2 casting session expanded book 1's section instead. Now resolves the title from `_audiobook.bookId` (kept correctly in sync with the active cast session) via the server, falling back to `readerState.title` only when no cast session is active. ## [1.17.24] — 2026-07-20 ### Fixed - **"View cast" (Cast ⌄ menu, Characters tab) did nothing when clicked in Studio.** Same root cause as the earlier "Open Script Rehearser" fix: it called `navTo('s-library')` directly, silently swallowed by Studio's nav guard while Studio is the active section. Redirects to Studio's own Voices phase instead, which already shows the same cast/character roster. ## [1.17.23] — 2026-07-20 ### Added - **Studio's phase tabs (Source/Characters/Voices/Perform & Export) didn't read as a sequential flow.** The old cross-section "Previous/Next" stepper was deliberately removed from Studio in 1.17.8 (it only knew about the old sections, not Studio's own phases), but nothing took its place — the only way to move forward was clicking a tab directly, with no visible "what's next" affordance. Each tab is now numbered (1-4), and a Studio-specific Previous/Next button pair sits in the header, disabled at the first/last phase. ## [1.17.22] — 2026-07-20 ### Fixed - **Studio's Characters tab could end up with no navigation controls visible at all.** The relocated dropdown toolbar (1.17.15) only ever hid the plain "Identify Characters"/"Cast Characters" tabs once a footer was found to replace them — a later transient state (e.g. "Checking saved cast before starting a new one…") rebuilds the panel without any footer at all, and since nothing re-showed the plain tabs, confirmed live: neither the dropdown toolbar nor the plain tabs were visible, leaving no way to navigate. The plain tabs now fall back to visible any time there's genuinely no footer to relocate, not just the first time. ## [1.17.21] — 2026-07-20 ### Fixed - **Exported audiobooks were only playable for the first few seconds, despite a correctly-sized file.** `audiobookExport()` synthesized every line as an independent mp3 clip and merged chapters by naively concatenating the raw mp3 byte streams (`new Blob(blobs, {type:'audio/mpeg'})`) — each clip carries its own frame/ID3 headers, which most players decode only the first of before stopping or glitching (confirmed live: an 85MB export that reported as 22 seconds playable). Also confirmed the per-line mp3 encoding was never given an explicit bitrate, silently falling back to ffmpeg/lame's low default (32kbps) rather than any bitrate this app actually chose. Fixed at the source: lines now synthesize as lossless WAV, merge properly per chapter via the same correct PCM-concatenation helper already used elsewhere (`mergeWavBlobs`), and get one real mp3 encode pass server-side at an explicit 96kbps (matched to the engine's native 24kHz mono output — higher would just be wasted file size, not more real quality) via a new `/api/audio/encode-mp3` endpoint. ## [1.17.20] — 2026-07-19 ### Fixed - **Narrator's voice never reached synthesis even when properly assigned in the Library.** "Synth all" in full-audiobook mode silently synthesized only the ~380 dialogue lines instead of all ~1325 narratable lines, with no error — the narrator's cast slot uses an emoji-prefixed sentinel key internally (`📖NARRATOR`), and `rehApplySharedCast()`'s roster lookup lowercased that whole sentinel (`"📖narrator"`) instead of translating it to the library's plain `"narrator"` key first, so the match always silently missed and the library's assigned voice never populated `rehState.narratorVoice` — the exact field `synthAll()` checks to decide whether to include narration at all. ## [1.17.19] — 2026-07-19 ### Fixed - **Reverted 1.17.16 through 1.17.18's timeout tuning on the character-sheets fallback request — it was solving the wrong problem.** Confirmed by comparing against a still-running, still-working session on a different book: the streaming endpoint never delivers a single visible delta for this generation (reproduced identically at 60s/300s/560s — not a matter of waiting longer), and the plain fallback request reliably completes in a couple of minutes *when left alone*. Adding a timeout to that fallback (as 1.17.16 did) meant every abort orphaned a thread holding the shared LLM lock (the underlying call can't be interrupted by a dropped connection), so the next attempt just queued up behind it and repeated the same doomed cycle — the timeout was actively causing the stall it was meant to prevent. The fallback is unbounded again, matching the working reference behavior; the stream's own idle-timeout is back to 60s since there's nothing to gain from waiting longer on an endpoint that never streams anyway. ## [1.17.18] — 2026-07-19 ### Fixed - **1.17.17's 300s timeout still aborted a genuinely-completing character-sheet passage, twice in a row — including immediately after a full restart with no pre-existing stuck threads.** Confirmed live: this client-side abort doesn't actually stop the server from working — the backend's upstream call sits in an uninterruptible blocking socket read, so the abort just orphans a server-side thread that keeps holding the shared LLM lock until its own ~600s watchdog force-closes it, and the next retry then queues up behind that same abandoned thread. A short client timeout doesn't recover faster here, it actively makes things worse. Both timeouts raised to 560s — close to, but safely under, the server's own watchdog ceiling — so aborting stays a last resort instead of routine, and any abort that does happen leaves the orphaned thread already near its own expiry. ## [1.17.17] — 2026-07-19 ### Fixed - **1.17.16's timeout fix was too aggressive and made things worse.** Confirmed live via the LLM server's own logs: a single character-sheet passage can run continuously for 4.5+ minutes with steady output the whole time — it was never stuck, it just doesn't emit a visible streamed delta until reasoning is done. The 60s/90s timeouts from 1.17.16 were aborting genuinely-succeeding generations mid-flight, discarding real progress and retrying from scratch — which looks identical to a real stall (repeated timeouts, zero progress) from the outside. Both timeouts raised to 300s, comfortably above the observed real duration and still safely under the server's own ~600s watchdog. ## [1.17.16] — 2026-07-19 ### Fixed - **Character sheet generation could freeze indefinitely on one passage with zero feedback.** Confirmed live: stuck on "Passage 2/114…" for 7+ minutes with no new requests, no error, no retry. Root cause: the streaming request has its own 60s idle-timeout, but the non-streaming fallback it falls back to on a stream failure had no timeout at all — when the backend's LLM lock was held by an abandoned stream thread stuck in a blocking socket read (a pre-existing server-side condition, only self-healing via a ~600s watchdog), the fallback request just hung for up to 10 minutes instead of failing fast into the existing retry-with-backoff logic. The fallback now aborts after 90s and retries like any other network error. ## [1.17.15] — 2026-07-19 ### Fixed - **Studio's Cast Characters tab pointed at the wrong pass entirely.** There are two unrelated things in the old app both loosely called "casting": dialogue speaker-attribution (Identify Characters tab, already wired) and passage-by-passage character PROFILE generation (appearance/backstory/voice notes, with live "Passage N / M…" progress and a per-character progress sidebar) — the old page's optional WF_STEPS step 3. An earlier fix (1.17.4) redirected this tab's empty-state button at the speaker-attribution menu instead, so it never actually offered profile generation at all, appearing to just go blank. Now calls the real entry point (`csForReader()`) directly, rendering the same live-progress UI the old page has, inline. ## [1.17.14] — 2026-07-19 ### Fixed - **Unknown-speaker dialogue whose preceding narration named the speaker with a plain-period inquit ("Garbaz rief von unten herauf.") stayed unresolved.** `audiobookResolveUnknowns`'s existing rules only recognized a preceding inquit when it ended in a colon ("... sagte:") or when the speech tag came right after the quote — a Name + speech-verb sentence ending in a normal period, especially with extra words between the verb and the period ("...Ork legte den Kopf in den Nacken und schrie seinen Triumph zum Himmel."), matched neither. Added a third rule that checks the preceding narration's last sentence against the actual character roster (not a generic capitalized-word guess), so multi-word names/aliases resolve correctly too. ## [1.17.13] — 2026-07-19 ### Fixed - **LLM casting could leave real dialogue lines completely unattributed right next to correctly-cast ones in the same passage.** Confirmed live: "»Ich glaube, ich bin in dich verliebt.«" got correctly split and attributed to Alrik, while "»Halt, bleib stehen.«" and "»Ich liebe dich«" a few lines later stayed silently merged into narration with no speaker at all — same passage, same `»...«` markers. The deterministic backfill that's supposed to catch dialogue the LLM misses only checked "did this chunk produce *any* dialogue at all" (`.some()` over the whole chunk), so it was satisfied by the first correct split and never re-examined the rest. Every leftover narration segment is now individually re-scanned and re-split. Already-cast passages need "Continue uncasted" or a targeted recast to pick this up — it doesn't retroactively repair segments already saved to a draft. ## [1.17.12] — 2026-07-19 ### Fixed - **No visible way to import a document after deleting the current one.** The paste/drag-and-drop import controls live inside the "Voice & synthesis settings" card, which is collapsed by default — fine once a document is loaded, but confirmed live as a dead end right after deleting the current book: an empty page with nothing indicating the import controls were hidden inside that collapsed card above it. The card now force-expands whenever no document is currently loaded (without touching a user's own collapse preference once one is). ## [1.17.11] — 2026-07-19 ### Fixed - **Narrator scare-quotes (›Zelt‹) got split out as speakerless "dialogue" lines.** German prose uses single guillemets ›...‹ for the narrator ironically/emphatically quoting a word within narration (e.g. "...die die Orks aufgeworfen hatten. ›Zelt‹ war eine sehr schmeichelhafte Bezeichnung..."), distinct from »...« for actual spoken dialogue — but the deterministic quote-splitter treated both the same way, producing standalone "Unknown"-speaker fragments for just the quoted word (confirmed live on book 2: "Zelt", "verlausten Pony", "Skipperedikt", "Seulaslintan", among others). Single guillemets are no longer treated as a dialogue delimiter anywhere in the casting pipeline. Already-cast books need their affected passages recast (e.g. "Continue uncasted" or a full recast) to pick this up — existing casts aren't retroactively repaired. ## [1.17.10] — 2026-07-19 ### Fixed - **Relocated Cast Characters toolbar (1.17.9) reappeared as a stale duplicate at the bottom after any in-panel edit.** `audiobookCastView()` rebuilds the whole panel (a fresh `#ab-cv-foot`, same id) on every call, not just the first — confirmed live: after using "Split text to Unknown Speaker" to edit a line, a second, un-relocated footer (with "Open Script Rehearser" showing again) appeared back at the bottom while the relocated copy stayed at the top. Replaced the one-time move with a `MutationObserver` that catches every rebuild, not just the first. - **Leaving Studio for the old Read Aloud page would come back missing its own footer.** The relocated toolbar was never part of the whole-panel borrow Studio already tracks for returning things to where they came from — it's now explicitly restored inside the panel before the panel itself goes home. ## [1.17.9] — 2026-07-18 ### Fixed - **Character portraits fetched and embedded as raw base64 on every bulk character-list load, blocking rendering with no feedback.** `/api/characters` (used to build the Cast Characters grid, the Library grid, and elsewhere) returned every character's full base64 portrait inline; for a book with dozens of generated portraits this made the JSON payload and the resulting `innerHTML` write tens of megabytes, so Studio's Cast Characters tab could sit completely blank for a long time with no spinner — easy to mistake for broken. The list endpoint now returns a lightweight `/api/characters/{id}/image` URL instead of the raw blob (single-character fetches for editing are unaffected). Guarded the database write path too: if a record round-tripped from the list ever comes back through a save with that placeholder URL still in its `image` field, the existing stored portrait is now preserved instead of being overwritten. - **Studio's Characters tab had a redundant, non-functional action row.** The plain "Identify Characters"/"Cast Characters" tabs at the top just duplicated the labels of a richer dropdown toolbar (Identify Characters ⌄ / Cast Characters ⌄ / Cast ⌄) sitting at the bottom of the borrowed panel, and that toolbar's "Open Script Rehearser" button silently did nothing when clicked from inside Studio (its `navTo('s-rehearser')` call was swallowed by the 1.17.8 nav guard, with no error shown). The dropdown toolbar is now relocated up to replace the plain top tabs, and "Open Script Rehearser" is dropped entirely — Studio already has its own Perform & Export tab for that. ## [1.17.8] — 2026-07-18 ### Fixed - **Studio's "Cast Audiobook >" header button broke navigation.** The old cross-section workflow stepper (`_wfUpdateHeaderNav` in `utils.js`) injects its Previous/Next buttons into every section's header, including Studio's — but those buttons are hardcoded to the old Read Aloud/Library/Script Rehearsal sections and know nothing about Studio's own 4-phase system. Clicking "Next" from Studio's Source phase left the UI in a half-switched state: the step hint read "2/8 · Characters" while the tab bar still showed "Source" and the old "Already uploaded" card. The stepper's header nav is now suppressed entirely inside `#s-caststudio` — Studio's own Source/Characters/Voices/Perform & Export tabs are the only way to move between phases there. ## [1.17.7] — 2026-07-16 ### Fixed - **Perform & Export rendered a blank Stage when reached without first visiting Characters in the same session.** `_audiobook.segments` is a purely in-memory cache, only populated by actually opening the Characters phase (which triggers the draft-restore); jumping straight to Perform & Export on a fresh page load left it empty even with a fully-cast draft already sitting on the server. Now fetches the server draft directly when needed instead of requiring that detour first. ## [1.17.6] — 2026-07-16 ### Fixed - **Series voice-reuse (1.17.5) could hand a character a stranger's voice.** Confirmed live: Lysandra's own character sheet lists "Kriegerin" ("the warrior woman") as a descriptive alias, and an unrelated placeholder character in book 1 happened to be literally named "Kriegerin" — the alias-matching direction treated that coincidence as "same person" and reused the wrong voice. Narrowed to exact-name matching only; a proper name repeating identically across books is a safe signal, a descriptive epithet coinciding with someone else's literal name is not (same class of false positive already found and fixed once this session for the identity-merge logic elsewhere). One character's voice on the live book was corrected by hand after the live repro. ## [1.17.5] — 2026-07-16 ### Added - **Voice assignment now reuses the same voice across a multi-book series.** The Character Library keys each character record per book (`book::name`), so a recurring character (e.g. "Nyrilla" in episode 2 of a 3-part novel) had no automatic link back to her already-voiced record from episode 1 — auto-assign and voice design would pick/generate a fresh, differently-sounding voice every time instead of keeping the cast consistent. "Automatisch", "Design a Voice", and bulk "Auto-design voices" now all check (by name and alias, across every other book) whether this character already has a voice from a different episode first, and reuse it directly instead of assigning/designing a new one. ## [1.17.4] — 2026-07-16 ### Added - **Library production sections are now collapsible**, with a chevron toggle per book. Landing here scoped to one specific book (Studio's Voices phase, the old "Assign Voices" step) now collapses every other production and expands only the one being worked on, instead of dumping every book's whole roster on screen at once. Manual collapse/expand choices persist per book. ### Fixed - **Studio's "Cast Characters" tab had noticeably less capability than the old page** — it only offered a single "Cast from Reader" button (equivalent to "Cast all, from scratch"), missing "Continue uncasted characters", "Cast selected character roles", and "New recast" entirely. Root cause: in the old app these are two dropdown buttons ("Identify Characters ⌄" and "Cast Characters ⌄") sharing ONE footer on ONE panel — splitting them into two separate Studio tabs left the real menu only reachable from the "Identify Characters" tab, with this tab falling back to a much weaker built-in placeholder. Now points at and opens the real menu instead of duplicating a lesser version of it. - **Studio kept getting silently kicked back to the old Read Aloud section** during long-running operations (opening a large book, running "Continue casting" on a partially-cast book) — several places in reader.js/audiobook.js call `navTo('s-reader')` themselves as a "make sure the right section is showing" safety measure, including ones buried deep inside multi-second PDF parses or multi-minute LLM casting passes. Chasing and patching each one individually didn't scale (found three separate cases live). Replaced with a persistent guard: while Studio is the active section, any `navTo` call targeting a section Studio borrows from (Read Aloud, Library, Script Rehearser) is dropped as an internal reflex — unless the user just clicked that section's own sidebar entry, which is unambiguous real intent to leave and is let through. Covers every current and future call site uniformly. ## [1.17.2] — 2026-07-16 ### Fixed - **Studio's Source phase rendered unstyled/cramped** (Speed/Seed/Temperature/Native speed as plain stacked rows, Paste/Drop-document stacked instead of side-by-side). Root cause: the CSS for this panel was scoped with `#s-reader` as a required ancestor (`#s-reader .reader-tuning-row`, etc.) — accurate when the markup lived only in Read Aloud, but once Studio reparents the same DOM under `#s-caststudio`, those rules stopped matching entirely and it fell back to unstyled browser defaults. Extended the ~30 affected rules to also match under `#s-caststudio`. ### Added - **"Already uploaded" book list in Studio's Source phase** — the same Library "Books" list, so you can open a previously-imported book directly from Studio instead of only being able to paste/import fresh text. ## [1.17.1] — 2026-07-16 ### Fixed - **Stray space before the closing German guillemet «** in dialogue text extracted from PDFs (e.g. "hier. «" instead of "hier.«") — this scanned book's font renders the closing quote glyph as its own separate text item, and `readerBuildSentences` joined every extracted word with an unconditional leading space, baking the stray space right in before it. The casting LLM then faithfully preserved it, since it's instructed to reproduce the source text exactly. Fixed to skip the leading space before any word that's purely closing punctuation (`«`, `"`, `'`, `'`, `"`, `)`, `]`). Applies to newly imported/extracted PDFs — a book already extracted before this fix keeps the existing spacing unless re-imported. ## [1.17.0] — 2026-07-16 ### Added - **New "Studio" section** (Speak menu) — a single 4-phase view (Source → Characters → Voices → Perform & Export) over the same PDF-to-audiobook pipeline previously spread across 8 confusing steps split between Read Aloud and Script Rehearser. It's a shell, not a rewrite: each phase borrows the exact same DOM/logic those sections already use (PDF import, LLM speaker-attribution casting, character sheets, the Library's voice-assignment cards, and Script Rehearser's Stage editor) via runtime DOM reparenting rather than duplicating any of it — so every bug fix made to those systems all session (voice-pill alias-merge redirect, Narrator card, emotion/speaker desync, etc.) applies here unchanged. Read Aloud and Script Rehearser stay in the sidebar, fully working, unchanged, while Studio is validated — nothing was removed. - **Perform & Export phase has one "Generate full audiobook" toggle** replacing the old two-section split: on, Narrator lines get voiced and per-character "I play this" live-recording is hidden; off, Narrator stays silent (or reads via `Skip descriptions`) and each character can be marked as user-played instead of TTS'd — the exact same underlying fields (`skipDescriptions`, `narratorVoice`, `cast[x].voice==='me'`) Script Rehearser's own toolbar already exposed, just as one switch instead of two different screens. ### Fixed - **Script Rehearser's Stage view could silently render as a completely empty page for books with a large, richly-illustrated cast.** Root cause: the per-line character portrait (added earlier this session) embedded the character's full base64 image data directly into every dialogue line's HTML — a character speaking hundreds of lines re-embedded their own multi-hundred-KB portrait that many times, ballooning a real 1968-line script to hundreds of megabytes and silently failing to render. Fixed by serving portraits from a proper URL (`GET /api/characters/{id}/image`, new route) that the browser fetches and caches once, exactly like voice pictures already do — instead of inlining the raw image data. This bug existed in the already-shipped Script Rehearser too, not just the new Studio section. ## [1.15.14] — 2026-07-16 ### Changed - **Script Rehearser's Stage line rows now show the character's real portrait** on the play button, instead of a generic "?"/voice-icon placeholder for any speaker whose assigned voice had no picture of its own — same portrait already shown in Cast Audiobook, Assign Voices, and the Cast sidebar strip right next to it. (Per-line emotion tag, edit-text pencil, and personal notes were already there — this closes the one visible gap.) ## [1.15.13] — 2026-07-16 ### Fixed - **Speaker names didn't match the lines in Script Rehearser's Stage view**, even though Cast Audiobook showed the correct attribution — confirmed live on a 1958-segment script: `parseScript`'s character-name-cue regex was ASCII-only (`[A-Z...]`), so any speaker name with a German umlaut or ß (e.g. "Turmwächter", "Freischärler", "Mädchen") uppercased to a string the regex couldn't match. Those cues were silently dropped — both the speaker line and its dialogue fell through to plain narration — which desynced the per-line emotions array (built with one entry per dialogue segment) from the actually-parsed dialog lines by one for every dropped cue. Six such names in this book meant every speaker/emotion pairing after the first drop point was shifted, eventually swapping entirely unrelated characters' lines. Fixed both the ALL-CAPS name-cue regex and the "CHAR: dialogue" colon-format regex to accept any Unicode uppercase letter plus ß. ## [1.15.12] — 2026-07-16 ### Fixed - **"Next" button stuck disabled on Assign Voices (5/8), unable to reach Script Rehearser (6/8) at all.** The Script Rehearser step was only enabled once `rehState.lines.length` was populated — but that's only ever set by loading a script *into* the Rehearser, which is exactly what clicking this step does. From a fresh session neither could happen first, permanently blocking the only path in. Now also enabled once the current Audiobook has cast segments ready, matching the fallback the click handler already builds from. ## [1.15.11] — 2026-07-16 ### Fixed - **Narrator card wasn't clickable at all.** The synthetic Narrator record added in 1.15.10 was inserted into each production's card list but never into the `byId` lookup map that `_wireCharCards` uses to find each card's click target — so clicking it (voice pill, avatar, anything) silently did nothing. ## [1.15.10] — 2026-07-16 ### Fixed - **Could not give an alias-duplicate card its own voice at all** ("Kolon Tunneltreiber" — an alias-duplicate of "Kolon" — always redirected the write onto "Kolon" no matter what, blocking progress). The picker's "pick" and "Automatisch" actions now write directly to the exact record you clicked (by id) instead of running through the identity-scan that decides where writes "really" belong — that scan is right for automated casting passes avoiding duplicate creation, but wrong for an explicit, unambiguous per-card action. Use "Bibliothek an aktuelle Besetzung anpassen" afterwards to clean up duplicate rows once you've sorted out which one should stay. ### Added - **Narrator can now get a voice from Assign Voices.** The Narrator was never a real Library record (kept out of the cast library on purpose), so the only place to set its voice was the Script Rehearser's own Cast tab, in a rehearsal-local field that isn't shared elsewhere — easy to lose track of, especially now that the pipeline jumps straight to Stage. A Narrator card is now pinned first in every production's grid; picking a voice for it saves like any other character, and the Rehearser picks it up the same way it already does for the rest of the shared cast. ### Changed - **"Hear a sample" play button in the banner is now green**, matching the assigned-voice pill. ## [1.15.9] — 2026-07-16 ### Fixed - **Picking a voice sometimes silently didn't save on the card you clicked.** Root cause confirmed live: `clUpsert` treats two library records as the same character whenever one's `aliases` field lists the other's name — "Kolon"'s aliases included "Kolon Tunneltreiber" and "Kolon der Zwerg" verbatim (leftover from an earlier casting pass that never got cleaned up), so assigning a voice to the still-separate "Kolon Tunneltreiber" card silently wrote it onto the "Kolon" record instead, leaving the clicked card looking untouched with no error. The picker now detects this redirect and shows a toast naming which record the voice actually landed on, with a pointer to "Bibliothek an aktuelle Besetzung anpassen" (Cast menu) to clear out the duplicate rows. ## [1.15.8] — 2026-07-16 ### Changed - **"Hear a sample" play button moved back into the banner**, directly in front of the voice pill, instead of at the bottom of the card — it had gone missing from easy reach after the voice info moved up to the pill in 1.15.6. ## [1.15.7] — 2026-07-16 ### Fixed - **Voice pill overlapped the select checkbox** in the top-left corner of Library cards — both were anchored at the same `top:10px; left:10px`. Pill now starts after the checkbox's width instead. ## [1.15.6] — 2026-07-16 ### Fixed - **Voice picker popup (Library detail page, card/table pick-voice buttons) had a transparent background**, letting the page content underneath bleed through the list and make it hard to tell what was actually clickable. Root cause: `background:var(--card)` referenced a CSS variable that was never defined anywhere (only `--surface`/`--panel` exist) — an invalid/missing custom property falls back to the property's initial value, which for `background` is transparent. Fixed to `var(--surface)` (also fixed the same bug on `.lib-tab`). ### Changed - **Library cards now show the assigned voice (or "Keine Stimme zugewiesen") as a clickable pill at the top of the banner**, replacing the small icon-only badge — the voice is the single most important thing to check per character, so it's promoted to a glance instead of scrolling to the bottom of the card. Clicking it opens the same pick/search popup used elsewhere, now with a "Neue Stimme designen" quick action at the top for jumping straight to Voice Design. The bottom-of-card voice row now only shows the "Anhören" (hear a sample) button, since the name/empty-state text moved up to the pill. ## [1.15.5] — 2026-07-16 ### Fixed - **Stage still showed 0/0 and an empty script/character pane after the 1.15.4 fix** — `showPhase(3)` only toggles which phase `