diff --git a/CHANGELOG.md b/CHANGELOG.md index 86bb2d9..bca66f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,1021 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- -## [Unreleased] +## [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 `
Table View Mode
Click a row to exit table view and edit.
Table View Mode
Click a row to exit table view and edit.
This permanently removes the selected voice${plural} from your library. This cannot be undone.
@@ -846,7 +846,7 @@ This warms each voice so the engine caches its .pt and first playback is instantNo saved rehearsals yet.
Parse a script below or drop a file to start.
No saved rehearsals yet.
Parse a script below or drop a file to start.
[tags] (e.g. [whisper], [excited], [laughing]). You can also type a custom tone like [professional broadcast tone] \u2014 S2 supports free-form descriptions. Fish-Speech S2 \u2197`),warn.hidden=!1;else if(!b.style_aware&&hasTone){const styleAware=all.find(x=>x.style_aware),suggest=styleAware?` Switch to ${escHtml(styleAware.label)} for reliable tone \u2014 but expect each voice to drift between lines.`:"";txtEl&&(txtEl.innerHTML=`${escHtml(b.label)} keeps each character\u2019s voice consistent but has weak tone control \u2014 tone picks may have little effect.${suggest}`),warn.hidden=!1}else if(b.style_aware&&!b.uses_wav){const wavBackend=all.find(x=>x.uses_wav),suggest=wavBackend?` Switch to ${escHtml(wavBackend.label)} to keep each character\u2019s voice identical throughout.`:"",qwenHint=/qwen|voice design|custom/i.test((b.id||"")+" "+(b.label||""))?" Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.":"";txtEl&&(txtEl.innerHTML=`${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won\u2019t sound the same throughout.${qwenHint}${suggest}`),warn.hidden=!1}else warn.hidden=!0}(_Fa=$("reh-tone-warn-close"))==null||_Fa.addEventListener("click",()=>{const w=$("reh-tone-warn");w&&(w.hidden=!0)}),(_Ga=$("reh-tb-play"))==null||_Ga.addEventListener("click",()=>{rehState.playing?pausePlay():startPlay()}),(_Ha=$("reh-tb-stop"))==null||_Ha.addEventListener("click",()=>{var _a2;stopPlay(),rehState.lineIndex=(_a2=rehState.practiceStart)!=null?_a2:0,highlightCurrentLine(),hideRecOverlay()}),(_Ia=$("reh-tb-prev"))==null||_Ia.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.max(0,rehState.lineIndex-1),highlightCurrentLine(),hideRecOverlay()}),(_Ja=$("reh-tb-next"))==null||_Ja.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.min(rehState.lines.length-1,rehState.lineIndex+1),highlightCurrentLine(),hideRecOverlay()}),(_Ka=$("reh-tb-repeat"))==null||_Ka.addEventListener("click",()=>{var _a2;rehState.repeat=!rehState.repeat,(_a2=$("reh-tb-repeat"))==null||_a2.classList.toggle("reh-btn-active",rehState.repeat)}),(_La=$("reh-skip-desc-toggle"))==null||_La.addEventListener("change",function(){rehState.skipDescriptions=this.checked}),(_Ma=$("reh-edit-script-btn"))==null||_Ma.addEventListener("click",openScriptEditorModal),(_Na=$("reh-fountain-btn"))==null||_Na.addEventListener("click",exportFountain),(_Oa=$("reh-fountain-export-p4"))==null||_Oa.addEventListener("click",exportFountain),(_Pa=$("reh-fdx-export-btn"))==null||_Pa.addEventListener("click",exportFDX),(_Qa=$("reh-osf-export-btn"))==null||_Qa.addEventListener("click",exportOSF),(_Ra=$("reh-exit-btn"))==null||_Ra.addEventListener("click",()=>{stopPlay(),stopRehMic(),rehState.clips.length?(renderSummary(),showPhase(4)):showPhase(2)}),(_Sa=$("reh-page-title"))==null||_Sa.addEventListener("dblclick",function(){this.contentEditable="true",this.style.outline="2px solid var(--accent)",this.style.borderRadius="3px",this.focus();const range=document.createRange();range.selectNodeContents(this),window.getSelection().removeAllRanges(),window.getSelection().addRange(range)}),(_Ta=$("reh-page-title"))==null||_Ta.addEventListener("blur",function(){if(this.contentEditable==="true"){this.contentEditable="false",this.style.outline="";const v=this.textContent.trim()||"Script";this.textContent=v,$("reh-script-title")&&($("reh-script-title").value=v)}}),(_Ua=$("reh-page-title"))==null||_Ua.addEventListener("keydown",function(e){var _a2;e.key==="Enter"&&(e.preventDefault(),this.blur()),e.key==="Escape"&&(this.textContent=((_a2=$("reh-script-title"))==null?void 0:_a2.value)||"Script",this.blur())});let _rehPlayCtx=null;const rehDecodedBuffers=new Map;let rehCurrentSource=null,rehWordHighlightRaf=null;function rehPlayCtx(){return _rehPlayCtx||(_rehPlayCtx=new(window.AudioContext||window.webkitAudioContext)),_rehPlayCtx.state==="suspended"&&_rehPlayCtx.resume().catch(()=>{}),_rehPlayCtx}const REH_DECODE_WINDOW=8;function _rehEvictDecoded(keepIdx){if(rehDecodedBuffers.size<=REH_DECODE_WINDOW*2+4)return;const lo=keepIdx-REH_DECODE_WINDOW,hi=keepIdx+REH_DECODE_WINDOW;for(const k of rehDecodedBuffers.keys())(kNo clips in this session.
';return}list.innerHTML=rehState.clips.map((clip,idx)=>{const line=rehState.lines[clip.lineIndex]||{text:"\u2014",speaker:clip.speaker},c=rehState.cast[clip.speaker]||{color:"#89b4fa"},typeLabel=clip.type==="me"?"\u{1F3A4} Recorded":clip.type==="tts"?"\u{1F50A} Synthesized":"\u23ED Skipped",audioHtml=clip.blob?``:"",dlHtml=clip.blob?``:"";return`[tags] (e.g. [whisper], [excited], [laughing]). You can also type a custom tone like [professional broadcast tone] \u2014 S2 supports free-form descriptions. Fish-Speech S2 \u2197`),warn.hidden=!1;else if(!b.style_aware&&hasTone){const styleAware=all.find(x=>x.style_aware),suggest=styleAware?` Switch to ${escHtml(styleAware.label)} for reliable tone \u2014 but expect each voice to drift between lines.`:"";txtEl&&(txtEl.innerHTML=`${escHtml(b.label)} keeps each character\u2019s voice consistent but has weak tone control \u2014 tone picks may have little effect.${suggest}`),warn.hidden=!1}else if(b.style_aware&&!b.uses_wav){const wavBackend=all.find(x=>x.uses_wav),suggest=wavBackend?` Switch to ${escHtml(wavBackend.label)} to keep each character\u2019s voice identical throughout.`:"",qwenHint=/qwen|voice design|custom/i.test((b.id||"")+" "+(b.label||""))?" Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.":"";txtEl&&(txtEl.innerHTML=`${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won\u2019t sound the same throughout.${qwenHint}${suggest}`),warn.hidden=!1}else warn.hidden=!0}(_Fa=$("reh-tone-warn-close"))==null||_Fa.addEventListener("click",()=>{const w=$("reh-tone-warn");w&&(w.hidden=!0)}),(_Ga=$("reh-tb-play"))==null||_Ga.addEventListener("click",()=>{rehState.playing?pausePlay():startPlay()}),(_Ha=$("reh-tb-stop"))==null||_Ha.addEventListener("click",()=>{var _a2;stopPlay(),rehState.lineIndex=(_a2=rehState.practiceStart)!=null?_a2:0,highlightCurrentLine(),hideRecOverlay()}),(_Ia=$("reh-tb-prev"))==null||_Ia.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.max(0,rehState.lineIndex-1),highlightCurrentLine(),hideRecOverlay()}),(_Ja=$("reh-tb-next"))==null||_Ja.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.min(rehState.lines.length-1,rehState.lineIndex+1),highlightCurrentLine(),hideRecOverlay()}),(_Ka=$("reh-tb-repeat"))==null||_Ka.addEventListener("click",()=>{var _a2;rehState.repeat=!rehState.repeat,(_a2=$("reh-tb-repeat"))==null||_a2.classList.toggle("reh-btn-active",rehState.repeat)}),(_La=$("reh-skip-desc-toggle"))==null||_La.addEventListener("change",function(){rehState.skipDescriptions=this.checked}),(_Ma=$("reh-edit-script-btn"))==null||_Ma.addEventListener("click",openScriptEditorModal),(_Na=$("reh-fountain-btn"))==null||_Na.addEventListener("click",exportFountain),(_Oa=$("reh-fountain-export-p4"))==null||_Oa.addEventListener("click",exportFountain),(_Pa=$("reh-fdx-export-btn"))==null||_Pa.addEventListener("click",exportFDX),(_Qa=$("reh-osf-export-btn"))==null||_Qa.addEventListener("click",exportOSF),(_Ra=$("reh-exit-btn"))==null||_Ra.addEventListener("click",()=>{stopPlay(),stopRehMic(),rehState.clips.length?(renderSummary(),showPhase(4)):showPhase(2)}),(_Sa=$("reh-page-title"))==null||_Sa.addEventListener("dblclick",function(){this.contentEditable="true",this.style.outline="2px solid var(--accent)",this.style.borderRadius="3px",this.focus();const range=document.createRange();range.selectNodeContents(this),window.getSelection().removeAllRanges(),window.getSelection().addRange(range)}),(_Ta=$("reh-page-title"))==null||_Ta.addEventListener("blur",function(){if(this.contentEditable==="true"){this.contentEditable="false",this.style.outline="";const v=this.textContent.trim()||"Script";this.textContent=v,$("reh-script-title")&&($("reh-script-title").value=v)}}),(_Ua=$("reh-page-title"))==null||_Ua.addEventListener("keydown",function(e){var _a2;e.key==="Enter"&&(e.preventDefault(),this.blur()),e.key==="Escape"&&(this.textContent=((_a2=$("reh-script-title"))==null?void 0:_a2.value)||"Script",this.blur())});let _rehPlayCtx=null;const rehDecodedBuffers=new Map;let rehCurrentSource=null,rehWordHighlightRaf=null;function rehPlayCtx(){return _rehPlayCtx||(_rehPlayCtx=new(window.AudioContext||window.webkitAudioContext)),_rehPlayCtx.state==="suspended"&&_rehPlayCtx.resume().catch(()=>{}),_rehPlayCtx}const REH_DECODE_WINDOW=8;function _rehEvictDecoded(keepIdx){if(rehDecodedBuffers.size<=REH_DECODE_WINDOW*2+4)return;const lo=keepIdx-REH_DECODE_WINDOW,hi=keepIdx+REH_DECODE_WINDOW;for(const k of rehDecodedBuffers.keys())(kNo clips in this session.
';return}list.innerHTML=rehState.clips.map((clip,idx)=>{const line=rehState.lines[clip.lineIndex]||{text:"\u2014",speaker:clip.speaker},c=rehState.cast[clip.speaker]||{color:"#89b4fa"},typeLabel=clip.type==="me"?"\u{1F3A4} Recorded":clip.type==="tts"?"\u{1F50A} Synthesized":"\u23ED Skipped",audioHtml=clip.blob?``:"",dlHtml=clip.blob?``:"";return`Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.
This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.
Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.
This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.
Saved on the server${opts.browsing?"":", in case the browser\u2019s own download went somewhere you don\u2019t check"} \u2014 come back and download any of these again any time, without re-exporting.
+