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 `
` is visible; the actual script text and character sidebar are built by `buildScriptPage()`, which the direct jump-to-Stage call skipped. Now calls `buildScriptPage()` (and `highlightCurrentLine()`) before switching phase, same as the Stage sub-tab's own click handler already does. + +## [1.15.4] — 2026-07-16 + +### Fixed +- **"Script Rehearser" step in the pipeline stepper landed on an empty Stage (0/0 lines).** Clicking the step number or the "Next" button from Assign Voices only navigated to the Rehearser section — nothing actually loaded the current cast/script into it, unlike the Rehearser's own "Rehearse" entry points which did. Now builds the Rehearser record from the live Audiobook segments (same cast and voices already assigned in Assign Voices) whenever no script is loaded yet, leaving an in-progress Stage session alone if one already exists. +- **Skips straight to the Stage view instead of the Cast tab** when opening the Rehearser from the Audiobook pipeline — by that point voices were already assigned in Assign Voices, so landing on Cast again was pure redundant re-work. + +## [1.15.3] — 2026-07-16 + +### Changed +- **Card name row now shows tier badge (Haupt/Neben) after the gender icon**, same line, instead of before the name. +- **Voice-assigned badge moved to the banner's top-right corner**, left of the "change photo" camera button, instead of living inline in the name row where it landed at whatever height the vertically-centered name row happened to be. + +### Removed +- **SillyTavern export button removed from Library cards** (still available from the table view) — it was cluttering the compact card banner for a rarely-used action. + +## [1.15.2] — 2026-07-16 + +### Changed +- **Library card body facts (Occupation/Archetype/Gender/Age/Lines) are now a compact single-line list** instead of a 2-column grid of boxes — each row only takes the height its own text needs, and a fact with no value is skipped entirely instead of rendering an empty box just to keep the grid aligned. Frees up meaningfully more room in an already-tight card. +- **Library character cards now use a playing-card aspect ratio (5:7)** for a uniform, deck-like grid. The body flexes to fill whatever space is left below the square banner instead of relying on a hardcoded height, so the card's total size is driven purely by its own width via `aspect-ratio`. Reverts to natural (content-driven) height on mobile's single-column layout, where a 5:7 ratio at full viewport width would stretch the card absurdly tall. + +## [1.15.1] — 2026-07-16 + +### Changed +- **Library card banner is now a 1:1 square** instead of a fixed 150px strip, so a saved portrait reads as an actual photo rather than a short crop. Its height now comes from the card's own width (already uniform across a grid row), and the card body below has a fixed height with internal scroll — together they keep every card the same total height without hardcoding one number that fought the new banner proportions. +- **Removed the gender/age/lines pills from the banner overlay** — they were duplicating the Gender/Lines stat boxes already in the card body. Age now gets its own stat box down there instead, next to Gender, rather than only ever showing on the banner. + +## [1.15.0] — 2026-07-16 + +### Added +- **"Clean up library to current roster"** (Cast menu, Cast Audiobook view). Removes saved character records that aren't in the current casting run's roster — the Character Library accumulates a record for every name any past casting run has ever produced for a book with no cleanup tied to the current cast, confirmed live: 65+ saved records for a book whose current roster is 46 names, including spelling-variant duplicates ("Globo Brohm" / "Gombo Brohm" / "Gernot Brohm") and one character split three ways ("Kolon" / "Kolon der Zwerg" / "Kolon Tunneltreiber") that a past, less-accurate pass never merged away. Shows the full list of what would be removed before deleting anything. + +### Changed +- **Library character cards are now a fixed size instead of growing with content.** A character with lots of aliases/occupation text used to stretch its own card — and, since grid rows size to their tallest item, every other card sharing that row — noticeably taller than a sparse one right next to it, making the grid look ragged. Overflowing banner text now clips instead of growing the card; overflowing body content scrolls internally. + +## [1.14.99] — 2026-07-16 + +### Fixed +- **A character's real-name fields could get contaminated with a completely different character's name.** Confirmed live: "Darrag" ended up with `first_name`/`last_name` both set to "Riedmar" — a separate, unrelated cast character, not a revealed alias (Riedmar's own record was blank at the time, ruling out a merge-logic bug — the model itself confused whose name belonged where, likely from the two characters appearing together in the same passage). Now: if a generated sheet's first/last/full name exactly matches a DIFFERENT known character's name, that field is dropped instead of trusted. Manually corrected the live Darrag record. +- **"Cast from Reader" (and the equivalent Rehearser/selective-cast entry points) could silently land back on the plain Source view instead of starting the cast**, showing an unrelated "resuming at sentence…" toast. Root cause not fully isolated (a race with the reader's own un-awaited async setup that runs every time its section becomes visible), but the practical fix doesn't need to be: the intended view is now re-asserted a moment later, winning the race regardless of which async step caused it. + +## [1.14.98] — 2026-07-16 + +### Added +- **Pencil icon to edit a character sheet's field values directly on the live casting card**, next to the color-swatch button in the header. Each field saves on its own debounce timer, so editing two fields in quick succession can't cancel one another's pending save. Known limit: a field that's currently empty isn't rendered at all here, so adding a brand-new value to an empty field still needs the full Library profile page — this covers correcting a value that's already shown. + +## [1.14.97] — 2026-07-16 + +### Added +- **"Design Voice" and "Generate Profile Image" action buttons directly on the Voice Design / Character Image prompt boxes.** Copy/Regenerate only ever acted on the prompt text itself — actually using it meant copying it out and pasting it somewhere else by hand. One click now does that directly with whatever prompt is already in the box. + +### Changed +- **Removed the ST / TTS / Bild indicator columns from the Cast table.** These only ever showed whether a prompt had been generated, which was redundant with information already visible elsewhere in the same row (the actual profile picture thumbnail, and the assigned voice in the Stimme column). + +### Fixed +- **"Generieren" (voice) always built a fresh, cruder prompt from raw sheet fields, ignoring the properly-crafted Voice Design Prompt already generated and sitting right there on the card.** Now uses the saved prompt when one exists, only falling back to the ad-hoc builder when nothing's been generated yet. + +## [1.14.96] — 2026-07-16 + +### Fixed +- **The voice picker popup ("Auswahl") could render floating several rows away from the button that opened it**, in the Cast table view specifically. Root cause: the popup was appended as a direct child of the trigger element with `position:absolute` — fine for a card-grid `
`, but the table view's trigger is a ``, and a `
` can't legally live inside a table row. Browsers silently relocate invalid table content out of the table structure, landing the popup at an unrelated position. Now appended to `` as a `fixed`-position popup anchored via `getBoundingClientRect`, the same proven pattern already used for the alias and footer-menu popups elsewhere in this app — works identically in both the card and table views. +- **The live casting card's avatar only opened a bare file picker** — no way to paste an image URL or regenerate one from the character's own image prompt without leaving the view. Now opens the same full lightbox (disk / URL / AI-regenerate) used everywhere else. + +## [1.14.95] — 2026-07-16 + +### Added +- **Library character cards now use an existing portrait as the whole card banner** (full-bleed background with a bottom-fade for legible text), instead of squeezing it into a small 96px circle — a real photo deserves more than icon size. The avatar circle shrinks to a small "change photo" corner button in that case, so re-uploading still works the same way. +- **A voice-assigned badge sits right next to each character's name** in the card grid — filled green speaker icon when a voice is assigned, muted outline when not. Previously this was only readable as plain text at the very bottom of a (possibly long) card, so scanning a whole cast for "who still needs a voice" meant reading every card in full. + +### Fixed +- **The live passage-by-passage casting view (Character sheets step) never showed an already-saved profile picture** — neither the main card's avatar nor the "Characters found" sidebar list. Root cause: the seeding step that loads each character's seed sheet from their saved library record dropped the `image` field entirely, so even a character with a real portrait always fell back to the plain letter placeholder in this view. Now carried through to both the card and the sidebar. +- **That same card's avatar was capped at a small square even with a real photo** — now stretches to fill the full height of the colored header banner instead of sitting shrunk in the corner. + +## [1.14.94] — 2026-07-16 + +### Added +- **Play button on every cast character with a voice assigned**, in both the Library's card and table views — hear a quick sample line in that voice without opening the full profile or leaving the page. Mirrors the Rehearser's own "Hear a line" button; toggles to a stop icon while playing. Since the Library only stores a character sheet (not actual script lines), the sample is a short generic greeting, in German or English depending on the character's own sheet text. +- **"Continue uncasted characters" now skips characters with fewer than 10 lines by default** instead of endlessly re-trying them. Confirmed live: a full-roster recast filled in the one prominent character and left every character under ~20 lines completely blank — a character with only a handful of lines usually just doesn't have enough text for the LLM to say anything real about them, so retrying burns a call that almost always comes back empty anyway. The toast now reports how many were skipped this way. + +## [1.14.93] — 2026-07-15 + +### Fixed +- **"Continue uncasted characters" could re-split an already-merged character back into two records forever.** It checked whether a roster name already had a library record by exact name only, never checking aliases — so once a character-sheet pass proved two roster names (e.g. a pre-reveal alias and an already-established dialogue-attribution name) were the same person and merged them under one canonical name, the OTHER name still looked like "never cast at all" on every future run and got endlessly re-queued as its own separate target, recreating the split every time. Now also matches against every existing record's recorded aliases before deciding a roster name is uncasted. +- **A second copy of the shared-debounce-timer bug**, in the casting view's own inline character-detail panel (fixed once already this session in the Library's detail page, missed this second copy): editing two fields within 900ms could silently drop the first edit. Same fix — one timer per field instead of one shared timer. + +## [1.14.92] — 2026-07-15 + +### Fixed +- **Opening a character's full profile from the Read Aloud recast-results grid rendered it squeezed into a sliver with the sidebar overflowing to the side.** That results grid (`.cs-list`) is a CSS grid with `minmax(300px, 1fr)` card columns; the detail page's own two-column layout (main content + 240px character-navigation sidebar) got inserted as a single grid item, confining it to one column's width instead of the full row. The detail page now explicitly spans every column when it renders — a no-op in the Library's own plain container, so this only changes the one place that was actually broken. + +## [1.14.91] — 2026-07-15 + +### Fixed +- **Regression from earlier today: a character referred to mostly by an alias could end up completely blank after a full recast, even with 100+ lines of dialogue.** The "output nothing if this passage is about someone else" instruction added to prevent cross-contamination (v1.14.89) was too broad — it also fired whenever a passage used a target's alias/nickname/role instead of their literal roster name (e.g. "der Schmied" instead of "Darrag"), even though the character's own already-recorded sheet said that alias belonged to them. Confirmed live: a synthetic "der Schmied" passage with Darrag's alias already on record returned an empty sheet before the fix, a fully-detailed one after. Caught this by directly comparing the API's raw output before/after against known real-book passages (Darrag/"Fremder" both had zero recorded sources despite 100+ lines each) rather than trusting the instruction wording alone. The model now explicitly checks the passage's own context AND the existing-sheets summary for an alias match before ever deciding a passage is unrelated. + +## [1.14.90] — 2026-07-14 + +### Fixed +- **"Verify all characters" could duplicate a paragraph into the cast, with a stray quote mark on the second copy.** Confirmed live: the same narration paragraph appeared twice, separated by an unrelated dialogue exchange — the source PDF only had it once. Root cause: each recast group pads its request with ±5/+4 segments of surrounding context so the LLM has continuity across the group's actual targets, and adjacent groups' context windows can overlap by that much; splicing a group's multi-segment replacement back in can then reintroduce text a neighbouring group's overlapping window already restated correctly a few segments earlier. Added a dedup pass after the verification run that removes a near-duplicate segment found within a nearby window (normalizing away stray leading/trailing quote marks before comparing, since the duplicate copy often carries one) and reports how many it removed. + +## [1.14.89] — 2026-07-14 + +### Fixed +- **A field's source citation badge only ever showed the FIRST supporting quote, silently hiding any others.** If "Relationships" was backed by 3 separate passages, only one citation mark ever appeared next to it — the rest existed in the saved data but had no way to reach them from that field. Now shows one small numbered mark per matching source, each numbered to match its position in the full "Sources / Evidence" list at the bottom of the card. +- **Clicking a source citation only jumped to the top of the page, not the actual sentence.** Now also runs the Reader's own search (highlighting + auto-scroll) for the quoted text right after the page jump, so the exact line is visible in context instead of "somewhere on this page, go find it." +- **Character-sheet generation could cross-contaminate two different characters' profiles.** When a per-character "evidence window" pass (used by "Continue uncasted characters" and manual re-cast) landed on a passage that was actually about a DIFFERENT character than the one being processed, the model had no valid way to say so — it wasn't allowed to invent a new profile, so it forced the unrelated character's information into the wrong profile's fields (caught live: a passage about "Marcian" got written into "Fremder"'s Notes field, along with the model's own visible confusion about what to do). The prompt now explicitly allows outputting nothing for a passage that isn't actually about any of the current targets. + +## [1.14.88] — 2026-07-14 + +### Added +- **3rd casting quality pass: "Check voice consistency"** (Identify Characters menu, Read Aloud → Cast Audiobook). Unlike the first two passes — which both re-read the source text chunk by chunk, since a whole book is far bigger than any context window — this one works purely over already-attributed lines already in memory: for each character with 4+ lines, it gathers every line credited to them from anywhere in the book (evenly sampled up to 60 for very talkative characters, so their arc late in the book is represented too, not just their first appearances) and sends that whole bundle to the LLM in one call, asking it to flag any line that doesn't match the voice established by the REST of that character's own lines. Catches a misattribution that reads fine in isolation but doesn't fit the character once you see their whole body of dialogue together — something no single passage-sized window could ever expose. Only ever reassigns to an already-known character name or 'Unknown', never invents a new one. New server route: `POST /api/audiobook-consistency-check`. Caught live during testing: the model sometimes echoed its own sequential count instead of the real line index — added a quoted-excerpt field to the response and a text-match fallback so a flagged line can't silently misapply to the wrong segment. + +## [1.14.87] — 2026-07-14 + +### Fixed +- **"Failed to fetch" during character-sheet generation wasn't retried at all.** The retry-with-backoff added earlier this session only matched 429/rate-limit errors — a plain dropped connection, DNS blip, or the backend restarting mid-run failed that passage permanently on the first hit, silently leaving it unprocessed for that run. Now covered by the same retry logic (shorter backoff than rate limits, since network blips usually clear faster). + +## [1.14.86] — 2026-07-14 + +### Added +- **"Cast selected character roles" now shows portraits and line counts, and is searchable/sortable.** Replaced the bare alphabetical checkbox list with the same avatar + name + line-count row style as the "Characters found" sidebar in the live casting view, plus a search box and a Lines/A–Z sort — matching how you already pick characters everywhere else in the app. +- **"Verify all characters" (2nd quality run) is now explicitly prioritized.** It checks, in order: (1) every 'Unknown' line first — an unattributed line is worse than a misattributed one, (2) runs of 2+ consecutive segments assigned to the same speaker (narrator or character) — the most common place a hidden speaker-change or unquoted dialogue line gets swallowed into a narration/dialogue streak, (3) a general plausibility check on everything else. Previously all of this was checked in one unordered pass. + +### Fixed +- **Stray quote marks were landing on the wrong segment instead of just being dropped.** German uses » to open and « to close a quote (the reverse of French) — a dialogue turn's closing « sometimes ended up glued to the FRONT of the following narration segment instead of the end of the dialogue itself (and the mirror case for opening »), including cases where the entire "segment" was nothing but the stray mark. Added a mechanical post-cast fix that moves each mark back to where it belongs, plus a new explicit prompt rule telling the casting LLM the same thing so it happens less often in the first place. + +## [1.14.85] — 2026-07-14 + +### Fixed +- **"Verify all characters" (the second-pass quality check) was missing the post-quote inquit-tag rule.** The first-pass casting prompt has an explicit rule for lines like `"Ich bin dagegen!" ... entgegnete Oberst von Blautann.` (the dialogue comes before the name) — but the verification pass's rule list only carried over the colon rule ("X sagte:" → the following line is X's), not this one, so a Narrator-mistagged line immediately followed by an inquit tag wasn't reliably caught. Added the same "Nachgestellte Zuordnung" and action-beat rules the first pass already had. + +## [1.14.84] — 2026-07-14 + +### Added +- **Workflow navigation now says what to actually do.** The Previous/Next buttons on every section header used to just say "Previous"/"Next" with no clue what either led to or when a step was "done" — they now show the target step's name, and a guidance bar under the header explains what this step is for and when it's safe to move on or go back to fix something. +- **Adjacent same-speaker segments get merged after casting.** Two or more consecutive segments assigned to the exact same speaker (narrator or a character) with no page break between them are either an attribution mistake or one continuous block chopped into noise by the per-passage LLM pass — both are now merged into one segment automatically, book-wide (not just within one passage), instead of leaving a wall of one-line cards to review by hand. + +### Fixed +- **A character's voice reassignment could silently fail to save.** `clUpsert`'s persisted-record builder computed `voice: prev.voice || sheet.voice`, so once a character had ANY voice, every later reassignment (via the picker, "Auto assign", or "Design voice") updated the in-memory view and showed a success toast, but the database kept the OLD voice forever — the wrong voice then got used for every subsequent audio generation, with nothing anywhere indicating the change hadn't actually stuck. Precedence flipped so an explicit new value always wins. +- **Unrelated characters could get silently merged into one profile.** `CL_IDENTITY_FIELDS` (the persisted-library identity-match list) still included `title`/other purely-descriptive fields, reintroducing a bug already fixed in the in-session merge logic (`CS_IDENTITY_FIELDS`) — two different characters sharing a job title or a reused epithet ("the Executioner") could have their sheets, backstory, and voice blended into a single record. Brought back in sync with the already-fixed list. +- **A failed TTS segment used to vanish from the exported/merged audiobook with zero warning.** Both Read Aloud's page/sentence export and Script Rehearser's audiobook export caught synthesis errors per-segment and just moved on, so a mid-book backend blip produced a file "successfully" downloaded with paragraphs quietly missing — discoverable only by listening carefully. Both now count failures and refuse to export until they're fixed/retried, instead of silently shipping a gapped file. +- **Generating character images could freeze the entire app for every user, for minutes.** Every image-gen provider (ComfyUI, OpenAI, Google, OpenRouter) made blocking HTTP calls directly on the request handler with no `asyncio.to_thread` wrapper — on this single-worker server, ComfyUI's own polling loop (up to 5 minutes per image) blocked the whole event loop, so a bulk "auto-generate images" run across a cast could make the entire site unresponsive for everyone, for as long as it ran, with no distinguishing symptom. Moved onto a worker thread like every other blocking call in this file already was. +- **Editing two character-profile fields in quick succession could silently drop the first edit.** The detail page's autosave used one shared debounce timer for every field — switching from one field to another within 900ms cancelled the first field's still-pending save with nothing else to replace it, so that edit was never written to the record or persisted, with no error shown. Each field now gets its own timer. +- **Rehearser's "Synthesize All"/"Re-synthesize stale" could get stuck locked forever.** Same failure shape as the character-sheet generation-lock bug fixed earlier: an uncaught error partway through the loop (e.g. the current line's character got deleted mid-run) left the running flag stuck `true`, silently no-op'ing every future click with no error shown. Both now run inside try/finally and skip a line whose cast entry has vanished instead of throwing. + +## [1.14.83] — 2026-07-14 + +### Fixed +- **Character-sheet recasts could silently stop saving forever, in one tab, with no error shown.** The generation lock (`_cs.running`) was only guaranteed to clear inside a `try/finally` that started *after* the progress overlay was created — if that setup step (or anything else run once per call, before the passage loop) ever threw, the lock stayed `true` permanently. Every later recast in that same browser tab then hit the very first guard (`if (_cs.running) return null`) and did nothing at all: no toast, no save, no visible error — exactly indistinguishable from "recasting isn't working." Confirmed live: hundreds of `/api/character-sheets/stream` calls succeeded over the last two days with zero corresponding `PUT /api/characters` saves. The whole function body is now wrapped so the lock always releases and any crash surfaces as a toast instead of vanishing. A page reload already clears the flag in the meantime, since it's just in-memory state — no data was lost, but nothing new was saved until now. + +## [1.14.82] — 2026-07-12 + +### Added +- **API-key gate for external callers** (Phase 4 of the roadmap) — off by default, since this is a live app and I couldn't test the same-origin detection against real browser traffic. Turn on "Require this key for external callers" in Settings → API Keys → External API Access once confirmed safe; the app's own UI is unaffected either way (same-origin, never needs the key). Also discovered the REST API was already comprehensive (118 documented routes, auto-generated docs live at `/docs`) — this phase was mostly already done. +- **MCP tool set expanded** (Phase 5) — from 4 tools (speak/transcribe/list_captures/list_profiles, discovered already existing at `/mcp`) to 9: added `list_books`, `list_characters`, `get_character`, `update_character`, `list_rehearsals`. Note: full end-to-end "cast an entire book via one tool call" isn't included — that orchestration (chunking, streaming, progressive merge) currently only exists client-side in JS, not as a backend-callable operation; porting it is a larger follow-up, not silently skipped. + +## [1.14.81] — 2026-07-12 + +### Added +- **i18n expanded from German-only to 7 languages** (Phase 3 of the roadmap: French, Spanish, Italian, Portuguese, Dutch, Polish, plus the existing German) — the translation infrastructure (`window.t`, `applyI18n`, the language picker in Settings → General) already existed and worked, it just only had one language's dictionary filled in. Same key set (~100 UI-chrome strings: nav, section titles/subtitles, common buttons/labels) translated into all six new languages. LLM-generated content (character sheets, prompts) is unaffected — stays in the source book's language, as scoped. + +## [1.14.80] — 2026-07-12 + +### Changed +- **SillyTavern + Concept Art prompt boxes no longer silently disappear during an active recast** — the live character-sheet card hid all four prompt boxes until their field had content, which made sense for Voice Design/Image (filled in live, passage by passage) but was confusing for SillyTavern/Concept Art, since those only ever run as a background pass after the whole recast finishes — they looked like they'd vanished rather than "not generated yet." Those two now always show, with a note explaining they generate after casting finishes while a run is active. + +## [1.14.79] — 2026-07-12 + +### Fixed +- **Character-sheet generation prompt hardcoded real character names from the user's actual book ("Zerwas", "Henker", "Vampir") as the identity-merging example**, in both the client-side and server-side copies of the prompt. Beyond not generalizing to any other book, this risked the LLM getting confused by its own instructions calling out a name it was actively reading dialogue for — plausibly explaining why some major characters (Zerwas: 146+ lines) ended up with a completely empty profile despite having plenty of source material. Replaced with clearly generic placeholder names ('Marcus', 'the Executioner', 'Bloodfang') in both copies. No custom prompt is currently saved, so this takes effect on the very next run with no reset needed. + +## [1.14.78] — 2026-07-12 + +### Added +- **Focus trap + focus restore for every modal in the app** (Phase 2 of the accessibility roadmap) — the ~11 places that build a `.audiobook-overlay` dialog (confirmDialog, the avatar lightbox, csShow, voice pickers, etc.) never managed keyboard focus individually: Tab could escape a modal into the page behind it, and closing one never returned focus to whatever opened it. Fixed centrally via the existing DOM-mutation observer instead of touching all 11 call sites — Tab/Shift+Tab now cycles within the topmost open modal, and closing it restores focus to the trigger element. + +## [1.14.77] — 2026-07-12 + +### Added +- **"Continue uncasted characters" in the Cast Characters menu** — a new option alongside "Cast all character roles" and "New recast (discard & rebuild all)" that only generates profiles for characters with zero detail yet (never cast, or only ever picked up as a bare name), skipping everyone already fully cast. Uses the same targeted evidence-window scan as "Cast selected character roles," just with the incomplete list built automatically instead of picked by hand. + +## [1.14.76] — 2026-07-12 + +### Fixed +- **Character sheet generation permanently dropped a passage's data on a rate-limit (429) error** — a burst of requests hitting the LLM provider's per-minute cap (observed live: OpenAI 429 on passage 4 of 149) just logged the failure and moved on, silently leaving that section of the book with no character detail. Rate-limit errors now retry with backoff (up to 4 attempts, growing delay) before giving up; other error types still fail fast as before. +- **Avatar lightbox and Rehearser cast card "More options" grid could overflow on narrow phones** — the lightbox's action column had a 340px minimum width and the cast card's Language/Gender/Tags row stayed 3 columns regardless of viewport; both now collapse properly on mobile. + +## [1.14.75] — 2026-07-12 + +### Fixed +- **Script Rehearser Stage view was unusable on phone-width screens** — the character sidebar was a fixed 220px column sitting next to the script page with no responsive handling at all, leaving almost no room for the actual script on a phone. It now stacks above the page and collapses full-width instead, same collapse toggle as desktop. + +### Investigated (Phase 1 of the mobile/accessibility/i18n/API roadmap) +- Spot-checked Library (Cast grid + table) and Read Aloud (Reader + Casting) for the same class of gap — both already handle narrow viewports reasonably well (auto-fill grids that naturally go single-column, flex-wrap toolbars, horizontally-scrollable tables) and didn't need immediate fixes. Broader page-by-page audit still pending for Phase 1 completion. + +## [1.14.74] — 2026-07-12 + +### Added +- **Script Rehearser Stage sidebar now has search, sort, and real character portraits** — matching what the Cast tab already gained, instead of plain colored-letter dots. Names show in the character's cast color when a real portrait is available. Search/sort now only re-render the sidebar strip, not the whole (potentially 1000+ line) script page. + +## [1.14.73] — 2026-07-12 + +### Fixed +- **"Open Script Rehearser" could load stale/wrong cast data** — it previously saved the current cast to IndexedDB and then re-fetched it by id before loading, which could hand back an outdated record (read-after-write race, or a stale id from an earlier pass). It now builds the record from the current in-memory segments and loads that directly — no re-fetch, no possible staleness — while still persisting to the DB in the background for the "Cast again" round trip. +- **Casting could leave a lone orphaned »/« character as its own narrator segment** — the casting prompt instructed the LLM to strip quote marks out of dialogue text entirely, and when it didn't fully comply, a stray quote mark landed as a standalone segment right before the actual dialogue. Quote marks are now kept attached to the dialogue text instead of being stripped (matching how they read in the source), which removes the failure mode rather than just re-emphasizing the instruction. Already-cast books need a recast to pick this up — existing segments aren't touched automatically. + +## [1.14.72] — 2026-07-12 + +### Changed +- **Script Rehearser cast cards now literally reuse the Library's own card** (`_charCardHtml`) for every speaker that matches a real character record — same colorful portrait/tier/occupation/alignment design as Library → Cast, not a look-alike. Clicking a card opens the same full profile page (voice/language/tags/soul editing lives there now); "back" returns to the cast list. Only "I play this", ignore/hide/delete, "Hear a line", and emotion chips remain on the card itself. Narrator and unmatched/"UNKNOWN" speakers (no Library record to reuse) keep the simpler fallback card with its own inline voice picker, since there's no profile page for them to point at. + +## [1.14.71] — 2026-07-12 + +### Changed +- **Script Rehearser cast cards decluttered** — Language/Gender/Tags, the online voice-match panel, speaking-style prompt, and Character soul are now collapsed behind a single "More options" toggle instead of always showing five-plus form fields per card. Only portrait, name, voice, play button, and emotion chips show by default, closer to the Library's own card look. Kept as Rehearser's own card grid rather than embedding the Library table directly — this tab has to handle the Narrator and unmatched/UNKNOWN speakers, which the Library's book-scoped character table doesn't model at all. + +## [1.14.70] — 2026-07-12 + +### Added +- **Script Rehearser cast cards now show a real portrait, tier badge, and occupation/archetype** — cross-referenced from the Character Library by matching the script's book/title, same visual language as the Library's own cast cards, instead of always falling back to a colored-initial avatar. +- **Emotion chips on each cast card** — every distinct speaking-tone tag used across that character's lines in the script (set via the existing per-line emotion picker) now shows as a small chip row, giving an at-a-glance sense of their emotional range before you even open the script. + +## [1.14.69] — 2026-07-11 + +### Fixed +- **Bulk image generation produced near-identical generic portraits for sparse background characters** — a character with no physical/clothing/archetype detail (typical for 2-3-line crowd extras like "Ruf aus der Menge" or "Zwei Gestalten") fell back to a prompt with nothing character-specific in it ("an original character named X, ambiguous expression"), close enough across dozens of characters that several providers returned visibly identical results. Bulk generation now skips characters below a minimum-detail threshold instead of spending a generation on a near-blank prompt, leaving the neutral initial-letter placeholder instead. + +## [1.14.68] — 2026-07-11 + +### Added +- **Character profile pictures now sync to their assigned library voice's own picture** — the Voices Library already had full picture upload/display support (`/api/voice/picture`), it just never had anything feeding it from the character side, so voices only ever showed generic gender/type icons. Syncs whenever a character's image changes (upload, URL, AI-regenerate, bulk generate) if they already have a voice assigned, and whenever a voice gets (re)assigned (manually, auto-assign, or auto-design) if they already have an image. Best-effort — never blocks or shows its own errors, since it's a convenience mirror of the character's real picture, not the primary action. + +## [1.14.67] — 2026-07-11 + +### Changed +- **Avatar lightbox is much bigger** — modal width 680px → 1180px, preview image 200px → 440px, larger text/inputs throughout. The first pass was sized like a small popup instead of something meant to actually judge image quality at a glance. + +## [1.14.66] — 2026-07-11 + +### Added +- **Avatar lightbox — click any character's profile picture in the Library grid/table to open a bigger preview with three ways to change it**: upload from disk, paste an image URL (downloaded server-side to sidestep CORS, since most image hosts don't allow direct browser fetches), or regenerate with AI using an editable prompt (pre-filled with the character's current Image Prompt, with a per-run engine picker). Replaces the previous behavior where clicking an avatar jumped straight into a file picker with no way to preview, use a URL, or tweak the prompt before generating. + +## [1.14.65] — 2026-07-11 + +### Fixed +- **ComfyUI generation crashed with `NameError: name 'copy' is not defined`** — a leftover from moving the route's ad-hoc imports to the top of the file; `copy` was never actually added there. Caught by testing the endpoint directly before relying on it. + +### Configured +- **Local ComfyUI wired to an actual working text-to-image workflow** — a minimal 4-step Flux Schnell pipeline (checkpoint/CLIP/VAE already present on disk), verified end-to-end: submitted, polled to completion (~30s), and the resulting image confirmed as a real, on-quality character portrait. Chosen over the much larger "Consistent Character Creator" workflow specifically because it's simple enough to trust without ComfyUI's own UI export step. + +## [1.14.64] — 2026-07-11 + +### Fixed +- **Bulk "Auto-generate images" gave no visible progress until the entire batch finished** — avatars only refreshed on the final grid re-render, so a long run (especially with a slow local ComfyUI workflow) looked frozen except for the toolbar's "N / total" counter. Each avatar now updates in place the moment its own image comes back, in both Cards and Table view. + +## [1.14.63] — 2026-07-11 + +### Added +- **Per-run image-generation engine picker in the Library toolbar** — a dropdown next to "Auto-generate images" lets you pick OpenAI/Google/OpenRouter/Pollinations.ai/ComfyUI for just that run, overriding the Active Provider in Settings instead of having to go change it first. Defaults to whatever's currently active. + +### Changed +- **Character avatar thumbnails in the table view are bigger** (26px → 48px) — the previous size was too small to actually judge a generated image's quality at a glance. + +## [1.14.62] — 2026-07-11 + +### Fixed +- **Pollinations.ai now retries automatically on "queue full" errors** — the free shared queue rejects requests under load instead of queueing them; a first live test failed this way and succeeded on manual retry, so that retry (up to 3 attempts, 4s apart) is now automatic. + +### Added +- **Pollinations.ai card in Settings → Engines → Image Generation → Cloud APIs**, alongside OpenAI/Google, documenting that it needs no key/setup — just select it as the Active Provider. Removed the stale "ComfyUI planned as a follow-up" note now that it's actually implemented. + +## [1.14.61] — 2026-07-11 + +### Added +- **Local ComfyUI as an image-generation provider** (Settings → Engines → Image Generation) — paste an API-format workflow export (Workflow → Export (API Format) in ComfyUI's own UI, not a regular saved workflow), point it at your prompt node/field and output node, and character portraits/concept sheets generate on your own GPU instead of a paid API. Includes "Test connection" (reachability check) and "Test generate" (runs a real prompt through it and shows the result inline) so you can validate the setup without going through a full bulk-generate run. Deliberately does not attempt to auto-convert ComfyUI's editor-format graphs — workflows using virtual-routing addons (e.g. rgthree Get/Set nodes) don't show up as real graph edges and a naive converter could silently mis-wire them. +- **Pollinations.ai as a free, no-API-key image-generation provider** — a stopgap for when the paid providers' billing isn't sorted yet; third-party public service, no SLA or account needed. + +## [1.14.60] — 2026-07-11 + +### Added +- **Search box in the status bar's LLM model fly-up** — OpenRouter alone lists 50+ models, making the plain scrollable list slow to use; typing now live-filters it. Applies to any fly-up with more than 8 entries (STT/TTS backend pickers included, though those rarely have that many). +- **OpenRouter as a third image-generation provider** (Settings → Engines → Image Generation), alongside OpenAI and Google — routes through the same chat/completions endpoint and API key as the OpenRouter LLM card (no separate key needed), for image-capable models like `google/gemini-2.5-flash-image-preview`. + +## [1.14.59] — 2026-07-11 + +### Added +- **Connect and "Use as LLM" buttons on the Anthropic card too**, for consistency with the other five Cloud API cards. Connect now probes correctly for Anthropic's actual auth scheme (`x-api-key` + `anthropic-version` header) instead of the generic Bearer-token check the other providers use, which would have falsely shown "unreachable" even for a valid key. "Use as LLM" now warns before applying it, since this app's LLM calls are OpenAI-format (`/chat/completions`) and Anthropic's Messages API isn't compatible — the button works, but generation will likely fail until real Anthropic support is added. + +## [1.14.58] — 2026-07-11 + +### Added +- **Connect and "Use as LLM" buttons on the Cloud API cards** (Groq, OpenRouter, Google Gemini, Mistral, OpenAI) — previously only the local Docker engine cards further up the page had these; the cloud cards only let you store a key with no way to test it or make it the active LLM. Connect probes the endpoint with the entered key and shows a persistent Connected state; "Use as LLM" applies the endpoint, key, and a sensible default model to Settings in one click, matching the local cards' behavior. Not added to the Anthropic card since it isn't OpenAI-compatible and would silently break. + +## [1.14.57] — 2026-07-11 + +### Added +- **OpenAI and Anthropic cards in Settings → Engines → Language Models → Cloud APIs**, alongside Groq/OpenRouter/Google Gemini/Mistral — same API key storage pattern as the existing cards. Anthropic is flagged as not OpenAI-compatible (needs the Messages API, not `/chat/completions`), since every other card here — including this app's own LLM calls — assumes an OpenAI-compatible `/chat/completions` endpoint. + +## [1.14.56] — 2026-07-11 + +### Changed +- **The image-generation Model field is now a dropdown of known models per provider, not a blank text box** — there was nothing to "select" because it was free text with only a greyed-out placeholder, so you had to already know a valid model ID to use it at all. Now shows curated options for OpenAI (gpt-image-1, dall-e-3, dall-e-2) and Google (gemini-2.5-flash-image and its preview variant, imagen-4.0 and imagen-3.0), plus a "Custom…" option that reveals the free-text field for anything newer than this list. Switching provider resets the model to that provider's default instead of silently keeping an incompatible model ID from the other one. + +## [1.14.55] — 2026-07-11 + +### Fixed +- **Clicking a character card in the fresh-recast results grid (Read Aloud → Character sheets) did nothing** — the detail page always rendered into `#lib-chars-list`, a container that only exists on the Library page. It now accepts an explicit target container and back-navigation callback, so opening a profile from any card grid in the app (not just the Library) works and "back" returns to wherever you actually came from. + +### Added +- **SillyTavern and Concept Art prompts are now generated automatically as part of a recast** — previously only the Voice Design and Image prompts were filled in during the passage-by-passage pass; the other two needed a manual "Generate" click per character. They now run as a background pass right after a recast finishes (its own progress/result toast), using the character's complete profile in one LLM call per prompt. +- **"Clone" as a fourth voice option on the character profile page**, alongside Pick/Auto/Online/Design — opens Clone a Voice with the character's name pre-filled, ready for a mic take, file, or YouTube URL (cloning needs a real audio source only the user can supply, so this just gets them to the right screen instead of automating a step with no sane default). +- **Explicit Upload / Search online / Generate buttons under the profile picture**, instead of only an upload-on-click avatar plus one AI-generate button — "Search online" opens an image search in a new tab (built from the character's name, book, and archetype) for the user to browse and save an image themselves. + +## [1.14.54] — 2026-07-11 + +### Fixed +- **The "Delete characters?" confirmation dialog's red confirm button was invisible** — `.btn-danger` and a few other rules (error toasts, danger menu items) styled themselves with `var(--error)`, but no `--error` CSS variable was ever defined (only `--red`), so the button rendered with no fill at all instead of solid red — it looked unclickable rather than like a button. `--error` is now defined alongside `--red` in both the light and dark themes. + +## [1.14.53] — 2026-07-11 + +### Fixed +- **Bulk actions in the Character Library only reported a fail count, never the actual error** — "Generating images finished for 0 characters (79 failed)" gave no way to tell why without opening DevTools. The toast now includes the actual error message (e.g. an API quota/billing limit), and a bulk run stops early after 3 characters in a row fail with the identical error instead of grinding through the rest of a selection hitting the same systemic wall. + +## [1.14.52] — 2026-07-11 + +### Changed +- **Bulk voice-design sample text now falls back to a real quote from the book before the generic filler sentence** — the fallback only kicked in when the character had no attributed dialogue line in the currently-loaded audiobook cast, which is the common case when designing in bulk straight from the Library (outside that book's cast context). It now tries the character's own sourced quotes next, preferring one that reads like something they actually said, before resorting to the neutral placeholder line. + +## [1.14.51] — 2026-07-11 + +### Fixed +- **Bulk-designed character voices showed up as "Clone" instead of "Design" in the voice library** — the headless auto-design flow saved a reference sample the same way manual cloning does, but never tagged the result with the `origin: designed` marker the UI's Clone/Design classification checks for. All 75 already-saved auto-designed voices were retagged in place. + +### Added +- **Bulk-designed voices are now grouped and tagged by the book they were designed for** — same `group`/`tag` metadata the Script Rehearser's own voice-design flow already sets, so a book's character voices cluster together instead of scattering through the flat voice list. + +## [1.14.50] — 2026-07-11 + +### Added +- **Select-all checkbox in the Character Library table header** — checking it selects/deselects every row directly from the column it lives in, in addition to the existing toolbar "Select all" button; the two stay in sync (including an indeterminate state when only some rows are checked). + +## [1.14.49] — 2026-07-11 + +### Fixed +- **Every page went blank right after the 1.14.48 deploy** — the new header Previous/Next injection (`_wfUpdateHeaderNav`) was called with a `curIdx` variable that only existed inside the stepper-rendering loop, throwing a `ReferenceError` on every single `refreshWorkflowCrumbs()` call (which runs on essentially every page render). The exception aborted whatever page-render code ran after it in the same call stack, leaving pages showing only their header. `curIdx` is now computed once, before the loop, in scope for both the stepper and the header-nav update. + +## [1.14.48] — 2026-07-11 + +### Added +- **"Select all" and "Delete selected" in the Character Library grid** — select-all toggles every checkbox in a production at once; delete removes the checked records outright (there was previously no delete option at all in this grid — needed for clearing stale/corrupted entries before a fresh recast). +- **Previous/Next workflow buttons on every page's section header**, not just the stepper widget further down — same navigation, one click away without scrolling. + +### Changed +- **The stepper's back-to-previous-step button moved from the title row down to the numbered-steps row**, sitting with the rest of the step navigation instead of next to the book title. + +## [1.14.47] — 2026-07-10 + +### Added +- **"Auto-design voices (selected)" and "Auto-generate images (selected)" bulk actions in the Character Library grid** — auto-design is fully headless (generates and saves a brand-new voice per checked character from their Voice Design Prompt, no navigation to the Design screen needed); auto-generate calls the configured image provider per character and saves the result as their profile picture. Both run sequentially over the selection with live progress. + +## [1.14.46] — 2026-07-10 + +### Fixed +- **Character gender was silently wiped to blank for every non-English answer** — the character-sheet cleanup validated `gender` against only the literal strings "male"/"female"/"nonbinary"; a German book naturally gets German answers ("weiblich"/"männlich"), which failed that check and got discarded instead of normalized. Now maps common English and German variants to the canonical value. + +## [1.14.45] — 2026-07-10 + +### Fixed +- **The page could hang/appear blank for many seconds on load** — a `MutationObserver` re-ran a full-document accessibility scan (several `document.querySelectorAll` passes over the entire page) on every single `class` attribute change anywhere in the app, instead of just the changed element. This session's larger dynamic grids/cards made class mutations frequent enough to compound into multi-second main-thread blocks (visible as `requestAnimationFrame`/`setTimeout` violations). The attribute-change handler now only does the cheap, targeted nav-active-state sync it actually needs, not a full re-scan. + +## [1.14.44] — 2026-07-10 + +### Changed +- **Character Image Prompt now asks for a full reference/turnaround sheet instead of a single portrait** — full-body front view, side/back turnaround, a small expression sheet, a color palette swatch, and labeled prop/clothing callouts in one composite image, matching production concept-art conventions. Applies to both the LLM-generated prompt and the client-side fallback used before that's been generated. + +## [1.14.43] — 2026-07-10 + +### Added +- **New "Assign Voices" workflow step between Cast and Script Rehearser** — jumps straight to the current book's block in the Character Library grid (voice pick/auto/online/generate, AI image generation, and per-character sheet regeneration already live there) instead of landing on the whole cross-book library unfocused. +- **AI-generated character profile pictures** — a new "Image Generation" page under Settings > Engines lets you configure OpenAI (gpt-image-1) or Google (Gemini 2.5 Flash Image) with an API key; a "Generate" button next to each character's avatar in the full editor builds the image from the character's Image Prompt and saves it as their profile picture. Local ComfyUI (no API key needed) is a planned follow-up. + +### Fixed +- **Fixed a real "NaNNaNNaN" rendering bug in the character card grid** — a stray double `+` operator (`+ + stat(...)`) coerced HTML strings to numbers instead of concatenating them. +- **Character card avatars in the table view were rendering at full card-grid size (~90px) and overlapping the name text** — a CSS specificity bug where the base `.lib-char-avatar` rule (declared later in the file) silently beat the smaller `.lib-chars-tbl-avatar` override regardless of source order. Fixed with a properly-scoped selector; checkbox and avatar columns are also narrower now. +- **The "Cast Characters" results grid and other browsing grids were rendering full detail cards instead of compact summary cards** — unreadable wall of expanded profiles. All cast-card grids (Library, post-generation results) now render the same compact card (name, occupation, archetype, main/side, lines, gender, voice, good/evil rating) and open the full profile on click. + +### Changed +- **The character table view is now sortable by clicking column headers** (Name, Gender, Age, Lines, Language, Alignment, Voice) — reuses the same comparators as the existing Sort dropdown, with a direction toggle and arrow indicator. + +## [1.14.41] — 2026-07-10 + +### Changed +- **Character Sheets fields render as a compact list (label left, value right, thin separator) instead of stacked label-above-value blocks** — closer to the old flat field list while keeping the grouped 2-column sections. + +## [1.14.40] — 2026-07-10 + +### Changed +- **Destructive confirmations (Identify all characters, New recast) now use an in-app dialog instead of the browser's native `confirm()` popup** — the native one exposed the raw server IP/URL ("192.168.178.8:7890 says…") and couldn't be styled to match the rest of the UI. +- **A character with no generated fields yet shows a plain "profile hasn't been generated yet" note instead of a blank gap** — it was indistinguishable from data having disappeared. + +## [1.14.39] — 2026-07-10 + +### Changed +- **The Character Sheets sidebar now defaults to sorting by most lines first** instead of by generation progress — main characters (who naturally have the most dialogue) surface at the top right away instead of waiting to be alphabetically or recently touched. + +## [1.14.38] — 2026-07-10 + +### Fixed +- **"Waiting for streamed JSON output…" could get stuck forever even though generation kept completing passages normally in the background** — the fallback message only fired when the streaming request itself failed and fell back to blocking; if streaming succeeded but the model simply never emitted incremental deltas (some models buffer the whole answer into one final frame), nothing ever replaced the placeholder text, making a healthy run look permanently hung. + +## [1.14.37] — 2026-07-10 + +### Fixed +- **The Passage/Live Output resize handle was inverted** — dragging up shrank the panel and dragging down grew it, backwards from what a top-edge handle should do (regression from moving the handle from the bottom-right corner to the top edge). +- **"Lines" on the character header/sidebar was always blank during live generation** — line counts were only computed after the entire run finished; they're now tallied from the already-cast dialogue up front and attached to every sheet from the start, so they show immediately instead of staying blank until completion. + +### Changed +- **The Character Sheets sidebar is now a two-row card per character** — a bigger avatar spans both rows, the name on top, "N lines | filled/32" together underneath, instead of cramming avatar/name/count into one line. + + +- **Character sheet generation could silently merge completely unrelated characters into one sheet** — the merge logic treated a shared title/occupation/age/race/language/social-class string as proof two characters were the same person, so different characters that happened to share a generic title (e.g. several villains here all carry "Verweser der von den Orks eroberten Reichsprovinzen") got smashed into a single corrupted sheet, with each wrongful merge dumping the other character's name into "Also known as". Matching is now based only on genuine name/alias fields. Sheets already corrupted by this need a regenerate (Cast Characters → New recast) to clear up — the fix only prevents it going forward. +- **Character Sheets live preview now shows the exact source sentence behind each field** — click the small numbered mark next to a field to see the quote it was drawn from, instead of only a hover tooltip, making it easy to spot a misread at a glance. + +### Changed +- **Character Sheets live preview and card layout overhauled to match the richer casting Profil view** — 2-column grouped sections (Identität / Erscheinung / Persönlichkeit / Geschichte / Fähigkeiten / Konflikt / Beziehungen) instead of a flat field list, a full voice-picker row (Auswählen / Automatisch / Online suchen / Generieren), and the four prompt boxes (Voice Design, Image, SillyTavern, Concept Art) now only appear once actually generated instead of always showing "not generated yet" placeholders. +- **Removed the duplicated Occupation/Archetype/Also-known-as lines in the character header** — they used to appear twice (once as plain text, once as pills further down); now shown once, as pills, in the position the plain text used to occupy. Added a "Lines" count and a highlight-color picker to the header too. +- **The Character Sheets sidebar can now be sorted** — Progress (default), A–Z, Lines, or Gender, matching the sort control already on the casting roster sidebar. + +### Fixed +- **Character Sheets no longer pulls in stale characters from earlier casting attempts** — the sheet generation list was unioning the current live cast with *every* character record ever saved for the book, including junk entries ("Unbekannter Mann", "Turmwächter", etc.) from before recasts/merges/quality runs cleaned things up. It now only reuses saved records that still match a name in the current cast. +- **Character line counts on the Character Sheets panel were silently always zero** — the code treated the roster (a plain array of names) as a lookup map, so counts never attached; now tallied directly from the segments. + +### Changed +- **The Reader's step wizard is now two rows instead of one horizontally-scrolling strip** — book title (with back-to-previous-step arrow) on top, the numbered step sequence below, so the steps aren't hidden behind a scrollbar. +- **The Casting audiobook footer is now three grouped flyout buttons instead of eight flat ones** — "Identify Characters" (all / unknown / verify), "Cast Characters" (all / selected / new recast — a fresh rebuild that discards existing sheets), and "Cast" (view / export). "Open Script Rehearser" and "Continue casting" stay as direct one-click buttons. The flyouts are fixed-position and anchored to their trigger button (not nested in a clipping container), unlike the old dropdown this replaces a second time. +- **The Character Sheets "Passage / Live output" panel now has a proper drag handle on its top edge** — a small centered grip bar you drag up/down to resize, replacing the boxed icon button that floated awkwardly over the bottom-right corner of the output text. + +## [1.14.32] — 2026-07-10 + +### Changed +- **"Verify all characters" is now a real second opinion, not a rerun of the first pass** — it uses a distinct plausibility-check prompt that judges whether each existing label actually holds up (rather than reclassifying from scratch), now also re-examines every Narrator line specifically to catch spoken dialogue that got swallowed into narration, and resolves any name the model returns against saved aliases in code (e.g. "Vampire" → "Zerwas") instead of leaving alternate names as separate/Unknown speakers. + +## [1.14.31] — 2026-07-10 + +### Fixed +- **Merging a character now only redraws the lines that actually changed speaker, instead of the entire feed** — a 2-line alias merge on a large book was rebuilding all ~2000 rows (each re-running the highlight regex pass), which is what made even a tiny merge take as long, and as freeze-prone, as opening a freshly cast book. Affected rows are patched in place now; only merges touching hundreds of lines still show the progress bar for more than an instant. + +## [1.14.30] — 2026-07-10 + +### Changed +- **The characters sidebar now shows skeleton placeholder rows while a live cast is still reading and hasn't found any characters yet**, instead of a static "reading…" label, so it's obvious the panel is actively working rather than stuck. + +## [1.14.29] — 2026-07-10 + +### Fixed +- **Restored the "Add alias / also known as" tag button on each character in the casting roster** — it was silently dropped from the roster row template during the recent Library cast card rework, even though the popup and merge logic behind it were still intact. + +## [1.14.28] — 2026-07-10 + +### Fixed +- **Rebuilt the stale `static/dist/main.min.js` bundle** — `loader.js` always tries this prebuilt bundle before falling back to individual `static/js/*.js` files, so any edit to those files (however many restarts or cache-busts) had no visible effect until the bundle itself was regenerated with `npm run minify`. + +## [1.14.27] — 2026-07-10 + +### Fixed +- **Reopening a large already-cast audiobook no longer freezes the tab** — restoring a saved cast with thousands of segments now redraws the feed in animation-frame batches instead of one giant synchronous pass, so the browser stays responsive instead of showing "Page Unresponsive" and looking like casting silently restarted on its own. + +### Changed +- **Character views now share a cleaner header and a wider overview card layout** — the Library cast cards are larger and more graphic, the profile header keeps the important identity info without duplicating aliases, and the audiobook profile view now shows line count with separate Auto refine and Edit actions. +- **The audiobook footer buttons now read like the actions they perform** — Identify unknown characters, Identify all characters, Verify all characters, Cast all/selected character roles, View cast, export, and Script Rehearser are all direct buttons in a clearer left-to-right order, so the old flyout-style wording is gone. +- **The Library cast table now uses a much smaller profile icon and tighter sticky columns** — the avatar no longer dominates the first columns, so the name and metadata scan more like a real table. --- diff --git a/VERSION b/VERSION index b637c8f..c0e9b0b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.14.24 +1.17.95 diff --git a/core/config.py b/core/config.py index 0ce7a46..8721b44 100644 --- a/core/config.py +++ b/core/config.py @@ -39,6 +39,19 @@ _SETTINGS_KEYS = { "client_voice_bindings", "llm_url", "llm_model", "llm_api_key", "engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards", + # Character portrait generation (cloud APIs + local ComfyUI) + "image_gen_provider", "image_gen_model", + "comfyui_url", "comfyui_workflow", "comfyui_prompt_node_id", + "comfyui_prompt_field", "comfyui_output_node_id", + # Inbound API key — gates non-browser callers (external scripts, MCP + # clients) hitting this app's own /api/* and /mcp routes. Distinct from + # every *outbound* key above, which are credentials this app sends to + # OTHER services. Off by default (external_api_key_required=False) — + # this is a live, actively-used app and the same-origin detection this + # gate relies on has never been exercised against real browser traffic, + # so enforcing it unconditionally risked locking out the working UI on + # an untested edge case. Turn it on deliberately once confirmed safe. + "external_api_key", "external_api_key_required", # Browser-persistent UI state "refine_llm_url", "conv_llm_url", "seed_finder_text", "seed_finder_dir", "pt_dir", "audiobook_prompt", @@ -51,7 +64,17 @@ _TTS_STABILITY_BY_BACKEND_DEFAULT = { "voice_clone": dict(_TTS_STABILITY_DEFAULT), "streaming": dict(_TTS_STABILITY_DEFAULT), "customvoice": dict(_TTS_STABILITY_DEFAULT), - "voice_design": dict(_TTS_STABILITY_DEFAULT), + # NOT voice_design: the stability block's fixed seed=0 + temperature=0.1 + # exists so repeated reads of the SAME cloned voice sound consistent + # across takes — exactly backwards for voice design, where every call is + # supposed to produce a DIFFERENT voice from a different character + # prompt. Pinning the model's random draw meant the prompt text was the + # only source of variation, and low temperature flattened even that — + # confirmed live: auto-designed voices for different characters all + # sounded near-identical. Leaving this empty lets the backend use its + # own natural randomization per call, same as the other creative-voice + # backends below. + "voice_design": {}, "nvidia_magpie": {}, "nvidia_zeroshot": {}, "nvidia_flow": {}, @@ -286,6 +309,15 @@ def _load_settings() -> dict: "engine_container_names": {}, "engine_api_keys": {}, "custom_engine_cards": [], + "image_gen_provider": "", + "image_gen_model": "", + "comfyui_url": "http://host.docker.internal:8188", + "comfyui_workflow": "", + "comfyui_prompt_node_id": "", + "comfyui_prompt_field": "text", + "comfyui_output_node_id": "", + "external_api_key": "", + "external_api_key_required": False, "refine_llm_url": "", "conv_llm_url": "", "seed_finder_text": "", @@ -316,6 +348,21 @@ def _load_settings() -> dict: return dict(result) +def _ensure_external_api_key() -> str: + """Returns the app's inbound API key, generating + persisting one on + first use. Called lazily by the auth middleware rather than at startup, + so a fresh install doesn't need a migration step.""" + import secrets + settings = _load_settings() + key = (settings.get("external_api_key") or "").strip() + if key: + return key + key = secrets.token_urlsafe(32) + settings["external_api_key"] = key + _save_settings(settings) + return key + + def _save_settings(s: dict) -> None: global _settings_cache, _settings_cache_mtime, _settings_cache_db_updated CONFIG_DIR.mkdir(parents=True, exist_ok=True) diff --git a/core/database.py b/core/database.py index 55592ef..739e731 100644 --- a/core/database.py +++ b/core/database.py @@ -134,6 +134,10 @@ def _row_to_reh(row: sqlite3.Row) -> dict: return d +def _reh_title_key(title: Any) -> str: + return " ".join(str(title or "").split()).casefold() + + # ── Characters ──────────────────────────────────────────────────────────────── def char_get_all() -> list[dict]: @@ -149,6 +153,15 @@ def char_get(char_id: str) -> dict | None: def char_put(rec: dict) -> dict: + image = rec.get("image") + if isinstance(image, str) and image.startswith("/api/characters/"): + # /api/characters (the bulk list) hands out a lightweight image URL + # instead of the real base64 blob (see routes/characters.py). A record + # round-tripped from that list and saved back here would otherwise + # silently overwrite the real stored portrait with this placeholder + # string — keep whatever is already on the row instead. + existing = char_get(rec.get("id", "")) + rec = {**rec, "image": existing.get("image") if existing else None} with _open() as conn: conn.execute(""" INSERT INTO characters (id, book, name, tags, voice, image, sheet, analysis, created, updated) @@ -196,11 +209,63 @@ def reh_get(reh_id: int) -> dict | None: return _row_to_reh(row) if row else None +def reh_find_by_title(title: str) -> dict | None: + key = _reh_title_key(title) + if not key: + return None + with _open() as conn: + rows = conn.execute( + "SELECT * FROM rehearsals ORDER BY updated DESC, id DESC" + ).fetchall() + for row in rows: + rec = _row_to_reh(row) + if _reh_title_key(rec.get("title")) == key: + return rec + return None + + +def reh_compact_titles() -> int: + """Remove older duplicate rehearsals that share the same title. + + We keep the most recently updated row for each normalized title and delete + the rest. This prevents the library from accumulating repeated copies when + auto-save/import paths reuse the same book title. + """ + with _open() as conn: + rows = conn.execute( + "SELECT id, title, updated FROM rehearsals ORDER BY updated DESC, id DESC" + ).fetchall() + seen: set[str] = set() + delete_ids: list[int] = [] + for row in rows: + key = _reh_title_key(row["title"]) + if not key: + continue + if key in seen: + delete_ids.append(int(row["id"])) + else: + seen.add(key) + for reh_id in delete_ids: + conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,)) + if delete_ids: + conn.commit() + return len(delete_ids) + + def reh_put(rec: dict) -> dict: """Insert (no id) or full replace (id present). Returns record with id.""" reh_id = rec.get("id") + title = str(rec.get("title", "") or "") + if not reh_id and title.strip(): + existing = reh_find_by_title(title) + if existing: + reh_id = existing["id"] + # Keep the original created timestamp when a title-based save updates + # an existing rehearsal rather than creating a new library copy. + rec = dict(rec) + rec["created"] = existing.get("created") or rec.get("created") row = { - "title": rec.get("title", ""), + "title": title, "script": rec.get("script", ""), "cast": _j(rec.get("cast", {})), "emotions": _j(rec.get("emotions", {})), diff --git a/core/tts_helpers.py b/core/tts_helpers.py index d960675..f938838 100644 --- a/core/tts_helpers.py +++ b/core/tts_helpers.py @@ -634,27 +634,42 @@ def _voice_design_dialogue_request_audio( # ── Benchmark request ───────────────────────────────────────────────────────── -def _tts_benchmark_request(text: str, voice: str, settings: dict, label: str) -> dict: - endpoint, payload, tts_hdrs = _tts_request_config(text, voice, settings, "wav") +def _tts_benchmark_request(text: str, voice: str, settings: dict, label: str, is_designed: bool = False) -> dict: start = time.perf_counter() first_audio_at = None raw = bytearray() media_type = "audio/wav" - with _post_tts_with_fallback(endpoint, payload, tts_hdrs, stream=True, timeout=180) as resp: - resp.raise_for_status() - media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" - for chunk in resp.iter_content(chunk_size=512): - if not chunk: - continue - raw.extend(chunk) - if first_audio_at is None: - if payload.get("response_format") == "wav" or "wav" in media_type.lower(): - offset = _wav_data_offset(bytes(raw)) - if offset is not None and len(raw) > offset: + if is_designed: + # A designed voice has no reference WAV to clone from — it can only + # ever be synthesized through the voice_design engine (same dispatch + # as /api/tts-preview's backend=='voice_design' branch), never the + # generic voice_clone-style request this function otherwise builds. + # Previously every voice benchmarked through the one fixed tts_url + # regardless of origin, so a designed voice's benchmark only ever + # "worked" by coincidence when that unrelated clone engine happened + # to also be reachable — confirmed live: with it down, EVERY voice + # in an all-designed batch failed the benchmark even though the + # voice_design engine itself was reachable the whole time. + audio_bytes, media_type = _voice_design_voice_request_audio(voice, text, settings) + raw.extend(audio_bytes) + first_audio_at = time.perf_counter() + else: + endpoint, payload, tts_hdrs = _tts_request_config(text, voice, settings, "wav") + with _post_tts_with_fallback(endpoint, payload, tts_hdrs, stream=True, timeout=180) as resp: + resp.raise_for_status() + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + for chunk in resp.iter_content(chunk_size=512): + if not chunk: + continue + raw.extend(chunk) + if first_audio_at is None: + if "wav" in media_type.lower(): + offset = _wav_data_offset(bytes(raw)) + if offset is not None and len(raw) > offset: + first_audio_at = time.perf_counter() + else: first_audio_at = time.perf_counter() - else: - first_audio_at = time.perf_counter() total = time.perf_counter() - start if not raw: diff --git a/core/voice.py b/core/voice.py index 1e9dcb5..e9a026d 100644 --- a/core/voice.py +++ b/core/voice.py @@ -137,6 +137,30 @@ def _backup_path(audio: Path) -> Path: return audio.with_name(f".{audio.stem}.original{audio.suffix}.bak") +def _picture_backup_path(picture: Path) -> Path: + return picture.with_name(f".{picture.stem}.original{picture.suffix}.bak") + + +def _backup_existing_picture(wav: Path) -> None: + # A voice's picture had no equivalent to _backup_original_voice's + # copy-before-overwrite protection — confirmed live as real, unrecoverable + # data loss: a shared library voice cloned from a real person's own + # reference photo got silently overwritten (the previous file just + # unlink()'d, nothing copied first) the moment an unrelated feature + # elsewhere pushed a different picture onto it. Best-effort and silent by + # design, same as the audio backup — this must never block/break the + # actual upload it's protecting. + existing = _picture_path(wav) + if not existing: + return + backup = _picture_backup_path(existing) + if not backup.exists(): + try: + shutil.copy2(str(existing), str(backup)) + except OSError: + pass + + def _legacy_backup_path(audio: Path) -> Path: return audio.with_name(f"{audio.stem}.original{audio.suffix}") @@ -375,10 +399,13 @@ def _benchmark_summary(runs: list[dict]) -> dict: def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str]]) -> dict: from core.tts_helpers import _tts_benchmark_request + has_ref, _transcript = _read_reference_text(audio) + meta = _load_meta(audio) + is_designed = meta.get("origin") == "designed" or not has_ref runs: list[dict] = [] for label, text in sentences: try: - runs.append(_tts_benchmark_request(text, audio.stem, settings, label)) + runs.append(_tts_benchmark_request(text, audio.stem, settings, label, is_designed=is_designed)) except Exception as e: runs.append({"ok": False, "label": label, "text": text, "error": str(e)}) diff --git a/core/voice_index.py b/core/voice_index.py index e4e59f0..c2c0319 100644 --- a/core/voice_index.py +++ b/core/voice_index.py @@ -124,6 +124,17 @@ def rebuild_voice_index(settings: dict) -> list[dict]: rows.append(_row_for_audio(settings, audio)) except Exception as exc: logger.warning("Could not index voice %s: %s", audio, exc) + # This scan can take a noticeable while over 100+ voices, and runs in a + # background thread while the app keeps serving requests — including + # DELETE /api/voice/{id}, which removes its index row immediately. + # A delete landing mid-scan (before this point) leaves that voice's row + # sitting in `rows` from when it still existed on disk; replacing the + # WHOLE table with that stale snapshot would resurrect it right after the + # delete already removed it. Confirmed live as deleted voices reappearing. + # Re-check existence right here, as close to the write as possible, to + # shrink that window down from "however long the scan took" to next to + # nothing. + rows = [row for row in rows if Path(row["path"]).exists()] voice_index_replace_all(rows) return [row["entry"] for row in rows] diff --git a/routes/characters.py b/routes/characters.py index 731de47..27e3a3d 100644 --- a/routes/characters.py +++ b/routes/characters.py @@ -9,16 +9,55 @@ Mirrors the IndexedDB API in characters-library.js so the JS swap is mechanical: """ from __future__ import annotations +import base64 +import re + from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import Response from core.database import char_get_all, char_get, char_put, char_delete router = APIRouter() +_DATA_URL_RE = re.compile(r"^data:(image/[\w.+-]+);base64,(.+)$", re.DOTALL) + @router.get("/api/characters") async def characters_list(): - return {"characters": char_get_all()} + # Swap each character's raw base64 portrait for a lightweight URL — + # the list endpoint is fetched in bulk (e.g. rendering a full cast grid), + # and re-sending every character's full image blob inline made that + # payload/DOM balloon to tens of megabytes for a book with dozens of + # portraits, blocking rendering with no visual feedback. Single-record + # fetches (characters_get below) still return the real base64. + chars = char_get_all() + for c in chars: + if c.get("image"): + c["image"] = f"/api/characters/{c['id']}/image" + return {"characters": chars} + + +@router.get("/api/characters/{char_id:path}/image") +async def characters_image(char_id: str): + # Character portraits are stored inline as base64 data: URLs (clUpsert/ + # clSetImage write straight into the `image` column) — fine for a single + # avatar per card, but Script Rehearser's Stage view renders one avatar + # PER DIALOGUE LINE, and a character can speak hundreds of lines. Inlining + # the raw data URL into every line's HTML re-embeds the same multi-KB/MB + # blob hundreds of times, ballooning the page to hundreds of megabytes and + # silently failing to render at all (confirmed live on a 1968-line book). + # Serving it as a real URL means the browser fetches/caches it once. + rec = char_get(char_id) + if rec is None or not rec.get("image"): + raise HTTPException(404, "No image") + m = _DATA_URL_RE.match(rec["image"]) + if not m: + raise HTTPException(404, "Invalid image data") + try: + raw = base64.b64decode(m.group(2)) + except Exception: + raise HTTPException(404, "Invalid image data") + return Response(content=raw, media_type=m.group(1)) @router.get("/api/characters/{char_id:path}") diff --git a/routes/conversation.py b/routes/conversation.py index 769944b..2d02c8f 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import base64 import contextlib +import copy import io import json import re @@ -12,6 +13,7 @@ import time import uuid import wave from pathlib import Path +from urllib.parse import quote import requests from typing import Optional @@ -600,8 +602,9 @@ def _charsheets_prepare(data: dict) -> dict: f"The source text is in {language}.\n" f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n" f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, " - f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, " - f"conflict_style, win_condition, archetype, aliases, first_name, last_name, full_name, title.\n" + f"capabilities, backstory, relationships, motivation, fears, mannerisms, communication_style, voice_pattern, " + f"secret, conflict_style, win_condition, archetype, aliases, first_name, last_name, title, age_estimate, " + f"race_species, languages, nationality_background, social_class, profession, reputation, religious_beliefs, notes.\n" f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n" f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n" f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n" @@ -615,7 +618,20 @@ def _charsheets_prepare(data: dict) -> dict: "- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name " "and put only the proven alternate form in aliases/title/full_name.\n" "- Do NOT invent brand-new profiles in this pass. Only refine the already-casted roster; ignore places, institutions, and other non-person entities.\n" - "- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n" + "- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n" + "- Before deciding a passage is about someone else: check whether the person could BE one of the targets " + "under an alias, title, role, or nickname instead of their literal listed name — check both this passage's " + "own context (action beats, who's being addressed, profession mentioned) AND the 'existing sheets so far' " + "below, which may already record that alias for a target (e.g. a target's existing sheet says " + "'aliases: der Schmied' — a passage about 'der Schmied' with no other name given IS that target, attribute " + "it there, do not skip it just because the passage never says the target's literal name).\n" + "- Only if, after that check, the passage is clearly about a DIFFERENT person who is NOT one of the listed " + "targets and NOT an alias/role already recorded for one of them (a scene that doesn't actually involve any " + "target), output NO sheet for this passage at all — an empty 'sheets' array is correct and expected. Never " + "force unrelated content about someone else into a target's profile just because a target happens to be " + "listed and you can't create a new one — but do not use this as an excuse to skip a passage that genuinely " + "is about a target under an alias; skipping a real match is just as wrong as gluing a fact onto the wrong " + "character.\n\n" ) if target_mode and known else "" # A user-editable prompt (client-side "Prompt" panel, mirroring the @@ -642,14 +658,19 @@ def _charsheets_prepare(data: dict) -> dict: "dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.\n" "For each character output these fields:\n" "- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n" - "- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n" - "- first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty.\n" + "- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain.\n" + "- first_name, last_name: split the character identity when known. Leave unknown parts empty.\n" "- title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').\n" "- profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').\n" + "- age_estimate: estimated age or age range, if inferable\n" + "- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant\n" + "- languages: spoken languages / dialects / tongues, comma-separated if multiple\n" + "- nationality_background: homeland, culture, origin, or social background if known\n" + "- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.)\n" "- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n" "- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.\n" - "- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n" - "- clothing: distinctive clothing, armour, accessories — as observed in the text\n" + "- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known.\n" + "- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look\n" "- alignment: strict moral code + the one line they will never cross\n" "- moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous\n" "- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'\n" @@ -662,28 +683,34 @@ def _charsheets_prepare(data: dict) -> dict: "- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)\n" "- fears: their deepest fears, phobias or dread\n" "- mannerisms: habitual gestures, tics, body language, habits and quirks\n" + "- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.\n" "- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n" - "- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.\n" + "- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. " + + ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though the prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ") + + "Do NOT mention plot spoilers.\n" "- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n" "- inventory: 1-3 defining items/props/clothing (array of short strings)\n" "- secret: dark secret or fatal flaw\n" "- conflict_style: fight, flight, or manipulate — how they act when cornered\n" "- win_condition: the specific event that would make them feel they have won\n" + "- reputation: how other characters or society see them\n" + "- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it\n" + "- notes: brief catch-all notes for useful details that do not fit elsewhere\n" "- tier: 'main' or 'supporting'\n" "- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, " "a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). " "Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.\n" "IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). " - "Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. " - "Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.\n" + "Examples: 'Marcus', 'the Executioner', and 'Bloodfang' may all refer to ONE profile if context shows they are the same person. " + "Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title and keep one canonical name.\n" "Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. " "Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n" "Respond with STRICT JSON only:\n" - '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","archetype":"","gender":"","physical":"","clothing":"",' + '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"",' '"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",' '"attribute_high":"","attribute_low":"","skills":"","capabilities":"",' - '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",' - '"inventory":[],"secret":"","conflict_style":"","win_condition":"",' + '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",' + '"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"",' '"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think' ) lang_reminder = ( @@ -773,9 +800,19 @@ def _charsheets_parse(raw: str) -> dict: arc = str(s.get("arc_direction") or "neutral").strip() if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"): arc = "neutral" - gender = str(s.get("gender") or "").strip().lower() - if gender not in ("male", "female", "nonbinary"): - gender = "" + # The model answers in whatever language the source text is in (this + # app is used heavily with German books), so a strict English-only + # whitelist silently discarded valid answers like "weiblich"/"männlich" + # instead of normalizing them — every non-German-speaking character's + # gender was quietly wiped to blank. + gender_raw = str(s.get("gender") or "").strip().lower() + _GENDER_NORMALIZE = { + "male": "male", "m": "male", "man": "male", "mann": "male", "männlich": "male", + "female": "female", "f": "female", "woman": "female", "frau": "female", "weiblich": "female", + "nonbinary": "nonbinary", "non-binary": "nonbinary", "n": "nonbinary", + "nichtbinär": "nonbinary", "nicht-binär": "nonbinary", "divers": "nonbinary", + } + gender = _GENDER_NORMALIZE.get(gender_raw, "") s.update({ "name": name, "aliases": aliases, "inventory": inv[:3], "tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting", @@ -999,6 +1036,117 @@ async def character_deep_analysis(request: Request): return {"name": name, "analysis": analysis} +def _silly_tavern_prompt_instruction(sheet: dict) -> str: + """Full SillyTavern character-card template (card + scenario + first + message), adapted from a user-supplied prompt originally written for + building a card from scratch via internet research (fandom/wikipedia). + Here the character's profile is already fully known from the book, so + the internet-research instructions are dropped and replaced with + 'use ONLY the profile provided' (already stated in the shared preamble + this gets appended to) — everything else (exact field structure, the + generic-but-deliberately-open Scenario block, the First Message rules) + is kept close to the original since it's a well-tested format.""" + gender = str((sheet or {}).get("gender") or "").strip().lower() + is_female = gender.startswith("f") + is_male = gender.startswith("m") + + if is_female: + appearance = ( + 'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/' + 'long (arms-length)/short (chin-length)], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, ' + 'body: [PICK FROM:slim/curvy, PICK FROM:perfect figure/sensual/abs, PICK FROM:light skin/tanned skin/' + 'brown skin/green skin/blue skin/red skin], breasts: [SIZE, CUP, PICK FROM:big areolas/medium-sized ' + 'areolas/small areolas, PICK FROM:cherry-tan nipples/cherry-pink nipples/honey-tan nipples/' + 'golden-brown nipples/dark-brown nipples], armpit hair: PICK FROM:shaved/natural, ' + 'pubic hair: PICK FROM:shaved/natural, fingernails: PICK FROM:natural/painted (color), ' + 'toenails: PICK FROM:natural/painted (color)' + ) + outfits = ( + '{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), ' + 'DESCRIBE SHOES (COLOR), lingerie: [lace bra (COLOR), lace thong (COLOR)]}\n' + '{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), ' + 'DESCRIBE SHOES (COLOR), lingerie: [lace bra (color), lace thong (color)]}\n' + '{{"Sleeping Outfit"}}:{nightgown (COLOR), thong (COLOR), soft slippers (white)}\n' + '{{"Running Outfit"}}:{sports bra (COLOR), leggings (COLOR), sports shoes (white), lingerie: thong (COLOR)}\n' + '{{"Exercise Outfit"}}:{sports bra (COLOR), leggings (COLOR), bare feet, lingerie: lace thong (COLOR)}\n' + '{{"Swimsuit"}}:{PICK FROM: bikini/one-piece (COLOR), DESCRIBE SHOES (COLOR)}' + ) + elif is_male: + appearance = ( + 'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/' + 'long (arms-length)/short (chin-length)], facial hair: PICK FROM:beard/goatie/beard & moustache/' + 'moustache/clean-shaven, eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, ' + 'body: [PICK FROM:slim/muscular/bulky/fat, PICK FROM:light skin/tanned skin/brown skin/green skin/' + 'blue skin/red skin], penis: [SIZE, LENGTH cm, PICK FROM:big balls/medium-sized balls/small balls, ' + 'PICK FROM:circumcised/uncircumcised], armpit hair: PICK FROM:shaved/natural, ' + 'pubic hair: PICK FROM:shaved/natural' + ) + outfits = ( + '{{"Main Outfit"}}:{DESCRIBE TOP (color), DESCRIBE BOTTOM (color), DESCRIBE SHOES (COLOR), ' + 'lingerie: DESCRIBE LINGERIE (COLOR)}\n' + '{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), ' + 'DESCRIBE SHOES (COLOR), lingerie: DESCRIBE LINGERIE (COLOR)}\n' + '{{"Sleeping Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, soft slippers (white)}\n' + '{{"Running Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, sports shoes (white), lingerie: DESCRIBE LINGERIE (COLOR)}\n' + '{{"Exercise Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, bare feet, lingerie: DESCRIBE LINGERIE (COLOR)}\n' + '{{"Swimsuit"}}:{DESCRIBE BOTTOM, DESCRIBE SHOES (COLOR)}' + ) + else: + # Gender unknown/non-binary/narrator role — keep the same card + # shape but skip the anatomy-specific appearance fields entirely + # rather than guessing a binary that doesn't fit. + appearance = ( + 'hair: [COLOR, STYLE, LENGTH], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, ' + 'body: [BUILD, SKIN TONE], distinguishing features: DESCRIBE' + ) + outfits = ( + '{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n' + '{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n' + '{{"Sleeping Outfit"}}:{DESCRIBE SLEEPWEAR, soft slippers (white)}' + ) + + return ( + "Produce exactly this field:\n" + "- silly_tavern_prompt: a complete SillyTavern character card for this character, in the EXACT format " + "below — a card body, then a Scenario block, then a First Message. Fill every field from the character " + "profile above; only invent a value when the profile truly has nothing for it, and mark anything invented " + "with a trailing '*'. Do not add bullet points, extra spaces, or commentary — follow the formatting " + "exactly. Do not replace '{{char}}' with the character's actual name — keep it literal. Keep every '{', " + "'}', '[', ']', '(', ')' character exactly as shown.\n\n" + "{{char}}:\n" + "{\n" + '{{"Personal Information"}}:{name: NAME, surname: SURNAME, race: PICK FROM PROFILE OR INFER, ' + "nationality: NATIONALITY, gender: GENDER, age: AGE, profession: PROFESSION, " + "residence: [PLACE, TYPE OF DWELLING], marital status: MARITAL STATUS}\n\n" + f'{{{{"Appearance"}}}}:{{{appearance}}}\n\n' + '{{"Personality"}}:{A DETAILED, SPECIFIC DESCRIPTION OF THIS CHARACTER\'S OWN PERSONALITY, SPEECH PATTERN ' + "AND QUIRKS FROM THE PROFILE ABOVE — NOT A GENERIC PERSONALITY TYPE. BE SPECIFIC TO THIS CHARACTER.}\n\n" + '{{"Likes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n' + '{{"Dislikes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n' + '{{"Goals"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n' + '{{"Skills"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n' + '{{"Weapons"}}:{LIST ONLY IF THIS CHARACTER PLAUSIBLY CARRIES ONE PER THE PROFILE — OMIT THIS FIELD ' + "ENTIRELY OTHERWISE}\n\n" + f"{outfits}\n" + "}\n\n" + "Then, in the SAME string, add a scenario block as clear instructions/definitions for the LLM, not " + "narration — {{char}}'s relationship with {{user}}, everyday routine, current mood, current plans. Keep " + "it open-ended (many different stories could start from it) rather than building one specific scene. " + "Use this exact structure:\n\n" + '{{"Scenario"}}:{"{{char}} is living everyday life","{{char}} and {{user}} keep crossing each other\'s ' + 'paths as {{char}} and {{user}} relationship develops","everyday routine":["mornings":"{{char}} GENERATE",' + '"days":"{{char}} GENERATE","evenings":"{{char}} GENERATE"],"current mood":"{{char}} GENERATE"]}\n\n' + "Then add a section literally titled 'First Message:' on its own line, followed by the message itself: " + "maximum 3 paragraphs, balancing narration with {{char}} dialogue, true to the profile's personality and " + "the scenario above. Never decide what {{user}} does or says. Avoid describing eyes. Use direct speech " + "with no markdown for dialogue, and *asterisks* for narration.\n\n" + "The finished silly_tavern_prompt string MUST contain all three parts, in this order: the {{char}} card " + "block, the {{\"Scenario\"}} block, and the 'First Message:' section — never stop after the card or the " + "Scenario alone.\n\n" + 'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think' + ) + + @router.post("/api/character-generate-prompts") async def character_generate_prompts(request: Request): """Turn an already-extracted character sheet into four ready-to-use external @@ -1053,45 +1201,89 @@ async def character_generate_prompts(request: Request): "voice_design_prompt": ( preamble + "Produce exactly this field:\n" - "- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, " + "- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (25-60 words, one paragraph, " "no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, " - "accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not " - "mention plot events — describe only how the voice should SOUND.\n\n" + "accent or register, emotional baseline, and suitability for audiobook dialogue delivery. " + + ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though this prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ") + + + "Generic category labels alone ('young female voice, energetic tone, clear pitch') describe a whole " + "demographic, not a person, and different characters who happen to share an age/gender end up " + "sounding like the same person — confirmed live as a real problem with several young-female " + "characters in one book. Every one of these fields needs a SPECIFIC, CONCRETE choice, not the safe " + "generic default: pick a distinctive timbre (breathy/husky/bright/nasal/silvery/gravelly/reedy, not " + "just 'clear'), a specific pace/rhythm quirk (clipped consonants, unhurried drawl, rapid-fire, " + "deliberate pauses before key words), and a specific emotional baseline drawn from THIS character's " + "own personality/backstory (guarded warmth, brittle confidence, weary sarcasm — not just 'friendly' " + "or 'energetic'). Two characters with the same age and gender in your profile should still end up " + "with visibly different prompts once you've done this. Do not mention plot events — describe only " + "how the voice should SOUND.\n\n" 'Respond with STRICT JSON only: {"voice_design_prompt":""}/no-think' ), "image_prompt": ( preamble + "Produce exactly this field:\n" - "- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. " - "Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, " - "clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style " - "(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors " - "are fine.\n\n" + "- image_prompt: a detailed English image-generation prompt for a full CHARACTER REFERENCE SHEET " + "(a single composite image, like a game/animation production turnaround), not just one portrait. " + "FIRST, from the book title and profile, work out the story's genre, setting, and era (e.g. " + "'medieval European-inspired high fantasy', 'grimdark low fantasy', 'space opera sci-fi', " + "'contemporary urban fantasy') — an image model has no idea what a book title implies and will " + "default to generic modern/real-world imagery unless told explicitly, which is exactly wrong for " + "an occupation like 'Admiral' or 'General' in a fantasy world (it will draw a 20th-century military " + "uniform instead of that world's actual equivalent). State that genre/setting/era explicitly, as " + "its own descriptor near the START of the prompt, and make clear the world has no real-world 20th " + "or 21st century technology, uniforms, or clothing unless the story is actually confirmed " + "contemporary/near-future — every other visual choice (armor, dress, rank insignia, weapons) must " + "fit THAT world, not the real one. Then ask the image model for: (1) a full-body front-view " + "illustration as the anchor, (2) a turnaround panel with side and back views, (3) a small " + "expression sheet with 3-5 headshots showing this character's typical emotional range " + "(calm/determined/etc. — pick expressions that fit their personality), (4) a color palette swatch " + "panel for hair/eyes/outfit, (5) callouts for their signature props, tools, or clothing details " + "with short labels. Include age impression, build, hair/eyes/skin if known, and clothing " + "appropriate to the established setting. Specify a clean production-design/concept-art layout " + "with a plain neutral background, and explicitly note this is an ORIGINAL character, not based on " + "any copyrighted character. One dense paragraph, comma-separated descriptors are fine.\n\n" 'Respond with STRICT JSON only: {"image_prompt":""}/no-think' ), - "silly_tavern_prompt": ( - preamble - + "Produce exactly this field:\n" - "- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their " - "own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), " - "'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character " - "greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character " - "lines showing their manner of speech). Keep each section a few lines at most.\n\n" - 'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think' - ), + "silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet), "concept_art_prompt": ( preamble + "Produce exactly this field:\n" - "- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — " - "a turnaround/reference sheet with multiple views and expressions: front view, side or back view, " - "2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, " - "in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, " - "white background').\n\n" + "- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC " + "reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, " + "Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph " + "(not a list). This mirrors a well-tested prompt-engineering pattern for these sheets; follow it " + "precisely rather than writing free-form:\n" + " Task: name the sheet type, e.g. 'Generate a character/NPC design sheet.'\n" + " Subject: 'an original adult [role/archetype from the profile]' plus its 3-6 most visually " + "distinctive, ALREADY-ESTABLISHED traits (skin/hair, signature garment layers, and — only if the " + "profile actually gives this character a prop, weapon, or tool — its exact count, e.g. 'exactly one " + "quiver' or 'exactly two throwing knives'; omit props entirely if the profile has none).\n" + " Context: one clause on what the sheet is for and the story's genre/setting/era — infer this from " + "the book/profile the same way you would for a portrait (a fantasy Admiral is NOT a real-world 20th-" + "century Admiral) and state it explicitly, since an image model defaults to generic modern imagery " + "otherwise.\n" + " Style: a concept-art style matching that genre/setting (painterly game concept art / detailed " + "semi-realistic concept art / hand-painted concept art — pick what fits), grounded materials, clear " + "shape language.\n" + " Composition: full-body front, side, and back views across the top; below them, ONE clean row of " + "isolated callouts for this character's established props/costume components ONLY (skip this row " + "entirely if the profile establishes no distinct props/costume pieces worth separating out) — state " + "the exact number of callouts and name each one.\n" + " Lighting: neutral studio/museum-style light, no cinematic color cast.\n" + " Constraints: face/silhouette/garment/prop consistency across every view; the exact prop count " + "restated; no duplicate gear, no extra limbs, no readable text/logos/watermark, no real-world/" + "franchise references, this is an ORIGINAL character not based on any copyrighted one.\n" + " Output: one production-ready 3:2 reference sheet.\n\n" 'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think' ), } requested = [f for f in (data.get("fields") or []) if f in all_groups] field_groups = [((f,), all_groups[f]) for f in (requested or all_groups.keys())] + # The SillyTavern card is a full structured card + scenario + first + # message now, not a few short labelled lines — needs a much bigger + # budget than the other three (single-paragraph) prompt fields or it + # reliably truncates mid-card. + _field_max_tokens = {"silly_tavern_prompt": 3000} def _call_group(fields: tuple, system: str) -> dict: payload: dict = { @@ -1100,7 +1292,7 @@ async def character_generate_prompts(request: Request): {"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."}, ], "temperature": 0.7, - "max_tokens": 1536, + "max_tokens": _field_max_tokens.get(fields[0], 1536), } if model: payload["model"] = model @@ -1148,6 +1340,417 @@ async def character_generate_prompts(request: Request): return out +@router.post("/api/audiobook-consistency-check") +async def audiobook_consistency_check(request: Request): + """Check whether every line already attributed to ONE character across the + whole book actually sounds like them — using the character's OWN other + lines as the reference, not a chunk-local judgement. Unlike the casting/ + verification passes (which re-read the source text chunk by chunk), this + works purely over already-attributed lines gathered from anywhere in the + book, so it can catch a character who briefly "borrows" someone else's + voice in a way no single passage-sized chunk would ever expose. + + Body: {character, lines: [{index, text}], known_characters: [...], llm_url, model} + Returns: {outliers: [{index, reason, suggested_speaker}]} + """ + data = await request.json() + character: str = (data.get("character") or "").strip() + lines: list = data.get("lines") or [] + known: list = data.get("known_characters") or [] + _settings = _load_settings() + llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") + model: str = (data.get("model") or _settings.get("llm_model") or "").strip() + if not character: + raise HTTPException(400, "Character name required") + if not lines: + raise HTTPException(400, "No lines to check") + + numbered = "\n".join( + f"[{l.get('index')}] {str(l.get('text') or '').strip()}" + for l in lines if str(l.get("text") or "").strip() + ) + known_note = ( + f"\n\nOther characters already recognized in this book (only suggest one of these, or 'Unknown' — " + f"never invent a new name): {', '.join(str(n) for n in known)}" + ) if known else "" + + system = ( + f"You are a dramaturge auditing dialogue attribution in a novel already cast for audiobook production. " + f"Below are ALL the lines currently attributed to ONE character, '{character}', gathered from across the " + f"whole book (not necessarily consecutive). Your job: read them as a whole and judge whether each line " + f"actually sounds like the SAME person speaking — same tone, vocabulary, register, and personality — or " + f"whether one or more lines sound like they were misattributed from someone else (a different tone, " + f"formality, vocabulary, or a statement that contradicts what the rest of {character}'s lines establish " + f"about them).\n\n" + f"Be conservative: most lines are correctly attributed. Only flag a line if it genuinely reads like a " + f"different voice compared to the REST of {character}'s own lines here — not just because it's short, " + f"blunt, or otherwise unremarkable.{known_note}\n\n" + 'Respond with STRICT JSON only: {"outliers":[{"index":0,"quote":"","reason":"","suggested_speaker":""}]}\n' + "index: the EXACT number shown in [square brackets] right before the flagged line below — copy that " + "number verbatim, do NOT count lines yourself or renumber them (the brackets are the book's real line " + "numbers, not a 0/1/2/3 sequence). quote: the first few words of the flagged line, verbatim, so the " + "index can be double-checked. reason: one short sentence explaining why this line doesn't fit. " + "suggested_speaker: your best guess at who actually said it (exact name from the list above), or " + "'Unknown' if you can't tell. Omit any line that isn't an outlier — do not list every line.\n/no-think" + ) + user = f"{character}'s lines (index — text):\n{numbered}" + + payload: dict = { + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": 0.3, + "max_tokens": 2048, + } + if model: + payload["model"] = model + + def _call() -> dict: + resp = requests.post( + f"{llm_url}/chat/completions", json=payload, + headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, + ) + resp.raise_for_status() + msg = resp.json()["choices"][0]["message"] + raw = (msg.get("content") or _reasoning_text(msg) or "").strip() + content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw + for cand in (content, _extract_json_block(content)): + if not cand: + continue + try: + parsed = json.loads(cand) + if isinstance(parsed, dict) and isinstance(parsed.get("outliers"), list): + return parsed + except Exception: + continue + return {"outliers": []} + + try: + result = await asyncio.to_thread(_call) + except Exception as e: + raise HTTPException(502, f"Consistency check failed: {e}") + + clean = [] + for o in result.get("outliers", []): + if not isinstance(o, dict): + continue + try: + idx = int(o.get("index")) + except (TypeError, ValueError): + continue + clean.append({ + "index": idx, + "quote": str(o.get("quote") or "").strip()[:120], + "reason": str(o.get("reason") or "").strip()[:200], + "suggested_speaker": str(o.get("suggested_speaker") or "").strip(), + }) + return {"outliers": clean} + + +def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str, + prompt_field: str, output_node_id: str, prompt: str) -> str: + """Submits a pre-exported ComfyUI API-format workflow with the given + prompt text injected into one designated node/field, waits for it to + finish, and returns the resulting image as a data: URI. + + The workflow itself is opaque to us — the user exports it from ComfyUI's + own UI (Workflow > Export (API Format)), since converting the editor's + graph format ourselves risks silently mis-wiring virtual-routing addons + (e.g. rgthree Get/Set nodes) that don't show up as real graph edges. + """ + if not comfyui_url: + raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)") + if not workflow_json: + raise HTTPException(400, "No ComfyUI workflow configured — paste an API-format workflow export in Settings > Engines > Image Generation") + if not prompt_node_id or not output_node_id: + raise HTTPException(400, "ComfyUI prompt/output node IDs not set (Settings > Engines > Image Generation)") + try: + workflow = json.loads(workflow_json) + except Exception as e: + raise HTTPException(400, f"Saved ComfyUI workflow isn't valid JSON: {e}") + if prompt_node_id not in workflow: + raise HTTPException(400, f"Prompt node id '{prompt_node_id}' not found in the saved workflow") + if output_node_id not in workflow: + raise HTTPException(400, f"Output node id '{output_node_id}' not found in the saved workflow") + + graph = copy.deepcopy(workflow) + graph[prompt_node_id].setdefault("inputs", {})[prompt_field or "text"] = prompt + client_id = str(uuid.uuid4()) + base = comfyui_url.rstrip("/") + + try: + resp = requests.post(f"{base}/prompt", json={"prompt": graph, "client_id": client_id}, timeout=30) + if not resp.ok: + detail = resp.text[:500] + try: + detail = resp.json().get("error", {}).get("message", detail) + except Exception: + pass + raise HTTPException(resp.status_code, f"ComfyUI rejected the workflow: {detail}") + prompt_id = resp.json().get("prompt_id") + if not prompt_id: + raise HTTPException(502, "ComfyUI didn't return a prompt_id") + except HTTPException: + raise + except requests.exceptions.ConnectionError: + raise HTTPException(502, f"Cannot reach ComfyUI at {base} — is the container running?") + except Exception as e: + raise HTTPException(502, f"ComfyUI submission failed: {e}") + + # Poll history — generation on a real workflow (multi-sampler, upscale, + # etc.) can take minutes, so this waits longer than a typical API call. + deadline = time.time() + 300 + history = None + while time.time() < deadline: + try: + hr = requests.get(f"{base}/history/{prompt_id}", timeout=10) + if hr.ok: + data = hr.json() + if prompt_id in data: + history = data[prompt_id] + status = history.get("status", {}) + if status.get("completed") is True or status.get("status_str") == "success": + break + if status.get("status_str") == "error": + msgs = status.get("messages", []) + raise HTTPException(502, f"ComfyUI generation failed: {msgs[-1] if msgs else 'unknown error'}") + except HTTPException: + raise + except Exception: + pass + time.sleep(2) + if not history: + raise HTTPException(504, "ComfyUI generation timed out after 5 minutes") + + outputs = history.get("outputs", {}).get(output_node_id, {}) + images = outputs.get("images") or [] + if not images: + raise HTTPException(502, f"Output node '{output_node_id}' produced no images — check it's the right SaveImage/PreviewImage node id") + img = images[0] + try: + vr = requests.get(f"{base}/view", params={ + "filename": img.get("filename", ""), "subfolder": img.get("subfolder", ""), "type": img.get("type", "output"), + }, timeout=30) + vr.raise_for_status() + except Exception as e: + raise HTTPException(502, f"Fetching the generated image from ComfyUI failed: {e}") + b64 = base64.b64encode(vr.content).decode("ascii") + mime = vr.headers.get("Content-Type", "image/png") + return f"data:{mime};base64,{b64}" + + +@router.post("/api/character-image-from-url") +async def character_image_from_url(request: Request): + """Download an image from a user-supplied URL server-side and return it + as a data: URI, so pasting a link works the same as an upload — fetching + an arbitrary third-party image directly from the browser would usually + fail on CORS, since most image hosts don't send permissive headers. + + Body: {url} + Returns: {image: "data:image/...;base64,...."} + """ + data = await request.json() + url: str = (data.get("url") or "").strip() + if not url: + raise HTTPException(400, "Image URL required") + try: + resp = requests.get(url, timeout=30, headers={"User-Agent": "TTS-Voice-Creator/image-fetch"}, stream=True) + resp.raise_for_status() + ctype = resp.headers.get("Content-Type", "") + if not ctype.startswith("image/"): + raise HTTPException(400, f"That URL didn't return an image (got {ctype or 'unknown content type'})") + content = resp.raw.read(15 * 1024 * 1024, decode_content=True) + if not content: + raise HTTPException(502, "Empty response from that URL") + b64 = base64.b64encode(content).decode("ascii") + return {"image": f"data:{ctype};base64,{b64}"} + except HTTPException: + raise + except requests.exceptions.RequestException as e: + raise HTTPException(502, f"Couldn't fetch that URL: {e}") + + +@router.post("/api/character-generate-image") +async def character_generate_image(request: Request): + """Generate a character profile picture from a text prompt via a cloud + image-gen provider (OpenAI, Google, OpenRouter) or a local ComfyUI + workflow. + + Body: {prompt, provider?, model?} — provider/model default to the + Settings > Engines > Image Generation choice if not passed explicitly. + Returns: {image: "data:image/png;base64,...."} + """ + data = await request.json() + prompt: str = (data.get("prompt") or "").strip() + if not prompt: + raise HTTPException(400, "Image prompt required") + + _settings = _load_settings() + keys: dict = _settings.get("engine_api_keys") or {} + provider: str = (data.get("provider") or _settings.get("image_gen_provider") or "").strip().lower() + model: str = (data.get("model") or _settings.get("image_gen_model") or "").strip() + + if not provider: + raise HTTPException(400, "No image generation provider configured — set one in Settings > Engines > Image Generation") + + # Every branch below does blocking requests.post/get (plus, for ComfyUI, a + # polling loop with time.sleep for up to 5 minutes on a real multi-stage + # workflow) with no asyncio.to_thread wrapper — unlike every other + # blocking call in this file. On a single-worker uvicorn process (see + # server.py) that blocked the ENTIRE event loop: every other user's + # request (TTS, page loads, /api/characters) would hang unresponsive for + # as long as one image generation took, which for a bulk "auto-generate + # images" run across a whole cast could be tens of minutes of site-wide + # freeze with no error, just silence. Runs in a worker thread instead. + return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings) + + +def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict) -> dict: + if provider == "openai": + api_key = (keys.get("openai_image") or "").strip() + if not api_key: + raise HTTPException(400, "OpenAI API key not set (Settings > Engines > Image Generation)") + try: + resp = requests.post( + "https://api.openai.com/v1/images/generations", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": model or "gpt-image-1", "prompt": prompt, "size": "1024x1024", "n": 1}, + timeout=120, + ) + if not resp.ok: + detail = resp.text[:400] + try: + detail = resp.json().get("error", {}).get("message", detail) + except Exception: + pass + raise HTTPException(resp.status_code, f"OpenAI image generation failed: {detail}") + b64 = resp.json()["data"][0]["b64_json"] + return {"image": f"data:image/png;base64,{b64}"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"OpenAI image generation failed: {e}") + + if provider == "google": + api_key = (keys.get("google_image") or "").strip() + if not api_key: + raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)") + gmodel = model or "gemini-2.5-flash-image" + try: + resp = requests.post( + f"https://generativelanguage.googleapis.com/v1beta/models/{gmodel}:generateContent", + params={"key": api_key}, + json={"contents": [{"parts": [{"text": prompt}]}]}, + timeout=120, + ) + if not resp.ok: + detail = resp.text[:400] + try: + detail = resp.json().get("error", {}).get("message", detail) + except Exception: + pass + raise HTTPException(resp.status_code, f"Google image generation failed: {detail}") + parts = (resp.json().get("candidates") or [{}])[0].get("content", {}).get("parts", []) + inline = next((p.get("inlineData") for p in parts if p.get("inlineData")), None) + if not inline: + raise HTTPException(502, "Google returned no image data — try again or rephrase the prompt") + mime = inline.get("mimeType", "image/png") + return {"image": f"data:{mime};base64,{inline['data']}"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"Google image generation failed: {e}") + + if provider == "openrouter": + # Reuses the same key as the OpenRouter LLM card (Settings > Engines > + # Language Models) — OpenRouter serves image-output models through + # the same chat/completions endpoint and account as text models, + # unlike OpenAI/Google where images are a separate API surface with + # their own key. + api_key = (keys.get("openrouter") or "").strip() + if not api_key: + raise HTTPException(400, "OpenRouter API key not set (Settings > Engines > Language Models > OpenRouter)") + omodel = model or "google/gemini-2.5-flash-image-preview:free" + try: + resp = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": omodel, "messages": [{"role": "user", "content": prompt}], "modalities": ["image", "text"]}, + timeout=120, + ) + if not resp.ok: + detail = resp.text[:400] + try: + detail = resp.json().get("error", {}).get("message", detail) + except Exception: + pass + raise HTTPException(resp.status_code, f"OpenRouter image generation failed: {detail}") + msg = (resp.json().get("choices") or [{}])[0].get("message", {}) + # OpenRouter returns generated images in message.images (not the + # OpenAI Images API shape) — each entry is + # {"type": "image_url", "image_url": {"url": "data:...;base64,..."}}. + images = msg.get("images") or [] + url = next((im.get("image_url", {}).get("url") for im in images if im.get("image_url", {}).get("url")), None) + if not url: + # Some models inline the image as a data URI in the text content instead. + content = msg.get("content") + text = content if isinstance(content, str) else " ".join( + p.get("text", "") for p in (content or []) if isinstance(p, dict) + ) + m = re.search(r"data:image/\w+;base64,[A-Za-z0-9+/=]+", text or "") + url = m.group(0) if m else None + if not url: + raise HTTPException(502, "OpenRouter returned no image data — this model may not support image output, try another") + return {"image": url} + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"OpenRouter image generation failed: {e}") + + if provider == "comfyui": + image = _comfyui_run( + (_settings.get("comfyui_url") or "").strip(), + _settings.get("comfyui_workflow") or "", + (_settings.get("comfyui_prompt_node_id") or "").strip(), + (_settings.get("comfyui_prompt_field") or "text").strip(), + (_settings.get("comfyui_output_node_id") or "").strip(), + prompt, + ) + return {"image": image} + + if provider == "pollinations": + # Pollinations.ai — free, no API key, no account. A GET request that + # returns the image directly. Third-party public service: no SLA, no + # control over the model behind it, prompts leave the server — fine + # as a free stopgap, not a permanent guarantee. + pmodel = model or "flux" + last_err = "" + # The free queue (50 concurrent slots server-wide, shared across all + # of Pollinations' users) fills up under load and rejects with a + # transient error rather than queueing — a short retry clears most + # of these without the user having to click "Generate" again. + for attempt in range(3): + try: + resp = requests.get( + f"https://image.pollinations.ai/prompt/{quote(prompt)}", + params={"model": pmodel, "width": 1024, "height": 1024, "nologo": "true"}, + timeout=120, + ) + if resp.ok and resp.content: + b64 = base64.b64encode(resp.content).decode("ascii") + mime = resp.headers.get("Content-Type", "image/jpeg") + return {"image": f"data:{mime};base64,{b64}"} + last_err = resp.text[:300] + except Exception as e: + last_err = str(e) + if attempt < 2: + time.sleep(4) + raise HTTPException(502, f"Pollinations.ai request failed after retries: {last_err}") + + raise HTTPException(400, f"Unknown image generation provider: {provider}") + + def _attribution_prepare(data: dict) -> dict: """Resolve settings and build the chat payload for one attribution request. Shared by the blocking endpoint and the streaming (watch-the-LLM-think) @@ -1191,8 +1794,13 @@ def _attribution_prepare(data: dict) -> dict: "kein Zitatende — die Rede desselben Charakters geht danach unverändert weiter, bis das tatsächliche " "schließende Anführungszeichen erscheint.\n" "9. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die " - "Stimme oder das (beginnende) Sprechen einer bestimmten Person (z.B. \"Marcians Stimme wirkte nicht mehr so " - "fest\", \"X setzte zum Sprechen an\"), gehört diese Zeile dieser Person — nicht 'Unknown'." + "Stimme oder das (beginnende) Sprechen einer bestimmten Person — auch in indirekter/idiomatischer Form, " + "nicht nur mit einem wörtlichen Sprechverb (z.B. \"Marcians Stimme wirkte nicht mehr so fest\", \"X setzte " + "zum Sprechen an\", \"X fand als erster seine Stimme wieder\", \"ihre ersten Worte waren\", \"X brach das " + "Schweigen\"), gehört die folgende Zeile dieser Person — nicht 'Unknown'.\n" + "10. Selbstvorstellung: Nennt eine Zitat-Zeile selbst den Namen der sprechenden Person als Vorstellung " + "(z.B. \"Man nennt mich Andra\", \"Ich bin X\", \"Mein Name ist X\", \"Ich heiße X\"), ist diese genannte " + "Person die Sprecherin dieser Zeile — nicht 'Unknown', auch ohne separaten Sprecher-Tag." ) if not base_prompt.strip(): base_prompt = ( @@ -1238,9 +1846,14 @@ def _attribution_prepare(data: dict) -> dict: "- MID-QUOTE DASH RULE: a \" - \" (em-dash/hyphen used as a pause) in the MIDDLE of a quotation does NOT " "end it — the SAME speaker's line continues unchanged after the dash, until the actual closing quotation " "mark appears. Do not split it into narration or a new speaker at the dash.\n" - "- VOICE-ANNOUNCEMENT RULE: if narration explicitly mentions a specific character's voice or that they " - "are about to speak, shortly before an unattributed line (e.g. \"Marcian's voice sounded...\", " - "\"X began to say\"), attribute that line to that character instead of 'Unknown'." + "- VOICE-ANNOUNCEMENT RULE: if narration mentions a specific character's voice or that they are about to " + "speak, shortly before an unattributed line, attribute that line to that character instead of 'Unknown' " + "— this includes indirect/idiomatic phrasing, not just a literal speech verb (e.g. \"Marcian's voice " + "sounded...\", \"X began to say\", \"X found his voice first\", \"her first words were\", \"X broke the " + "silence\").\n" + "- SELF-INTRODUCTION RULE: if a quoted line itself names the speaker as an introduction (e.g. \"They " + "call me Andra\", \"I am X\", \"My name is X\"), that named person is the speaker of THIS line — never " + "'Unknown', even with no separate speaker tag." ) else: if lang_hint: @@ -1790,6 +2403,47 @@ _MCP_TOOLS = [ "description": "List all available voice profiles with their language, persona, and enabled state.", "inputSchema": {"type": "object", "properties": {}}, }, + { + "name": "list_books", + "description": "List all Read Aloud books/documents (title, id, character count metadata).", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "list_characters", + "description": "List character library records, optionally filtered to one book/production.", + "inputSchema": { + "type": "object", + "properties": {"book": {"type": "string", "description": "Book/production title to filter by (optional — omit for every character across every book)"}}, + }, + }, + { + "name": "get_character", + "description": "Get one character's full record (sheet, voice, image, tags) by id.", + "inputSchema": { + "type": "object", + "properties": {"id": {"type": "string", "description": "Character record id"}}, + "required": ["id"], + }, + }, + { + "name": "update_character", + "description": "Update fields on a character's sheet (e.g. backstory, archetype, gender) or top-level record fields (name, voice, tags). Merges with the existing record — only send the fields you want to change.", + "inputSchema": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Character record id"}, + "sheet": {"type": "object", "description": "Partial sheet fields to merge in (e.g. {\"backstory\": \"...\", \"archetype\": \"...\"})"}, + "voice": {"type": "string", "description": "Voice id to assign (shortcut for updating just the voice)"}, + "name": {"type": "string", "description": "Rename the character (rare — usually leave unset)"}, + }, + "required": ["id"], + }, + }, + { + "name": "list_rehearsals", + "description": "List all saved Script Rehearser sessions (title, id, character/line counts).", + "inputSchema": {"type": "object", "properties": {}}, + }, ] @@ -1879,6 +2533,58 @@ async def _mcp_tool_list_profiles() -> dict: return {"content": [{"type": "text", "text": json.dumps(profiles)}]} +async def _mcp_tool_list_books() -> dict: + from routes.reader import reader_list_docs + data = await reader_list_docs() + return {"content": [{"type": "text", "text": json.dumps(data.get("docs", []))}]} + + +async def _mcp_tool_list_characters(args: dict) -> dict: + from core.database import char_get_all + book = str(args.get("book") or "").strip().lower() + recs = char_get_all() + if book: + recs = [r for r in recs if str(r.get("book") or "").strip().lower() == book + or book in [t.strip().lower() for t in str(r.get("tags") or "").split(",")]] + return {"content": [{"type": "text", "text": json.dumps(recs)}]} + + +async def _mcp_tool_get_character(args: dict) -> dict: + from core.database import char_get + char_id = str(args.get("id") or "").strip() + if not char_id: + raise ValueError("id is required") + rec = char_get(char_id) + if rec is None: + raise ValueError(f"Character '{char_id}' not found") + return {"content": [{"type": "text", "text": json.dumps(rec)}]} + + +async def _mcp_tool_update_character(args: dict) -> dict: + from core.database import char_get, char_put + char_id = str(args.get("id") or "").strip() + if not char_id: + raise ValueError("id is required") + rec = char_get(char_id) + if rec is None: + raise ValueError(f"Character '{char_id}' not found") + if "sheet" in args and isinstance(args["sheet"], dict): + rec["sheet"] = {**(rec.get("sheet") or {}), **args["sheet"]} + if "voice" in args: + rec["voice"] = args["voice"] + if "name" in args: + rec["name"] = args["name"] + rec["id"] = char_id + updated = char_put(rec) + return {"content": [{"type": "text", "text": json.dumps(updated)}]} + + +async def _mcp_tool_list_rehearsals() -> dict: + from core.database import reh_get_all, reh_compact_titles + reh_compact_titles() + return {"content": [{"type": "text", "text": json.dumps(reh_get_all())}]} + + def _mcp_error_response(code: int, message: str, rpc_id) -> Response: import logging body = {"jsonrpc": "2.0", "error": {"code": code, "message": message}, "id": rpc_id} @@ -1921,6 +2627,16 @@ async def mcp_jsonrpc(request: Request): result = await _mcp_tool_list_captures() elif tool_name == "list_profiles": result = await _mcp_tool_list_profiles() + elif tool_name == "list_books": + result = await _mcp_tool_list_books() + elif tool_name == "list_characters": + result = await _mcp_tool_list_characters(tool_args) + elif tool_name == "get_character": + result = await _mcp_tool_get_character(tool_args) + elif tool_name == "update_character": + result = await _mcp_tool_update_character(tool_args) + elif tool_name == "list_rehearsals": + result = await _mcp_tool_list_rehearsals() else: return _mcp_error_response(-32601, f"Unknown tool: {tool_name}", rpc_id) else: diff --git a/routes/docker.py b/routes/docker.py index 8de1f1e..6915969 100644 --- a/routes/docker.py +++ b/routes/docker.py @@ -85,7 +85,7 @@ async def start_local_container(name: str): if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): raise HTTPException(404, f"Unknown container: {name}") try: - code, _ = _docker_post(f"/containers/{quote(name, safe='')}/start") + code, _ = await asyncio.to_thread(_docker_post, f"/containers/{quote(name, safe='')}/start") except Exception as e: raise HTTPException(502, f"Docker start failed: {e}") if code not in (204, 304): @@ -98,7 +98,7 @@ async def stop_local_container(name: str): if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): raise HTTPException(404, f"Unknown container: {name}") try: - code, _ = _docker_post(f"/containers/{quote(name, safe='')}/stop?t=10") + code, _ = await asyncio.to_thread(_docker_post, f"/containers/{quote(name, safe='')}/stop?t=10") except Exception as e: raise HTTPException(502, f"Docker stop failed: {e}") if code not in (204, 304): @@ -111,7 +111,7 @@ async def restart_local_container(name: str): if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): raise HTTPException(404, f"Unknown container: {name}") try: - code, _ = _docker_post(f"/containers/{quote(name, safe='')}/restart?t=10") + code, _ = await asyncio.to_thread(_docker_post, f"/containers/{quote(name, safe='')}/restart?t=10") except Exception as e: raise HTTPException(502, f"Docker restart failed: {e}") if code not in (204, 304): @@ -128,14 +128,24 @@ async def probe_url(url: str, type: str = "", api_key: str = ""): base = base[: -len(_suffix)] break key_to_use = api_key if api_key else "sk-dummy-key" - hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"} + # Anthropic doesn't speak the OpenAI-style Bearer auth every other card + # here uses — it needs x-api-key + an anthropic-version header, so the + # generic Bearer probe below would 401 even with a perfectly valid key. + if type == "anthropic": + hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "x-api-key": key_to_use, "anthropic-version": "2023-06-01"} + else: + hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"} - if type == "llm": + if type == "anthropic": + checks = [("/v1/models", "data")] + elif type == "llm": checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)] elif type == "stt": checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)] elif type == "tts": checks = [("/health", None), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)] + elif type == "comfyui": + checks = [("/system_stats", "system")] else: checks = [("", None)] diff --git a/routes/library.py b/routes/library.py index abcd8da..aad4e6c 100644 --- a/routes/library.py +++ b/routes/library.py @@ -26,7 +26,7 @@ from core.voice import ( _backup_original_voice, _backup_candidates, _backup_audio_suffix, _remove_audio_variants, _remove_voice_package, _voice_package_paths, _move_voice_package, _read_reference_text, _voice_health, - _is_internal_voice_file, _benchmark_voice, + _is_internal_voice_file, _benchmark_voice, _backup_existing_picture, ) from core.voice_index import ( indexed_voices, @@ -40,6 +40,44 @@ from core.voice_index import ( router = APIRouter() +# ── Book/production profile (genre, setting, era, language) ────────────────── +# A one-time-per-book note the user fills in ("German, fantasy like Lord of +# the Rings, medieval times") so every LLM prompt this book generates — +# voice design, character portraits — carries real setting context instead +# of guessing generic defaults per character. Confirmed live as a recurring +# problem before this existed: fantasy-book characters designed with 1920s +# general portraits, and per-character language detection defaulting to +# English for sparse/minor characters with no descriptive text of their own. + +def _book_profile_key(book: str) -> str: + return "book_profile::" + book.strip().lower() + + +@router.get("/api/book-profile") +async def get_book_profile(book: str): + from core.database import state_get + if not book.strip(): + raise HTTPException(400, "book is required") + return state_get(_book_profile_key(book), {}) or {} + + +@router.post("/api/book-profile") +async def save_book_profile(request: Request): + from core.database import state_put + data = await request.json() + book = str(data.get("book", "")).strip() + if not book: + raise HTTPException(400, "book is required") + profile = { + "genre": str(data.get("genre", "")).strip()[:200], + "setting": str(data.get("setting", "")).strip()[:200], + "era": str(data.get("era", "")).strip()[:200], + "language": str(data.get("language", "")).strip()[:60], + } + state_put(_book_profile_key(book), profile) + return {"ok": True, "profile": profile} + + # ── Upload ──────────────────────────────────────────────────────────────────── @router.post("/api/upload") @@ -208,15 +246,18 @@ async def save_voice(request: Request): wav_dest = out_dir / f"{voice_id}.wav" txt_dest = out_dir / f"{voice_id}.reference.txt" + existed = wav_dest.exists() _remove_audio_variants(out_dir, voice_id) loudness = _export_normalized_wav(src, wav_dest) txt_dest.write_text(transcript, encoding="utf-8") meta = _load_meta(wav_dest) meta["enabled"] = True meta["loudness"] = loudness + if existed: + meta["needs_tts_restart"] = True _save_meta(wav_dest, meta) await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest) - return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "loudness": loudness} + return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "loudness": loudness, "needs_tts_restart": existed} # ── Voice library CRUD ──────────────────────────────────────────────────────── @@ -707,6 +748,7 @@ async def upload_picture(voice_id: str = Form(...), file: UploadFile = File(...) if orig_suffix not in _PICTURE_EXTS: orig_suffix = ".jpg" + _backup_existing_picture(wav) for ext in _PICTURE_EXTS: old = wav.with_suffix(ext) if old.exists(): @@ -753,6 +795,7 @@ async def upload_picture_url(request: Request): content_type = (r.headers.get("content-type") or "").split(";", 1)[0].lower() if content_type and not content_type.startswith("image/"): raise HTTPException(400, "Image URL did not return an image") + _backup_existing_picture(wav) for ext in _PICTURE_EXTS: old = wav.with_suffix(ext) if old.exists(): diff --git a/routes/rehearsals_db.py b/routes/rehearsals_db.py index f3e1534..8f65697 100644 --- a/routes/rehearsals_db.py +++ b/routes/rehearsals_db.py @@ -12,13 +12,14 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException, Request -from core.database import reh_get_all, reh_get, reh_put, reh_delete +from core.database import reh_get_all, reh_get, reh_put, reh_delete, reh_compact_titles router = APIRouter() @router.get("/api/rehearsals") async def rehearsals_list(): + reh_compact_titles() return {"rehearsals": reh_get_all()} diff --git a/routes/settings.py b/routes/settings.py index 645f246..c8c54f3 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException, Request -from core.config import _load_settings, _save_settings, _normalize_settings, _SETTINGS_KEYS +from core.config import _load_settings, _save_settings, _normalize_settings, _SETTINGS_KEYS, _ensure_external_api_key from core.routing import _load_tts_routes, _save_tts_routes from core.constants import _log_buffer, _LOG_BUFFER_MAX, _routing_log, _ROUTING_LOG_MAX from core.presets import _load_design_presets, _save_design_presets @@ -13,7 +13,21 @@ router = APIRouter() @router.get("/api/settings") async def get_settings(): - return _load_settings() + s = _load_settings() + # Lazily generated on first read, not at server startup — a fresh + # install shows a real usable key in Settings immediately without a + # migration step, whether or not the gate is actually turned on yet. + s["external_api_key"] = _ensure_external_api_key() + return s + + +@router.post("/api/settings/regenerate-api-key") +async def regenerate_api_key(): + import secrets + s = _load_settings() + s["external_api_key"] = secrets.token_urlsafe(32) + _save_settings(s) + return {"external_api_key": s["external_api_key"]} @router.post("/api/settings") diff --git a/routes/tts.py b/routes/tts.py index 18127b6..9dd49e1 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -20,7 +20,7 @@ from fastapi.responses import Response, StreamingResponse from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url from core.constants import ( _VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW, - _routing_log_add, + _routing_log_add, CONFIG_DIR, ) from core.routing import ( _load_tts_routes, _resolve_tts_route, _route_backend, @@ -469,7 +469,15 @@ async def restart_tts_container(): for container in containers: path = f"/containers/{quote(container, safe='')}/restart?t=10" try: - code, raw = _docker_post(path) + # _docker_post is a raw blocking socket call (core/docker_client.py) + # and this loop can span two containers x up to 10s each — run off + # the event loop thread, or every other request on this server + # (including a plain GET /api/characters) hangs for the whole + # restart instead of just this one call. Confirmed live: the + # Studio Voices tab's own character fetch silently failed and + # rendered "No characters yet" while a restart triggered elsewhere + # was still in flight. + code, raw = await asyncio.to_thread(_docker_post, path) if code not in (204, 304): detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"HTTP {code}" errors.append(f"{container}: {detail}") @@ -1085,3 +1093,211 @@ async def get_seed_sample(voice_name: str, seed: int): raise HTTPException(502, "Could not reach TTS server") except requests.exceptions.HTTPError as e: raise HTTPException(e.response.status_code, str(e)) + + +@router.post("/api/audio/encode-mp3") +async def encode_mp3(request: Request): + """Encode a raw WAV body into MP3 at an explicit bitrate. + + audiobookExport() used to concatenate independently-encoded per-line MP3 + byte streams directly into one Blob — each clip carries its own frame/ID3 + headers, so most players only decode the first one (confirmed live: an + 85MB file that reported as 22s playable). The fix merges lossless WAV + clips client-side (mergeWavBlobs, already correct) and sends the single + merged WAV here for one real encode pass — also fixes the previous + 32kbps default (ffmpeg/lame's unset-bitrate fallback, not a deliberate + choice anywhere in this app) without pretending to add quality beyond + the engine's native 24kHz mono output. + """ + wav_bytes = await request.body() + if not wav_bytes: + raise HTTPException(400, "Empty request body") + try: + from pydub import AudioSegment + segment = AudioSegment.from_file(io.BytesIO(wav_bytes), format="wav") + out = io.BytesIO() + segment.export(out, format="mp3", bitrate="96k") + return Response(content=out.getvalue(), media_type="audio/mpeg") + except Exception as e: + raise HTTPException(400, f"Could not encode audio: {e}") + + +# ── Per-paragraph synthesized-audio cache ─────────────────────────────────── +# +# "Synth all" pre-synthesizes every line for instant playback, but only ever +# kept the result in the browser tab's memory — closing the tab (or a crash, +# or just a normal reload) threw all of it away, and every future playback +# or export had to wait on the GPU again from scratch. This persists each +# line's audio to disk, keyed by a hash of its own content (text + voice + +# instruct/tone) rather than its position in the script — editing a +# paragraph changes its hash, so the edited version simply never matches a +# cached file and gets synthesized fresh, while an untouched paragraph +# reuses its file instantly regardless of how the surrounding lines shifted. +# The key is computed client-side (SHA-256 over the exact inputs that affect +# the audio) and treated here as an opaque cache token — this endpoint never +# needs to know what it means, only that the same key always means the same +# audio. +_LINE_AUDIO_DIR = CONFIG_DIR / "line_audio_cache" +_LINE_AUDIO_KEY_RE = re.compile(r"^[a-f0-9]{16,64}$") + + +def _line_audio_book_dir(book: str) -> Path: + safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book" + d = _LINE_AUDIO_DIR / safe_book + d.mkdir(parents=True, exist_ok=True) + return d + + +@router.get("/api/line-audio/{book}/{key}") +async def get_line_audio(book: str, key: str): + if not _LINE_AUDIO_KEY_RE.match(key): + raise HTTPException(400, "Invalid cache key") + path = _line_audio_book_dir(book) / f"{key}.wav" + if not path.exists(): + raise HTTPException(404, "Not cached") + return Response(content=path.read_bytes(), media_type="audio/wav") + + +@router.post("/api/line-audio/{book}/check") +async def check_line_audio(book: str, request: Request): + """Bulk existence check — one request instead of one GET per line — so + the Stage page can mark its "pre-synthesized" dots correctly right after + a reload, instead of every dot looking unsynthesized just because + rehState.synthCache (this browser tab's own memory) starts empty on + every fresh page load even when the audio is sitting on disk already.""" + data = await request.json() + keys = data.get("keys") or [] + if not isinstance(keys, list): + raise HTTPException(400, "keys must be a list of cache keys") + book_dir = _line_audio_book_dir(book) + existing = [k for k in keys if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k) and (book_dir / f"{k}.wav").exists()] + return {"ok": True, "existing": existing} + + +@router.post("/api/line-audio/{book}/prune") +async def prune_line_audio(book: str, request: Request): + """Delete cached files for this book that no longer match any current + line — a paragraph's cache key is its own content hash, so editing it + just makes the old file unreachable rather than actively removing it + (nothing on the write path knows a "previous" key exists to delete). + The client sends every key still valid for the CURRENT script; anything + else on disk for this book is safe to remove. + + Registered BEFORE the generic POST /api/line-audio/{book}/{key} route + below — FastAPI matches routes in declaration order, and {key} is just + a plain path segment at the routing level (its regex validation only + runs inside the handler, after routing already picked one), so a + literal "prune" segment would otherwise always match that generic + route first and this one would never be reached at all. + """ + data = await request.json() + keep = data.get("keep") or [] + if not isinstance(keep, list): + raise HTTPException(400, "keep must be a list of cache keys") + keep_set = {k for k in keep if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k)} + book_dir = _line_audio_book_dir(book) + deleted = 0 + for f in book_dir.glob("*.wav"): + if f.stem not in keep_set: + try: + f.unlink() + deleted += 1 + except OSError: + pass + return {"ok": True, "deleted": deleted, "kept": len(keep_set)} + + +@router.post("/api/line-audio/{book}/{key}") +async def put_line_audio(book: str, key: str, request: Request): + if not _LINE_AUDIO_KEY_RE.match(key): + raise HTTPException(400, "Invalid cache key") + wav_bytes = await request.body() + if not wav_bytes: + raise HTTPException(400, "Empty request body") + path = _line_audio_book_dir(book) / f"{key}.wav" + path.write_bytes(wav_bytes) + return {"ok": True} + + +# ── Finished audiobook chapter exports ────────────────────────────────────── +# +# audiobookExport() already triggers a browser download per chapter, but +# that only ever lands wherever the browser's download settings put it — +# confirmed as a real gap: nothing in the app itself says where the files +# went, and re-finding a chapter later means re-running the whole export. +# This additionally saves the exact same file server-side so the app can +# show a real download link (and the on-disk path) right after the export +# finishes, and again any time later without resynthesizing anything. +_AUDIOBOOK_EXPORT_DIR = CONFIG_DIR / "audiobook_exports" +_EXPORT_FILENAME_RE = re.compile(r"^[^/\\]{1,200}$") # any single path segment, no traversal + + +def _audiobook_export_book_dir(book: str) -> Path: + safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book" + d = _AUDIOBOOK_EXPORT_DIR / safe_book + d.mkdir(parents=True, exist_ok=True) + return d + + +@router.get("/api/audiobook-export/{book}") +async def list_audiobook_exports(book: str): + """List previously-saved chapter exports for this book — lets the app + show a "browse what's already been exported" view without re-running + the export, and without any real filesystem access on the user's part. + Registered before the generic GET .../{filename} route below for the + same reason "zip" and "check"/"prune" are elsewhere in this file: a + plain path segment matches ANY literal string at the routing level. + """ + book_dir = _audiobook_export_book_dir(book) + files = sorted( + ({"name": f.name, "size": f.stat().st_size} for f in book_dir.iterdir() if f.is_file()), + key=lambda x: x["name"], + ) + return {"book": book, "files": files, "dir": str(book_dir)} + + +@router.get("/api/audiobook-export/{book}/zip") +async def zip_audiobook_exports(book: str): + """Bundle every saved chapter for this book into one ZIP download — + the "download everything at once" the per-file list doesn't offer.""" + import zipfile + book_dir = _audiobook_export_book_dir(book) + files = [f for f in book_dir.iterdir() if f.is_file()] + if not files: + raise HTTPException(404, "No exported files for this book") + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf: + for f in files: + zf.write(f, arcname=f.name) + buf.seek(0) + zip_name = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "audiobook" + return Response( + content=buf.getvalue(), media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_name}.zip"'}, + ) + + +@router.get("/api/audiobook-export/{book}/{filename}") +async def get_audiobook_export(book: str, filename: str): + if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."): + raise HTTPException(400, "Invalid filename") + path = _audiobook_export_book_dir(book) / filename + if not path.exists() or not path.is_file(): + raise HTTPException(404, "Not found") + media_type = "audio/mpeg" if path.suffix.lower() == ".mp3" else "audio/wav" + return Response( + content=path.read_bytes(), media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("/api/audiobook-export/{book}/{filename}") +async def put_audiobook_export(book: str, filename: str, request: Request): + if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."): + raise HTTPException(400, "Invalid filename") + audio_bytes = await request.body() + if not audio_bytes: + raise HTTPException(400, "Empty request body") + path = _audiobook_export_book_dir(book) / filename + path.write_bytes(audio_bytes) + return {"ok": True, "path": str(path)} diff --git a/scripts/minify.mjs b/scripts/minify.mjs index 931d9c4..845affb 100644 --- a/scripts/minify.mjs +++ b/scripts/minify.mjs @@ -23,7 +23,7 @@ const MAIN = [ 'voice-picker', 'benchmark-voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser', 'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview', 'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets', - 'characters-library', 'sillytavern', 'library', 'library-characters', + 'characters-library', 'sillytavern', 'library', 'library-characters', 'studio', ].map(n => join(jsDir, n + '.js')); const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n'); diff --git a/server.py b/server.py index 93baf11..a96dbc9 100644 --- a/server.py +++ b/server.py @@ -15,7 +15,7 @@ from fastapi.middleware.gzip import GZipMiddleware from starlette.datastructures import MutableHeaders from core.constants import STATIC_DIR, _BufferHandler -from core.config import _load_settings +from core.config import _load_settings, _ensure_external_api_key from core.voice_index import refresh_voice_index_background from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db @@ -116,6 +116,75 @@ class StaticCacheHeadersMiddleware: app.add_middleware(StaticCacheHeadersMiddleware) +# ── API-key gate for non-browser callers ──────────────────────────────────── +# The app's own UI calls /api/* same-origin from the browser and needs no +# key — everything else (curl, scripts, MCP clients, agents) does. Gated by +# Origin/Referer host matching the request's own Host header, which the +# browser sets automatically and a bare script/curl call generally doesn't. +# /mcp always requires the key regardless of origin, since no in-app browser +# code calls it — it exists specifically for external MCP clients. +# Raw ASGI (not @app.middleware("http")/BaseHTTPMiddleware) for the same +# reason as StaticCacheHeadersMiddleware above: that wrapper's task-group +# around call_next() fights with a client disconnecting mid-SSE-stream. +class ApiKeyGateMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + path = scope["path"] + if not (path.startswith("/api/") or path == "/mcp"): + await self.app(scope, receive, send) + return + if not _load_settings().get("external_api_key_required"): + # Off by default — see the settings-key comment in core/config.py + # for why. /mcp still needs SOME signal it's being used + # deliberately even while the general gate is off, but that's a + # judgment call for whoever enables it, not a silent bypass. + await self.app(scope, receive, send) + return + + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + host = headers.get("host", "") + + def _same_origin(url: str) -> bool: + if not url or not host: + return False + try: + from urllib.parse import urlparse + return urlparse(url).netloc == host + except Exception: + return False + + is_browser_same_origin = path != "/mcp" and ( + _same_origin(headers.get("origin", "")) or _same_origin(headers.get("referer", "")) + ) + if is_browser_same_origin: + await self.app(scope, receive, send) + return + + expected = _ensure_external_api_key() + provided = headers.get("x-api-key", "") + if not provided and headers.get("authorization", "").lower().startswith("bearer "): + provided = headers["authorization"][7:] + if provided and provided == expected: + await self.app(scope, receive, send) + return + + import json as _json + body = _json.dumps({"detail": "Missing or invalid API key — pass it as X-API-Key. Find/regenerate it in Settings > API Keys > External API Access."}).encode() + await send({ + "type": "http.response.start", "status": 401, + "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())], + }) + await send({"type": "http.response.body", "body": body}) + + +app.add_middleware(ApiKeyGateMiddleware) + + # ── Routers ─────────────────────────────────────────────────────────────────── app.include_router(admin.router) diff --git a/static/dist/main.min.js b/static/dist/main.min.js index c6ca379..3a3110e 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -1,4 +1,4 @@ -var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_z,_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z,__,_$,_aa,_ba,_ca,_da,_ea,_fa,_ga,_ha,_ia,_ja,_ka,_la,_ma,_na,_oa,_pa,_qa,_ra,_sa,_ta,_ua,_va,_wa,_xa,_ya,_za,_Aa,_Ba,_Ca,_Da,_Ea,_Fa,_Ga,_Ha,_Ia,_Ja,_Ka,_La,_Ma,_Na,_Oa,_Pa,_Qa,_Ra,_Sa,_Ta,_Ua,_Va,_Wa,_Xa,_Ya,_Za,__a,_$a,_ab,_bb,_cb,_db,_eb,_fb,_gb,_hb,_ib,_jb,_kb,_lb,_mb,_nb,_ob,_pb,_qb,_rb,_sb,_tb,_ub,_vb,_wb,_xb,_yb,_zb,_Ab,_Bb,_Cb,_Db,_Eb,_Fb,_Gb,_Hb,_Ib,_Jb,_Kb,_Lb,_Mb,_Nb,_Ob,_Pb,_Qb,_Rb,_Sb,_Tb,_Ub,_Vb,_Wb,_Xb,_Yb,_Zb,__b,_$b,_ac,_bc,_cc,_dc,_ec,_fc,_gc,_hc,_ic,_jc,_kc,_lc,_mc,_nc,_oc;(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
+var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_z,_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z,__,_$,_aa,_ba,_ca,_da,_ea,_fa,_ga,_ha,_ia,_ja,_ka,_la,_ma,_na,_oa,_pa,_qa,_ra,_sa,_ta,_ua,_va,_wa,_xa,_ya,_za,_Aa,_Ba,_Ca,_Da,_Ea,_Fa,_Ga,_Ha,_Ia,_Ja,_Ka,_La,_Ma,_Na,_Oa,_Pa,_Qa,_Ra,_Sa,_Ta,_Ua,_Va,_Wa,_Xa,_Ya,_Za,__a,_$a,_ab,_bb,_cb,_db,_eb,_fb,_gb,_hb,_ib,_jb,_kb,_lb,_mb,_nb,_ob,_pb,_qb,_rb,_sb,_tb,_ub,_vb,_wb,_xb,_yb,_zb,_Ab,_Bb,_Cb,_Db,_Eb,_Fb,_Gb,_Hb,_Ib,_Jb,_Kb,_Lb,_Mb,_Nb,_Ob,_Pb,_Qb,_Rb,_Sb,_Tb,_Ub,_Vb,_Wb,_Xb,_Yb,_Zb,__b,_$b,_ac,_bc,_cc,_dc,_ec,_fc,_gc,_hc,_ic,_jc,_kc,_lc,_mc,_nc,_oc,_pc,_qc,_rc,_sc,_tc;(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
${_avatarHtml(id,26)} ${_esc(name)} ${meta?`${_esc(meta)}`:_flagSpan(v)} @@ -196,7 +196,7 @@ var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_ ${can?'':""}
-
`}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a3,_b3;const v=items[i];(_a3=el.querySelector(".fa-play"))==null||_a3.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b3=el.querySelector(".fa-import"))==null||_b3.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a2=$2("fa-fetch"))==null||_a2.addEventListener("click",()=>{_page=1,browse()}),(_b2=$2("fa-search"))==null||_b2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c2=$2("fa-tag"))==null||_c2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d2=$2("fa-lang"))==null||_d2.addEventListener("change",()=>{_page=1,browse()}),(_e2=$2("fa-sort"))==null||_e2.addEventListener("change",()=>{_page=1,browse()}),(_f2=$2("fa-prev"))==null||_f2.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g2=$2("fa-next"))==null||_g2.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i2=$2("fa-filter-close"))==null||_i2.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j2=$2("fa-tag"))==null||_j2.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k2=$2("fa-filter-apply"))==null||_k2.addEventListener("click",applyFilters),(_l2=$2("fa-filter-reset"))==null||_l2.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}();function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+name.replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS +
`}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a3,_b3;const v=items[i];(_a3=el.querySelector(".fa-play"))==null||_a3.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b3=el.querySelector(".fa-import"))==null||_b3.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(typeof _umlautSafe=="function"?_umlautSafe(v.title||"fishaudio"):v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a2=$2("fa-fetch"))==null||_a2.addEventListener("click",()=>{_page=1,browse()}),(_b2=$2("fa-search"))==null||_b2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c2=$2("fa-tag"))==null||_c2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d2=$2("fa-lang"))==null||_d2.addEventListener("change",()=>{_page=1,browse()}),(_e2=$2("fa-sort"))==null||_e2.addEventListener("change",()=>{_page=1,browse()}),(_f2=$2("fa-prev"))==null||_f2.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g2=$2("fa-next"))==null||_g2.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i2=$2("fa-filter-close"))==null||_i2.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j2=$2("fa-tag"))==null||_j2.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k2=$2("fa-filter-apply"))==null||_k2.addEventListener("click",applyFilters),(_l2=$2("fa-filter-reset"))==null||_l2.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}();function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+(typeof _umlautSafe=="function"?_umlautSafe(name):name).replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS API base URL: ${v1} API key: dummy Model: qwen3-tts @@ -421,12 +421,12 @@ Mia:Only if you promise not to spill coffee on my notes again... though I guess
- `,list.appendChild(card)}))}function applyQwenSample(sample){$("design-instruct").value=sample.description,$("design-sample-text").value=sample.text,$("design-language").value=sample.language,$("design-gender").value=sample.gender,currentDesignSource=sample,$("design-result").style.display="none",$("design-save-result").style.display="none",$("design-instruct").scrollIntoView({behavior:"smooth",block:"nearest"})}function isDialogueDesign(instruct,text,source=null){if(source&&source.dialogue)return!0;const speakers=new Set;if(String(instruct||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/);match&&speakers.add(match[1].trim())}),speakers.size<2)return!1;const turnSpeakers=new Set;return String(text||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^([^:]{1,40}):\s*(.+)$/);match&&speakers.has(match[1].trim())&&turnSpeakers.add(match[1].trim())}),turnSpeakers.size>=2}function voiceDesignPayload(instruct,sampleText,language,source=null,gender=null){var _a2;return{instruct,sample_text:sampleText,language,gender:gender||(source==null?void 0:source.gender)||((_a2=$("design-gender"))==null?void 0:_a2.value)||"",dialogue:isDialogueDesign(instruct,sampleText,source)}}let _dVoiceIdManual=!1;function designSafeName(name){return String(name||"VoiceDesign").replace(/^[A-Z]{2}_[FMN]_/,"").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,42)||"VoiceDesign"}function voiceIdSafePart(value,fallback="style"){return String(value||fallback).replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,32)||fallback}function suggestedStyleVoiceId(baseId,style){const suffix=voiceIdSafePart(style||"style");return`${baseId}_${suffix}`.slice(0,96)}function _updateDVoiceId(){if(_dVoiceIdManual)return;const lang=$("d-lang").value,gender=$("d-gender").value,name=$("d-name").value.trim();$("d-voice-id").value=name?`${lang}_${gender}_${name}`:""}["d-lang","d-gender"].forEach(id=>$(id).addEventListener("change",_updateDVoiceId)),$("d-name").addEventListener("input",()=>{_dVoiceIdManual=!1,_updateDVoiceId()}),$("d-voice-id").addEventListener("input",()=>{_dVoiceIdManual=!0}),seedDesignPresets(),refreshDesignPresetSelect(),renderQwenSampleCards(),syncDesignPresetsToServer(),$("design-preset-select").addEventListener("change",()=>{$("design-preset-select").value&&applyDesignPreset($("design-preset-select").value)}),$("design-preset-load").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}applyDesignPreset(name)}),$("design-preset-save").addEventListener("click",()=>{const name=$("design-preset-name").value.trim()||$("design-preset-select").value;if(!name){toast("Enter a preset name","error"),$("design-preset-name").focus();return}const presets=loadDesignPresets();presets[name]={description:$("design-instruct").value,sample_text:$("design-sample-text").value,language:$("design-language").value,gender:$("design-gender").value,dialogue:isDialogueDesign($("design-instruct").value,$("design-sample-text").value,currentDesignSource)},saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-select").value=name,toast("Preset saved: "+name,"success")}),$("design-preset-delete").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}const presets=loadDesignPresets();if(!presets[name]){toast("Preset not found","error");return}delete presets[name],saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-name").value="",toast("Preset deleted: "+name,"success")}),["design-instruct","design-sample-text"].forEach(id=>$(id).addEventListener("input",()=>{currentDesignSource=null,id==="design-sample-text"&&($("d-transcript").value=$("design-sample-text").value)})),document.querySelectorAll(".qwen-sample").forEach(card=>{const sample=QWEN_DESIGN_SAMPLES[card.dataset.qwenSample],state=card.querySelector(".qwen-state"),audio=card.querySelector("audio");card.querySelector(".qwen-use").addEventListener("click",()=>{applyQwenSample(sample),toast("Voice Design sample loaded","success")}),card.querySelector(".qwen-preview").addEventListener("click",async e=>{const btn=e.currentTarget;btn.disabled=!0,state.textContent="Generating preview\u2026";try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(sample.description,sample.text,sample.language,sample))});if(!r.ok){const err=await r.json().catch(()=>({}));throw new Error(err.detail||r.statusText)}const d=await r.json();audio.src="/api/audio/"+d.id,audio.style.display="",audio.play().catch(()=>{}),state.textContent="Preview ready"}catch(err){state.textContent="Preview failed",toast("Sample preview failed: "+err.message,"error")}finally{btn.disabled=!1}})});async function runVoiceDesign(){const baseInstruct=$("design-instruct").value.trim(),sample=$("design-sample-text").value.trim(),dialogue=isDialogueDesign(baseInstruct,sample,currentDesignSource),instruct=baseInstruct;if(!instruct){toast("Enter a voice description first","error");return}$("design-generate-btn").disabled=!0,$("design-status").textContent="Generating\u2026",$("design-result").style.display="none",$("design-save-result").style.display="none",status("Generating voice design\u2026");try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(instruct,sample,$("design-language").value,currentDesignSource,$("design-gender").value))});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();designedFileId=d.id,trimmedFileId=null,editingVoiceId=null,$("design-audio").src="/api/audio/"+d.id,$("design-result").style.display="flex",$("design-status").textContent="Done ("+d.duration.toFixed(1)+" s)";const langCode=DESIGN_LANG_CODE[$("design-language").value]||"EN";$("d-lang").value=langCode,$("d-gender").value=$("design-gender").value,$("d-name").value=designSafeName((currentDesignSource==null?void 0:currentDesignSource.title)||(currentDesignSource==null?void 0:currentDesignSource.name)||$("design-preset-name").value||"VoiceDesign"),_dVoiceIdManual=!1,_updateDVoiceId(),$("d-transcript").value=sample,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",$("transcript-area").value||($("transcript-area").value=sample),$("design-audio").play().catch(()=>{}),$("design-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Voice generated and export fields filled.","success"),status("Voice design ready")}catch(e){$("design-status").textContent="Failed: "+e.message,toast("Voice design failed: "+e.message,"error"),status("Voice design failed")}finally{$("design-generate-btn").disabled=!1}}$("design-generate-btn").addEventListener("click",runVoiceDesign),$("design-retry-btn").addEventListener("click",runVoiceDesign),$("design-save-btn").addEventListener("click",async()=>{if(!designedFileId){toast("No voice generated yet","error");return}const voiceId=$("d-voice-id").value.trim();if(!voiceId){toast("Enter a Voice ID first","error"),$("d-name").focus();return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("design-save-btn").disabled=!0;try{const r=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designedFileId,voice_id:voiceId,transcript:$("d-transcript").value})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const saved=await r.json();await saveMeta(saved.voice_id,{gender:$("d-gender").value,flag:LANG_FLAG_DEFAULT[$("d-lang").value]||void 0,transcript:$("d-transcript").value,note:"Voice Design: "+$("design-instruct").value.slice(0,240)}).catch(()=>{}),await loadVoiceLibrary().catch(()=>{}),$("design-save-result").style.display="flex",$("design-save-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Exported to Voice Clone Library: "+saved.voice_id,"success"),status("Exported to Voice Clone Library: "+saved.voice_id)}catch(e){toast("Save failed: "+e.message,"error")}finally{$("design-save-btn").disabled=!1}}),$("design-download-btn").addEventListener("click",()=>{if(!designedFileId)return;const a=document.createElement("a");a.href="/api/audio/"+designedFileId,a.download=($("d-voice-id").value.trim()||"voice_design")+".wav",a.click()}),(_y=$("clone-refresh-stt-btn"))==null||_y.addEventListener("click",async()=>{var _a2;$("clone-refresh-stt-btn").disabled=!0;try{await refreshSttBackends((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)}finally{$("clone-refresh-stt-btn").disabled=!1}}),$("transcribe-btn").addEventListener("click",async()=>{var _a2,_b2;const id=trimmedFileId||designedFileId||currentFileId;if(!id){toast("No audio to transcribe","error");return}const btn=$("transcribe-btn"),status2=$("transcribe-status"),area=$("transcript-area"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Transcribing\u2026',status2&&(status2.className="clone-tr-status working",status2.innerHTML=' Listening to your recording\u2026'),area&&(area.classList.add("transcribing"),area.placeholder="Transcribing your audio \u2014 please wait\u2026");try{const backend=((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)||"configured",r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id,backend})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();area&&(area.value=d.text),status2&&(status2.className="clone-tr-status done",status2.innerHTML=' Transcribed'),toast("Transcription complete","success"),(_b2=window._cloneScheduleAutoSave)==null||_b2.call(window)}catch(e){status2&&(status2.className="clone-tr-status error",status2.innerHTML=` Failed: ${escHtml(e.message||String(e))}`),toast("Transcription failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig,area&&(area.classList.remove("transcribing"),area.placeholder="Type or auto-transcribe the spoken text\u2026")}}),function(){var _a2,_b2,_c2;const g=id=>document.getElementById(id);let _idManual=!1,_prevName="Sam",_autoSaveTimer=null,_lastAutoSaved="";function buildVoiceId(){var _a3,_b3,_c3;if(_idManual){scheduleAutoSave();return}const lang=((_a3=g("lang-select"))==null?void 0:_a3.value)||"EN",gender=((_b3=g("gender-select"))==null?void 0:_b3.value)||"N",name=(((_c3=g("name-input"))==null?void 0:_c3.value)||"").trim().replace(/\s+/g,""),vid=g("voice-id-input");vid&&name&&(vid.value=`${lang}_${gender}_${name}`,vid.dispatchEvent(new Event("input"))),scheduleAutoSave()}const nameField=g("clone-your-name");nameField==null||nameField.addEventListener("input",()=>{const name=nameField.value.trim();if(!name)return;const sample=g("clone-sample-text");if(sample){const prev=sample.dataset.sampleName||_prevName,re=new RegExp("\\b"+prev.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b");re.test(sample.value)&&(sample.value=sample.value.replace(re,name)),sample.dataset.sampleName=name}_prevName=name;const ni=g("name-input");ni&&(ni.value=name.replace(/\s+/g,"")),buildVoiceId()}),["lang-select","gender-select"].forEach(id=>{var _a3;return(_a3=g(id))==null?void 0:_a3.addEventListener("change",buildVoiceId)}),(_a2=g("name-input"))==null||_a2.addEventListener("input",buildVoiceId),(_b2=g("voice-id-input"))==null||_b2.addEventListener("input",e=>{e.isTrusted&&(_idManual=!0),scheduleAutoSave()}),(_c2=g("transcript-area"))==null||_c2.addEventListener("input",scheduleAutoSave),window._cloneAutoTranscribe=function(){var _a3;const ta=g("transcript-area");if(ta&&ta.value.trim()){scheduleAutoSave();return}(_a3=g("transcribe-btn"))==null||_a3.click()};function canAutoSave(){var _a3,_b3;const vid=(((_a3=g("voice-id-input"))==null?void 0:_a3.value)||"").trim(),tr=(((_b3=g("transcript-area"))==null?void 0:_b3.value)||"").trim();return!!((typeof trimmedFileId!="undefined"&&trimmedFileId||typeof designedFileId!="undefined"&&designedFileId)&&vid&&tr&&(typeof validateVoiceId!="function"||validateVoiceId(vid)))}function scheduleAutoSave(){const toggle=g("clone-autosave-toggle");!toggle||!toggle.checked||(clearTimeout(_autoSaveTimer),_autoSaveTimer=setTimeout(()=>{var _a3;if(!canAutoSave())return;const sig=(g("voice-id-input").value+"|"+g("transcript-area").value).trim();sig!==_lastAutoSaved&&(_lastAutoSaved=sig,(_a3=g("save-btn"))==null||_a3.click())},1600))}window._cloneScheduleAutoSave=scheduleAutoSave}(),function(){const picker=document.getElementById("clone-src-picker");if(!picker)return;const cards=[...document.querySelectorAll(".clone-src-card")],tabs=[...picker.querySelectorAll(".clone-src-tab")],KEY="clone-src-choice";function show(src){cards.forEach(c=>{c.hidden=c.dataset.src!==src}),tabs.forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem(KEY,src)}catch{}}tabs.forEach(t=>t.addEventListener("click",()=>show(t.dataset.src))),show(localStorage.getItem(KEY)||"mic")}(),$("save-btn").addEventListener("click",async()=>{const id=trimmedFileId||designedFileId||currentFileId;if(!id){toast("No audio ready","error");return}const voiceId=$("voice-id-input").value.trim();if(!voiceId){toast("Enter a Voice ID","error");return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("save-btn").disabled=!0;try{const payload={id,voice_id:voiceId,path:editingVoicePath,transcript:$("transcript-area").value},sendSave=endpoint=>fetch(endpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});let fallbackSave=!1,r=await sendSave(editingVoiceId?"/api/voice-replace":"/api/save");if(editingVoiceId&&(r.status===404||r.status===405)&&(fallbackSave=!0,status("Update endpoint unavailable; saving as a regular voice\u2026"),r=await sendSave("/api/save")),!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();$("save-result").style.display="",toast((editingVoiceId?"Voice updated: ":"Voice saved: ")+d.voice_id,"success"),fallbackSave&&editingVoiceId&&voiceId!==editingVoiceId&&((await fetch("/api/voice/"+encodeURIComponent(editingVoiceId),{method:"DELETE"})).ok||status("Saved renamed voice; old library entry may need manual deletion.")),editingVoiceId=null,editingVoicePath=null}catch(e){toast("Save failed: "+e.message,"error")}finally{$("save-btn").disabled=!1}});let _voices=[],_pendingSelectId=null,_sortField="id",_sortDir=1,_libraryIssueFilter="",_activePlayButton=null,_activePlayVoiceId=null,_activePlayUrl=null,_libraryLoadPromise=null;const BENCHMARK_SAMPLE_STORAGE_KEY="vcf-benchmark-sample-text",_VL_CACHE_KEY="ttsvc_vc";function _vlCacheRead(){try{return JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY)||"null")}catch{return null}}function _vlCacheWrite(voices){try{sessionStorage.setItem(_VL_CACHE_KEY,JSON.stringify(voices))}catch{}}function _vlCacheClear(){try{sessionStorage.removeItem(_VL_CACHE_KEY)}catch{}}window._vlCacheClear=_vlCacheClear;const _libraryFilters={text:"",lang:"",sex:"",type:"",rating:""};let _libraryFilterOptionsSig="";const DEFAULT_BENCHMARK_SAMPLE_TEXT="Hello, how are you today? Please read this sample clearly for a fair voice benchmark.",BENCHMARK_PRESETS={de:"Die Welt ist voller Geschichten, die darauf warten, erz\xE4hlt zu werden \u2014 von mutigen Helden und stillen Tr\xE4umern.",en:"The old lighthouse stood firm against the crashing waves, its beam sweeping silently across the dark and restless sea.",de2:"Victor jagt zw\xF6lf Boxk\xE4mpfer quer \xFCber den gro\xDFen Sylter Deich. Im Winter ist es kalt und die Tage sind kurz.",en2:"She sells seashells by the seashore. Peter Piper picked a peck of pickled peppers on a perfectly pleasant afternoon.",reset:DEFAULT_BENCHMARK_SAMPLE_TEXT};function benchmarkSampleText(){const el=$("benchmark-sample-text");return el&&el.value.trim()||DEFAULT_BENCHMARK_SAMPLE_TEXT}function initBenchmarkSampleControls(){var _a2,_b2;const sample=$("benchmark-sample-text");if(!sample)return;sample.value=localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY)||DEFAULT_BENCHMARK_SAMPLE_TEXT,sample.addEventListener("input",debounce(()=>{localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value.trim()),status("Benchmark sample sentence saved")},500)),(_a2=$("benchmark-reset-sample-btn"))==null||_a2.addEventListener("click",()=>{sample.value=DEFAULT_BENCHMARK_SAMPLE_TEXT,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),status("Benchmark sample sentence reset")});const presetSel=$("benchmark-preset-select");presetSel&&presetSel.addEventListener("change",()=>{const key=presetSel.value;if(!key||!BENCHMARK_PRESETS[key]){presetSel.value="";return}sample.value=BENCHMARK_PRESETS[key],localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),presetSel.value="",status("Benchmark sample sentence loaded")}),(_b2=$("benchmark-use-preview-btn"))==null||_b2.addEventListener("click",()=>{var _a3;const text=(_a3=$("preview-text-area"))==null?void 0:_a3.value.trim();if(!text){toast("Preview text is empty","error");return}sample.value=text,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,text),status("Benchmark sample sentence copied from TTS preview")})}function _displaySource(v){if(v.origin)return v.origin;if(v.note){const m=v.note.match(/^Rehearser\s*·\s*(.+?)\s*·/);if(m)return m[1].trim()}if(v.tag){const tags=v.tag.split(",").map(t=>t.trim().toLowerCase());if(tags.includes("fish-audio")||tags.includes("fishaudio"))return"fish-audio"}return""}function getSortValue(v,field){var _a2,_b2,_c2,_d2,_e2,_f2;switch(field){case"has_picture":return v.has_picture?1:0;case"flag":return(v.flag||"").toLowerCase();case"gender":return(_a2={F:0,M:1,N:2}[v.gender])!=null?_a2:3;case"id":return v.id.toLowerCase();case"file_type":return voiceFileType(v);case"duration":return v.duration||0;case"dbfs":return(_b2=voiceDbfs(v))!=null?_b2:-999;case"benchmark":case"factor":return-((_c2=voiceFactor(v))!=null?_c2:-999);case"elapsed":return(_d2=voiceBenchmarkElapsed(v))!=null?_d2:999;case"bench_audio":return(_e2=voiceBenchmarkAudioSec(v))!=null?_e2:999;case"wpm":return(_f2=voiceWpm(v))!=null?_f2:-1;case"transcript":return(v.transcript||"").toLowerCase();case"note":return(v.note||"").toLowerCase();case"source":return(_displaySource(v)||"").toLowerCase();case"seed":return v.seed!=null?v.seed:9999999;case"tag":return(v.tag||"").toLowerCase();case"rating":return v.rating||0;case"enabled":return v.enabled===!1?0:1;default:return""}}function setSort(field){_sortDir=_sortField===field?_sortDir*-1:1,_sortField=field,syncSortHeaders(),renderVoiceList()}function toggleSortDir(){_sortDir*=-1,syncSortHeaders(),renderVoiceList()}function syncSortHeaders(){document.querySelectorAll(".vl-header [data-sort], .vl-table-header [data-sort]").forEach(el=>{el.classList.remove("sort-asc","sort-desc"),el.dataset.sort===_sortField&&el.classList.add(_sortDir===1?"sort-asc":"sort-desc")});const sel=document.getElementById("voice-sort-field");sel&&sel.value!==_sortField&&(sel.value=_sortField);const dirBtn=document.getElementById("voice-sort-dir");if(dirBtn){const icon=dirBtn.querySelector(".mdi");icon&&(icon.className=_sortDir===1?"mdi mdi-arrow-up":"mdi mdi-arrow-down"),dirBtn.title=_sortDir===1?"Ascending \u2014 click to reverse":"Descending \u2014 click to reverse"}}document.addEventListener("click",e=>{var _a2,_b2,_c2;if(e.target.closest("#voice-sort-dir")&&toggleSortDir(),e.target.closest("#voice-group-tag-btn")){window._voiceGroupByTag=!window._voiceGroupByTag;try{localStorage.setItem("vl-group-by-tag",window._voiceGroupByTag?"1":"0")}catch{}const btn=document.getElementById("voice-group-tag-btn");btn==null||btn.classList.toggle("active",window._voiceGroupByTag);const icon=btn==null?void 0:btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline"),renderVoiceList()}else if(e.target.closest("#voice-table-view-btn")){window._voiceTableView=!window._voiceTableView,window._voiceTableView||(window._voiceTableEditMode=!1);try{localStorage.setItem("vl-table-view",window._voiceTableView?"1":"0")}catch{}const btn=document.getElementById("voice-table-view-btn");btn==null||btn.classList.toggle("active",window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");if(editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode)),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",window._voiceTableView),(_b2=document.querySelector(".voices-workbench"))==null||_b2.classList.toggle("table-edit-mode",!!window._voiceTableEditMode),window._voiceTableView){document.querySelectorAll(".edit-open").forEach(r=>r.classList.remove("edit-open"));const inspector=document.getElementById("voices-inspector");inspector&&(inspector.innerHTML='

Table View Mode
Click a row to exit table view and edit.

')}renderVoiceList()}else if(e.target.closest("#voice-table-edit-btn")){window._voiceTableEditMode=!window._voiceTableEditMode;const editBtn=document.getElementById("voice-table-edit-btn");editBtn==null||editBtn.classList.toggle("active",window._voiceTableEditMode),(_c2=document.querySelector(".voices-workbench"))==null||_c2.classList.toggle("table-edit-mode",window._voiceTableEditMode),renderVoiceList()}});try{window._voiceGroupByTag=localStorage.getItem("vl-group-by-tag")==="1"}catch{}try{window._voiceTableView=localStorage.getItem("vl-table-view")==="1"}catch{}document.addEventListener("click",e=>{const th=e.target.closest(".vl-th-sortable");if(th){const field=th.dataset.sort;if(field){const sel=document.getElementById("voice-sort-field");sel&&(sel.value=field),setSort(field)}}}),document.addEventListener("change",e=>{e.target.id==="voice-sort-field"&&setSort(e.target.value)}),document.addEventListener("change",async e=>{if(e.target.classList.contains("vl-inline-edit")){const row=e.target.closest(".vl-row");if(!row)return;const voiceId=row.dataset.id,field=e.target.dataset.field;let value=e.target.type==="checkbox"?e.target.checked:e.target.value;field==="rating"&&(value=parseInt(value)||0);const payload={};payload[field]=value;try{await saveMeta(voiceId,payload);const v=_voices.find(vv=>vv.id===voiceId);v&&(v[field]=value),typeof toast=="function"&&toast("Saved "+field,"success")}catch{typeof toast=="function"&&toast("Failed to save "+field,"error")}}});const FLAG_LANGUAGE_CANDIDATES={GB:["EN"],US:["EN"],AU:["EN"],NZ:["EN"],IE:["EN"],ZA:["EN"],NG:["EN"],KE:["EN"],GH:["EN"],JM:["EN"],TT:["EN"],CA:["EN","FR"],IN:["EN","HI"],SG:["EN","ZH"],PH:["EN","FIL"],MT:["EN","MT"],DE:["DE"],AT:["DE"],CH:["DE","FR","IT"],FR:["FR"],BE:["FR","NL"],LU:["FR","DE"],ES:["ES"],MX:["ES"],AR:["ES"],CO:["ES"],CL:["ES"],PE:["ES"],VE:["ES"],UY:["ES"],EC:["ES"],BO:["ES"],CR:["ES"],CU:["ES"],DO:["ES"],PT:["PT"],BR:["PT"],IT:["IT"],NL:["NL"],PL:["PL"],SE:["SV"],DK:["DA"],NO:["NO"],FI:["FI"],IS:["IS"],GR:["EL"],CY:["EL","TR"],CZ:["CS"],SK:["SK"],HU:["HU"],RO:["RO"],BG:["BG"],HR:["HR"],SI:["SL"],RS:["SR"],BA:["BS"],ME:["SR"],MK:["MK"],AL:["SQ"],EE:["ET"],LV:["LV"],LT:["LT"],UA:["UK"],RU:["RU"],BY:["RU"],MD:["RO"],TR:["TR"],CN:["ZH"],TW:["ZH"],HK:["ZH"],MO:["ZH"],JP:["JA"],KR:["KO"],VN:["VI"],TH:["TH"],ID:["ID"],MY:["MS"],PK:["UR"],BD:["BN"],LK:["SI"],NP:["NE"],SA:["AR"],EG:["AR"],AE:["AR"],MA:["AR"],QA:["AR"],KW:["AR"],OM:["AR"],JO:["AR"],LB:["AR"],IQ:["AR"],IR:["FA"],IL:["HE"]},FLAG_LANGUAGE=Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc,langs])=>[cc,langs[0]])),LANGUAGE_LABELS={EN:"English",DE:"German",FR:"French",ES:"Spanish",PT:"Portuguese",IT:"Italian",NL:"Dutch",PL:"Polish",SV:"Swedish",DA:"Danish",NO:"Norwegian",FI:"Finnish",IS:"Icelandic",EL:"Greek",MT:"Maltese",CS:"Czech",SK:"Slovak",HU:"Hungarian",RO:"Romanian",BG:"Bulgarian",HR:"Croatian",SL:"Slovenian",SR:"Serbian",BS:"Bosnian",MK:"Macedonian",SQ:"Albanian",ET:"Estonian",LV:"Latvian",LT:"Lithuanian",UK:"Ukrainian",RU:"Russian",ZH:"Chinese",JA:"Japanese",KO:"Korean",VI:"Vietnamese",TH:"Thai",ID:"Indonesian",MS:"Malay",FIL:"Filipino",HI:"Hindi",UR:"Urdu",BN:"Bengali",SI:"Sinhala",NE:"Nepali",AR:"Arabic",FA:"Persian",HE:"Hebrew",TR:"Turkish"},SEX_FILTER_LABELS={F:"\u2640 Female",M:"\u2642 Male",N:"\u26A5 Diverse / neutral"};function voiceLangFromName(v){return(v.lang||String(v.id||"").split("_")[0]||"").toUpperCase()}function libraryVoiceLang(v){const fromName=voiceLangFromName(v),candidates=FLAG_LANGUAGE_CANDIDATES[String(v.flag||"").toUpperCase()];return candidates!=null&&candidates.length?candidates.includes(fromName)?fromName:candidates[0]:fromName}function libraryLanguageLabel(code){return LANGUAGE_LABELS[code]||code}function populateLibraryFilters(){const langSel=$("library-filter-lang"),sexSel=$("library-filter-sex"),typeSel=$("library-filter-type"),tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");if(!langSel||!sexSel||!typeSel)return;const langSet=new Set,sexSet=new Set,typeSet=new Set,tagSet=new Set,groupSet=new Set;(_voices||[]).forEach(v=>{const lang=libraryVoiceLang(v);lang&&langSet.add(lang),v.gender&&sexSet.add(v.gender);const type=voiceFileType(v);type&&typeSet.add(type),String(v.tag||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>tagSet.add(t));const g=(v.group||"").trim();g&&groupSet.add(g)});const langs=[...langSet].sort((a,b)=>libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b))),sexOrder=["F","M","N"],sexes=[...sexSet].sort((a,b)=>(sexOrder.indexOf(a)<0?99:sexOrder.indexOf(a))-(sexOrder.indexOf(b)<0?99:sexOrder.indexOf(b))),types=[...typeSet].sort(),tags=[...tagSet].sort((a,b)=>a.localeCompare(b)),groups=[...groupSet].sort((a,b)=>a.localeCompare(b)),sig=JSON.stringify([langs,sexes,types,tags,groups]);if(sig===_libraryFilterOptionsSig)return;_libraryFilterOptionsSig=sig;const keep={lang:langSel.value,sex:sexSel.value,type:typeSel.value,tag:tagSel==null?void 0:tagSel.value,group:groupSel==null?void 0:groupSel.value};langSel.innerHTML=''+langs.map(x=>``).join(""),sexSel.innerHTML=''+sexes.map(x=>``).join(""),typeSel.innerHTML=''+types.map(x=>``).join(""),tagSel&&(tagSel.innerHTML=''+tags.map(x=>``).join("")),groupSel&&(groupSel.innerHTML=''+groups.map(x=>``).join("")),langSel.value=langs.includes(keep.lang)?keep.lang:"",sexSel.value=sexes.includes(keep.sex)?keep.sex:"",typeSel.value=types.includes(keep.type)?keep.type:"",tagSel&&(tagSel.value=tags.includes(keep.tag)?keep.tag:""),groupSel&&(groupSel.value=groups.includes(keep.group)?keep.group:"")}function readLibraryFilters(){var _a2,_b2,_c2,_d2,_e2;_libraryFilters.text=(((_a2=$("library-filter-text"))==null?void 0:_a2.value)||"").trim().toLowerCase(),_libraryFilters.lang=((_b2=$("library-filter-lang"))==null?void 0:_b2.value)||"",_libraryFilters.sex=((_c2=$("library-filter-sex"))==null?void 0:_c2.value)||"",_libraryFilters.type=((_d2=$("library-filter-type"))==null?void 0:_d2.value)||"",_libraryFilters.rating=((_e2=$("library-filter-rating"))==null?void 0:_e2.value)||""}function libraryFilterMatch(v){const f=_libraryFilters;if(f.lang&&libraryVoiceLang(v)!==f.lang||f.sex&&(v.gender||"")!==f.sex||f.type&&voiceFileType(v)!==f.type)return!1;if(f.rating){const r=Number(v.rating||0),wanted=Number(f.rating);if(wanted===0&&r!==0||wanted===1&&r<1||wanted>1&&rString(x||"").toLowerCase()).join(" ").includes(f.text))}function clearLibraryFilters(){["library-filter-text","library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{const el=$(id);el&&(el.value="")}),readLibraryFilters(),renderVoiceList()}function libraryTtsBackend(){var _a2;return((_a2=$("library-tts-backend-select"))==null?void 0:_a2.value)||"voice_clone"}function needsDuration(v){return v.duration==null||Number.isNaN(Number(v.duration))}function voiceFileType(v){if(v.file_type)return String(v.file_type).replace(/^\./,"").toLowerCase();const match=String(v.path||v.filename||"").match(/\.([A-Za-z0-9]+)(?:$|[?#])/);return match?match[1].toLowerCase():"wav"}function voiceDbfs(v){var _a2;const value=v.loudness&&((_a2=v.loudness.dbfs)!=null?_a2:v.loudness.after_dbfs);return value==null||Number.isNaN(Number(value))?null:Number(value)}function fmtDbfs(v){const db=voiceDbfs(v);return db==null?"-":db.toFixed(1)}function voiceBenchmark(v){return v.benchmark&&typeof v.benchmark=="object"&&Object.keys(v.benchmark).length>0?v.benchmark:null}function voiceBenchmarkElapsed(v){const b=voiceBenchmark(v),value=b&&b.elapsed_sec;return value==null||Number.isNaN(Number(value))?null:Number(value)}function voiceFactor(v){const b=voiceBenchmark(v);return b&&b.ok&&b.speed!=null?Number(b.speed):null}function fmtFactor(v){const f=voiceFactor(v);return f!=null?f.toFixed(2)+"x":"-"}function fmtElapsed(v){const e=voiceBenchmarkElapsed(v),b=voiceBenchmark(v);return!b||!b.ok?b&&!b.ok?"ERR":"-":e!=null?e.toFixed(1)+"s":"-"}function fmtBenchmark(v){const elapsed=fmtElapsed(v),factor=fmtFactor(v);return elapsed==="-"&&factor==="-"?"-":[elapsed,factor].filter(x=>x!=="-").join(" \xB7 ")}function benchmarkClass(v){const b=voiceBenchmark(v);if(!b)return"";if(!b.ok||b.clipped||b.realtime_ok===!1)return"bench-bad";const elapsed=voiceBenchmarkElapsed(v);return elapsed!=null&&elapsed<=4?"bench-ok":"bench-warn"}function voiceBenchmarkAudioSec(v){const b=voiceBenchmark(v);return b&&b.ok&&b.audio_sec!=null?Number(b.audio_sec):null}function fmtBenchmarkAudio(v){const sec=voiceBenchmarkAudioSec(v);return sec!=null?sec.toFixed(1)+"s":"-"}function voiceWpm(v){const b=voiceBenchmark(v);if(!b||!b.ok||!b.audio_sec||!b.text)return null;const words=b.text.trim().split(/\s+/).length;return Math.round(words/(b.audio_sec/60))}function fmtWpm(v){const wpm=voiceWpm(v);return wpm!=null?wpm+" wpm":"-"}function voiceFileUrl(v){const bust=v._audioVersion||v.updated_at||v.benchmarked_at||""||Date.now();return`/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`}function markVoiceAudioChanged(v){v._audioVersion=Date.now()}function benchmarkTitle(v){const b=voiceBenchmark(v);if(!b)return"Not benchmarked yet";const parts=[];return b.ok?(parts.push(`total ${Number(b.elapsed_sec||0).toFixed(2)}s`),b.ttfa_ms!=null&&parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`),b.audio_sec!=null&&parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`),b.rtf!=null&&parts.push(`RTF ${Number(b.rtf).toFixed(2)}`),b.speed!=null&&parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`),b.clipped&&parts.push("output clipped")):(parts.push("benchmark failed"),b.error&&parts.push(b.error)),Array.isArray(b.advice)&&b.advice.length&&parts.push(b.advice.join(" | ")),b.benchmarked_at&&parts.push(`saved ${b.benchmarked_at}`),parts.join(" \xB7 ")}async function clientVoiceLoudness(v){if(!v.path)throw new Error("No audio path");const resp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const audioData=await resp.arrayBuffer(),buffer=await new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(audioData.slice(0));let sum=0,peak=0,count=0;for(let ch=0;ch0?20*Math.log10(rms):null,peakDbfs=peak>0?20*Math.log10(peak):null;return{dbfs:dbfs==null?null:Number(dbfs.toFixed(2)),peak_dbfs:peakDbfs==null?null:Number(peakDbfs.toFixed(2))}}async function clientCalculateVoiceDb(){const voices=_bulkSelected&&_bulkSelected.size>0?(_voices||[]).filter(v=>_bulkSelected.has(v.id)):visibleLibraryVoices(),errors=[];let calculated=0;const stats={startedAt:Date.now(),ok:0,slow:0,errors:0,middleLabel:"Skipped"};setBenchmarkProgress(0,voices.length,"Preparing dB scan...",stats);for(const v of voices){setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats);try{v.loudness=await clientVoiceLoudness(v),await saveMeta(v.id,{loudness:v.loudness}).catch(()=>{}),calculated++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs(v)} dBFS`,status(`Calculated dB: ${calculated} / ${voices.length}`)}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats),await new Promise(resolve=>setTimeout(resolve,0))}return setBenchmarkProgress(voices.length,voices.length,"dB scan complete",stats),{calculated,errors,voices:voices.map(v=>({voice_id:v.id,loudness:v.loudness}))}}async function hydrateVoiceDuration(v,el){if(!(!v.path||!needsDuration(v)||v._durationLoading)){v._durationLoading=!0;try{const audio=new Audio;audio.preload="metadata",audio.src=voiceFileUrl(v),await new Promise((resolve,reject)=>{audio.onloadedmetadata=resolve,audio.onerror=()=>reject(new Error("Could not read duration"))}),Number.isFinite(audio.duration)&&audio.duration>0&&(v.duration=audio.duration,el&&document.body.contains(el)&&(el.textContent=fmtDuration(v.duration),el.title=String(v.duration.toFixed(2)))),audio.removeAttribute("src"),audio.load()}catch(e){el&&document.body.contains(el)&&(el.title=e.message)}finally{v._durationLoading=!1}}}document.querySelectorAll(".vl-header [data-sort]").forEach(el=>el.addEventListener("click",()=>setSort(el.dataset.sort)));function dominantLanguages(limit=3){const counts=new Map;return(_voices||[]).forEach(v=>{const lang=(v.lang||String(v.id||"").split("_")[0]||"?").toUpperCase();counts.set(lang,(counts.get(lang)||0)+1)}),[...counts.entries()].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0])).slice(0,limit).map(([lang,count])=>`${lang} ${count}`).join(" \xB7 ")||"-"}function updateLibraryInsights(state="ready"){const el=$("library-insights");if(!el)return;if(state==="loading"){el.innerHTML=[["\u2026","Loading"],["\u2026","Active"],["\u2026","Languages"],["\u2026","Benchmarks"],["\u2026","Quality"],["\u2026","Actions"]].map(([value,label])=>`
${value}${label}
`).join("");return}if(state==="error"){el.innerHTML='
FailedLibrary load
';return}const total=_voices.length,active=_voices.filter(v=>v.enabled!==!1).length,hidden=total-active,bench=_voices.map(voiceBenchmark).filter(Boolean),slow=bench.filter(b=>b&&b.ok&&b.realtime_ok===!1).length,dbValues=_voices.map(voiceDbfs).filter(v=>v!=null),avgDb=dbValues.length?(dbValues.reduce((a,b)=>a+b,0)/dbValues.length).toFixed(1):"-",missingRef=_voices.filter(v=>!v.transcript).length,restart=_voices.filter(v=>v.needs_tts_restart).length,tiles=[{value:`${_voices.filter(v=>$("show-disabled-cb").checked||v.enabled!==!1).length}/${total}`,label:"Visible"},{value:`${active} on`,label:hidden?`${hidden} hidden`:"Active"},{value:dominantLanguages(),label:"Languages"},{value:bench.length?`${bench.length} done`:"-",label:slow?`${slow} slow`:"Benchmarks",filter:slow?"slow":"",title:slow?describeIssueVoices("slow"):"No slow voices"},{value:avgDb==="-"?"-":`${avgDb} dB`,label:missingRef?`${missingRef} no text`:"Avg loudness",filter:missingRef?"no_text":"",title:missingRef?describeIssueVoices("no_text"):"All visible voices have reference text"},{value:restart||"-",label:restart?"Need restart":"Restart flags",filter:restart?"restart":"",title:restart?describeIssueVoices("restart"):"No voices need restart"}];el.innerHTML=tiles.map(item=>{const filter=item.filter?` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"`:"",activeCls=item.filter&&item.filter===_libraryIssueFilter?" active":"",title=item.title?` title="${escHtml(item.title)}"`:"";return`
${escHtml(item.value)}${escHtml(item.label)}
`}).join(""),el.querySelectorAll("[data-filter]").forEach(tile=>{const activate=()=>setLibraryIssueFilter(tile.dataset.filter||"");tile.addEventListener("click",activate),tile.addEventListener("keydown",e=>{(e.key==="Enter"||e.key===" ")&&(e.preventDefault(),activate())})})}function shouldRenderVoiceLibrary(){const section=$("s-voices");return!section||section.classList.contains("is-active")}async function loadVoiceLibrary(options={}){const forceRefresh=!!(options&&options.refresh);return _libraryLoadPromise?_libraryLoadPromise.then(()=>{shouldRenderVoiceLibrary()&&_voices.length&&renderVoiceList()}):(_libraryLoadPromise=(async()=>{setBusyButton("refresh-voices-btn",!0);const list=$("voice-list"),renderVisibleList=shouldRenderVoiceLibrary(),cached=_voices.length===0?_vlCacheRead():null;cached&&Array.isArray(cached)&&cached.length&&(_voices=cached,window._voices=_voices,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices),renderVisibleList&&renderVoiceList(),updatePreviewVoiceMatchPanel(),status(`Loaded ${_voices.length} voices`));const silent=_voices.length>0;list&&!silent&&renderVisibleList&&(list.innerHTML=loadingMarkup("Loading voice library","Scanning voices, reference text, metadata, ratings, and benchmark results.",8)),!silent&&renderVisibleList&&($("voice-count").textContent="Loading voices\u2026",updateLibraryInsights("loading"),status("Loading voice library\u2026"));try{const r=await fetch("/api/voices"+(forceRefresh?"?refresh=1":""));if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const fresh=await r.json();_vlCacheWrite(fresh),_voices=fresh,window._voices=_voices,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices),shouldRenderVoiceLibrary()&&renderVoiceList(),updatePreviewVoiceMatchPanel(),typeof renderPerfHistory=="function"&&renderPerfHistory(),status(`Loaded ${_voices.length} voices`)}catch(e){if(!silent&&renderVisibleList&&(list&&(list.innerHTML='
Failed to load voices: '+(e.message||String(e))+"
"),$("voice-count").textContent="Load failed",updateLibraryInsights("error")),status("Voice library load failed: "+e.message),!silent)throw e}finally{setBusyButton("refresh-voices-btn",!1),_libraryLoadPromise=null}})(),_libraryLoadPromise)}$("refresh-voices-btn").addEventListener("click",()=>{_vlCacheClear(),loadVoiceLibrary({refresh:!0})}),$("sync-voice-folders-btn").addEventListener("click",async()=>{$("sync-voice-folders-btn").disabled=!0,status("Syncing active_voices and hidden_voices\u2026");try{const r=await fetch("/api/voices/sync-folders",{method:"POST"});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();await loadVoiceLibrary();const conflicts=d.conflicts&&d.conflicts.length?`, ${d.conflicts.length} conflicts`:"";toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`,d.conflicts&&d.conflicts.length?"error":"success"),status("Synced folders. Restart Qwen3-TTS after changing active voices.")}catch(e){toast("Sync failed: "+e.message,"error"),status("Folder sync failed")}finally{$("sync-voice-folders-btn").disabled=!1}});function visibleLibraryVoices(){const showDisabled=$("show-disabled-cb").checked;return _voices.filter(v=>showDisabled||v.enabled!==!1)}function libraryIssueMatch(v,filter=_libraryIssueFilter){const b=voiceBenchmark(v);return filter==="slow"?!!(b&&b.ok&&b.realtime_ok===!1):filter==="no_text"?!String(v.transcript||"").trim():filter==="restart"?!!v.needs_tts_restart:!0}function libraryIssueLabel(filter=_libraryIssueFilter){return{slow:"slow benchmark voices",no_text:"voices without reference text",restart:"voices needing TTS restart"}[filter]||"all voices"}function libraryIssueVoices(filter=_libraryIssueFilter){return visibleLibraryVoices().filter(v=>libraryIssueMatch(v,filter))}function describeIssueVoices(filter=_libraryIssueFilter,limit=12){const voices=libraryIssueVoices(filter).map(v=>v.id);if(!voices.length)return"No matching voices";const extra=voices.length>limit?`, +${voices.length-limit} more`:"";return voices.slice(0,limit).join(", ")+extra}function setLibraryIssueFilter(filter=""){_libraryIssueFilter=_libraryIssueFilter===filter?"":filter,renderVoiceList(),status(_libraryIssueFilter?`${libraryIssueLabel()}: ${describeIssueVoices()}`:"Showing all visible voices")}function libraryTargetDb(){var _a2;const input=$("library-target-db"),raw=Number((_a2=input==null?void 0:input.value)!=null?_a2:-20),value=Number.isFinite(raw)?Math.min(-1,Math.max(-60,raw)):-20;return input&&(input.value=String(value)),value}$("calculate-db-btn").addEventListener("click",async()=>{$("calculate-db-btn").disabled=!0;const _calcTarget=_bulkSelected&&_bulkSelected.size>0?`${_bulkSelected.size} selected`:"visible";status(`Calculating voice loudness (${_calcTarget})\u2026`);try{const d=await clientCalculateVoiceDb();renderVoiceList();const extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Calculated dB for ${d.calculated} voices${extra}`,d.errors&&d.errors.length?"error":"success"),status("Calculated voice loudness. Use Normalize volume for visible WAV voices.")}catch(e){toast("Calculate dB failed: "+e.message,"error"),status("dB calculation failed")}finally{$("calculate-db-btn").disabled=!1}}),$("normalize-volume-btn").addEventListener("click",async()=>{var _a2;const target=libraryTargetDb(),visible=visibleLibraryVoices(),voices=visible.filter(v=>voiceFileType(v)==="wav"),skipped=visible.length-voices.length;if(!voices.length){toast("No visible WAV voices to normalize","error");return}if(!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped?` ${skipped} non-WAV voices will be skipped.`:""}`))return;$("normalize-volume-btn").disabled=!0,$("calculate-db-btn").disabled=!0;const stats={startedAt:Date.now(),ok:0,slow:skipped,errors:0,middleLabel:"Skipped"},errors=[];let normalized=0;setBenchmarkProgress(0,voices.length,`Normalizing to ${target} dBFS...`,stats),status(`Normalizing ${voices.length} voices to ${target} dBFS...`);try{for(const v of voices){setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats);try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a2=d.duration)!=null?_a2:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),normalized++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs(v)} dBFS`}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats),status(`Normalized ${normalized} / ${voices.length}`),await new Promise(resolve=>setTimeout(resolve,0))}setBenchmarkProgress(voices.length,voices.length,"Volume normalization complete",stats),renderVoiceList(),updateLibraryInsights();const extra=`${skipped?`, ${skipped} skipped`:""}${errors.length?`, ${errors.length} errors`:""}`;toast(`Normalized ${normalized} voices${extra}`,errors.length?"error":"success"),status("Volume normalized. Restart TTS before rebenchmarking these voices.")}catch(e){toast("Normalize volume failed: "+e.message,"error"),status("Normalize volume failed")}finally{$("normalize-volume-btn").disabled=!1,$("calculate-db-btn").disabled=!1}});function fmtClock(ms){if(!Number.isFinite(ms)||ms<0)return"-";const total=Math.round(ms/1e3),m=Math.floor(total/60),s=total%60;return`${m}:${String(s).padStart(2,"0")}`}function setBenchmarkProgress(done,total,label="",stats={}){const panel=$("benchmark-progress"),track=panel.querySelector(".benchmark-progress-track"),pct=total?Math.round(done/total*100):0;panel.hidden=!1,$("benchmark-progress-label").textContent=label||(done>=total?"Benchmark complete":"Benchmarking voices..."),$("benchmark-progress-count").textContent=`${done} / ${total}`,$("benchmark-progress-bar").style.width=pct+"%",track.setAttribute("aria-valuenow",String(pct));const live=$("benchmark-live-stats");if(live){const elapsed=stats.startedAt?Date.now()-stats.startedAt:0,avg=done>0?elapsed/done:0,eta=done>0&&total>done?avg*(total-done):0;live.innerHTML=[`Elapsed ${fmtClock(elapsed)}`,`Avg ${done?(avg/1e3).toFixed(1)+"s":"-"}`,`ETA ${done&&total>done?fmtClock(eta):"-"}`,`OK ${stats.ok||0}`,`${stats.middleLabel||"Slow"} ${stats.slow||0}`,`${stats.errorLabel||"Errors"} ${stats.errors||0}`].map(x=>`${escHtml(x)}`).join("")}const last=$("benchmark-live-last");last&&stats.last&&(last.textContent=stats.last)}function hideBenchmarkProgress(){$("benchmark-progress").hidden=!0,$("benchmark-progress-bar").style.width="0%",$("benchmark-live-last")&&($("benchmark-live-last").textContent="")}async function clearTtsRestartFlags(){const r=await fetch("/api/tts/restart-flags/clear",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();return _voices.forEach(voice=>{voice.needs_tts_restart=!1}),document.querySelectorAll(".vl-row.edit-open").forEach(row=>row.classList.remove("opt-restart-needed")),updateLibraryInsights(),d}async function runVoiceBenchmark(voiceId="",opts={}){var _a2;const text=(_a2=opts.text)!=null?_a2:benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;const payload={active_only:!0,text};voiceId&&(payload.voice_id=voiceId);const r=await fetch("/api/voices/benchmark",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return r.json()}async function runVoiceBenchmarkBatch(){const voices=benchmarkTargetVoices(),text=benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;if(!voices.length)return toast("No voices to benchmark","error"),null;const total=voices.length,aggregate={benchmarked:0,errors:[],voices:[],text,active_only:!0},stats={startedAt:Date.now(),ok:0,slow:0,errors:0,last:""};voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&(row.classList.remove("benchmarking-active","benchmarking-done"),row.classList.add("benchmarking-pending"))}),setBenchmarkProgress(0,total,"Starting benchmark...",stats);for(let i=0;ix.voice_id===voice.id),b=hit&&hit.benchmark;if(b&&b.ok){if(stats.ok++,b.realtime_ok===!1&&stats.slow++,stats.last=`${voice.id}: ${Number(b.elapsed_sec||0).toFixed(1)}s${b.speed!=null?` \xB7 ${Number(b.speed).toFixed(2)}x`:""}${b.realtime_ok===!1?" \xB7 slow":""}`,row){const bc=benchmarkClass(voice),btitle=benchmarkTitle(voice),durCell=row.querySelector(".vl-tbl-dur"),factorCell=row.querySelector(".vl-tbl-factor"),timeCell=row.querySelector(".vl-tbl-time"),wpmCell=row.querySelector(".vl-tbl-wpm");if(durCell&&(durCell.textContent=fmtBenchmarkAudio(voice),durCell.title=`${fmtBenchmarkAudio(voice)} \u2014 length of synthesised benchmark audio`),factorCell&&(factorCell.textContent=fmtFactor(voice),factorCell.className=`vl-tbl-factor ${bc}`,factorCell.title=btitle),timeCell&&(timeCell.textContent=fmtElapsed(voice),timeCell.className=`vl-tbl-time ${bc}`,timeCell.title=btitle),wpmCell){const wpm=voiceWpm(voice);wpmCell.textContent=fmtWpm(voice),wpmCell.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}}else stats.errors++,stats.last=`${voice.id}: failed${b&&b.error?" \xB7 "+b.error:""}`}}catch(e){aggregate.errors.push({voice_id:voice.id,detail:e.message}),stats.errors++,stats.last=`${voice.id}: failed \xB7 ${e.message}`}row&&(row.classList.remove("benchmarking-active"),row.classList.add("benchmarking-done")),setBenchmarkProgress(i+1,total,`Finished ${voice.id}`,stats)}return voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&row.classList.remove("benchmarking-pending")}),setBenchmarkProgress(total,total,"Benchmark complete",stats),aggregate}function mergeBenchmarkResults(d){const byId=new Map((d.voices||[]).map(x=>[x.voice_id,x]));_voices.forEach(v=>{const hit=byId.get(v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark)})}window.loadVoiceLibrary=loadVoiceLibrary,window.mergeBenchmarkResults=mergeBenchmarkResults;function activeBenchmarkVoices(){return(_voices||[]).filter(v=>v.enabled!==!1)}function benchmarkTargetVoices(){return typeof _bulkSelected!="undefined"&&_bulkSelected.size>0?(_voices||[]).filter(v=>_bulkSelected.has(v.id)):activeBenchmarkVoices()}function showBenchmarkConfirm(){const voices=benchmarkTargetVoices(),onlySelected=typeof _bulkSelected!="undefined"&&_bulkSelected.size>0;if(!benchmarkSampleText()){toast("Enter a benchmark sample sentence","error");return}if(!voices.length){toast("No voices to benchmark","error");return}const staleCount=voices.filter(v=>v.needs_tts_restart).length;$("benchmark-confirm-title").textContent=`Benchmark ${voices.length} ${onlySelected?"selected":"active"} voice${voices.length===1?"":"s"}?`,$("benchmark-confirm-text").textContent="This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice."+(staleCount?` ${staleCount} edited voice${staleCount===1?"":"s"} should be restarted first, otherwise cached old voices may be benchmarked.`:""),$("benchmark-confirm").hidden=!1,$("benchmark-confirm-start").focus()}function hideBenchmarkConfirm(){const panel=$("benchmark-confirm");panel&&(panel.hidden=!0)}$("benchmark-voices-btn").addEventListener("click",showBenchmarkConfirm),(_z=$("benchmark-confirm-cancel"))==null||_z.addEventListener("click",hideBenchmarkConfirm),(_A=$("benchmark-confirm-start"))==null||_A.addEventListener("click",async()=>{hideBenchmarkConfirm(),$("benchmark-voices-btn").disabled=!0,$("benchmark-confirm-start").disabled=!0,status("Benchmarking active voices...");try{const d=await runVoiceBenchmarkBatch();if(!d)return;mergeBenchmarkResults(d),await loadVoiceLibrary();const slow=(d.voices||[]).filter(x=>x.benchmark&&x.benchmark.realtime_ok===!1).length,extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Benchmarked ${d.benchmarked} voices${slow?`, ${slow} slow`:""}${extra}`,d.errors&&d.errors.length?"error":"success"),status("Benchmark saved with TTFA, total time, RTF, and speed.")}catch(e){toast("Benchmark failed: "+e.message,"error"),status("Benchmark failed")}finally{$("benchmark-voices-btn").disabled=!1,$("benchmark-confirm-start").disabled=!1}}),$("copy-active-voices-btn").addEventListener("click",async()=>{const useSelected=_bulkSelected&&_bulkSelected.size>0,ids=useSelected?[..._bulkSelected]:activeVoiceIds(),label=useSelected?`${ids.length} selected`:`${ids.length} active`;if(!ids.length){toast("No voices to copy","error");return}await copyText(ids.join(", ")),toast("Copied "+label+" voices","success"),status("Copied "+label+" voices to clipboard")}),(_B=$("precompute-embeddings-btn"))==null||_B.addEventListener("click",async()=>{const backend=libraryTtsBackend(),_useSelected=_bulkSelected&&_bulkSelected.size>0,ids=_useSelected?[..._bulkSelected]:activeVoiceIds(),_scopeLabel=_useSelected?`${ids.length} selected`:`${ids.length} active`;if(!ids.length){toast("No voices to precompute","error");return}if(!confirm(`Precompute speaker embeddings for ${_scopeLabel} voice(s) via \u201C${backend}\u201D? + `,list.appendChild(card)}))}function applyQwenSample(sample){$("design-instruct").value=sample.description,$("design-sample-text").value=sample.text,$("design-language").value=sample.language,$("design-gender").value=sample.gender,currentDesignSource=sample,$("design-result").style.display="none",$("design-save-result").style.display="none",$("design-instruct").scrollIntoView({behavior:"smooth",block:"nearest"})}function isDialogueDesign(instruct,text,source=null){if(source&&source.dialogue)return!0;const speakers=new Set;if(String(instruct||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/);match&&speakers.add(match[1].trim())}),speakers.size<2)return!1;const turnSpeakers=new Set;return String(text||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^([^:]{1,40}):\s*(.+)$/);match&&speakers.has(match[1].trim())&&turnSpeakers.add(match[1].trim())}),turnSpeakers.size>=2}function voiceDesignPayload(instruct,sampleText,language,source=null,gender=null){var _a2;return{instruct,sample_text:sampleText,language,gender:gender||(source==null?void 0:source.gender)||((_a2=$("design-gender"))==null?void 0:_a2.value)||"",dialogue:isDialogueDesign(instruct,sampleText,source)}}let _dVoiceIdManual=!1;function designSafeName(name){const base=name||"VoiceDesign";return(typeof _umlautSafe=="function"?_umlautSafe(base):String(base)).replace(/^[A-Z]{2}_[FMN]_/,"").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,42)||"VoiceDesign"}function voiceIdSafePart(value,fallback="style"){return(typeof _umlautSafe=="function"?_umlautSafe(value||fallback):String(value||fallback)).replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,32)||fallback}function suggestedStyleVoiceId(baseId,style){const suffix=voiceIdSafePart(style||"style");return`${baseId}_${suffix}`.slice(0,96)}function _updateDVoiceId(){if(_dVoiceIdManual)return;const lang=$("d-lang").value,gender=$("d-gender").value,name=$("d-name").value.trim();$("d-voice-id").value=name?`${lang}_${gender}_${name}`:""}["d-lang","d-gender"].forEach(id=>$(id).addEventListener("change",_updateDVoiceId)),$("d-name").addEventListener("input",()=>{_dVoiceIdManual=!1,_updateDVoiceId()}),$("d-voice-id").addEventListener("input",()=>{_dVoiceIdManual=!0}),seedDesignPresets(),refreshDesignPresetSelect(),renderQwenSampleCards(),syncDesignPresetsToServer(),$("design-preset-select").addEventListener("change",()=>{$("design-preset-select").value&&applyDesignPreset($("design-preset-select").value)}),$("design-preset-load").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}applyDesignPreset(name)}),$("design-preset-save").addEventListener("click",()=>{const name=$("design-preset-name").value.trim()||$("design-preset-select").value;if(!name){toast("Enter a preset name","error"),$("design-preset-name").focus();return}const presets=loadDesignPresets();presets[name]={description:$("design-instruct").value,sample_text:$("design-sample-text").value,language:$("design-language").value,gender:$("design-gender").value,dialogue:isDialogueDesign($("design-instruct").value,$("design-sample-text").value,currentDesignSource)},saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-select").value=name,toast("Preset saved: "+name,"success")}),$("design-preset-delete").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}const presets=loadDesignPresets();if(!presets[name]){toast("Preset not found","error");return}delete presets[name],saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-name").value="",toast("Preset deleted: "+name,"success")}),["design-instruct","design-sample-text"].forEach(id=>$(id).addEventListener("input",()=>{currentDesignSource=null,id==="design-sample-text"&&($("d-transcript").value=$("design-sample-text").value)})),document.querySelectorAll(".qwen-sample").forEach(card=>{const sample=QWEN_DESIGN_SAMPLES[card.dataset.qwenSample],state=card.querySelector(".qwen-state"),audio=card.querySelector("audio");card.querySelector(".qwen-use").addEventListener("click",()=>{applyQwenSample(sample),toast("Voice Design sample loaded","success")}),card.querySelector(".qwen-preview").addEventListener("click",async e=>{const btn=e.currentTarget;btn.disabled=!0,state.textContent="Generating preview\u2026";try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(sample.description,sample.text,sample.language,sample))});if(!r.ok){const err=await r.json().catch(()=>({}));throw new Error(err.detail||r.statusText)}const d=await r.json();audio.src="/api/audio/"+d.id,audio.style.display="",audio.play().catch(()=>{}),state.textContent="Preview ready"}catch(err){state.textContent="Preview failed",toast("Sample preview failed: "+err.message,"error")}finally{btn.disabled=!1}})});async function runVoiceDesign(){const baseInstruct=$("design-instruct").value.trim(),sample=$("design-sample-text").value.trim(),dialogue=isDialogueDesign(baseInstruct,sample,currentDesignSource),instruct=baseInstruct;if(!instruct){toast("Enter a voice description first","error");return}$("design-generate-btn").disabled=!0,$("design-status").textContent="Generating\u2026",$("design-result").style.display="none",$("design-save-result").style.display="none",status("Generating voice design\u2026");try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(instruct,sample,$("design-language").value,currentDesignSource,$("design-gender").value))});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();designedFileId=d.id,trimmedFileId=null,editingVoiceId=null,$("design-audio").src="/api/audio/"+d.id,$("design-result").style.display="flex",$("design-status").textContent="Done ("+d.duration.toFixed(1)+" s)";const langCode=DESIGN_LANG_CODE[$("design-language").value]||"EN";$("d-lang").value=langCode,$("d-gender").value=$("design-gender").value,$("d-name").value=designSafeName((currentDesignSource==null?void 0:currentDesignSource.title)||(currentDesignSource==null?void 0:currentDesignSource.name)||$("design-preset-name").value||"VoiceDesign"),_dVoiceIdManual=!1,_updateDVoiceId(),$("d-transcript").value=sample,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",$("transcript-area").value||($("transcript-area").value=sample),$("design-audio").play().catch(()=>{}),$("design-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Voice generated and export fields filled.","success"),status("Voice design ready")}catch(e){$("design-status").textContent="Failed: "+e.message,toast("Voice design failed: "+e.message,"error"),status("Voice design failed")}finally{$("design-generate-btn").disabled=!1}}$("design-generate-btn").addEventListener("click",runVoiceDesign),$("design-retry-btn").addEventListener("click",runVoiceDesign),$("design-save-btn").addEventListener("click",async()=>{if(!designedFileId){toast("No voice generated yet","error");return}const voiceId=$("d-voice-id").value.trim();if(!voiceId){toast("Enter a Voice ID first","error"),$("d-name").focus();return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("design-save-btn").disabled=!0;try{const r=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designedFileId,voice_id:voiceId,transcript:$("d-transcript").value})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const saved=await r.json();await saveMeta(saved.voice_id,{gender:$("d-gender").value,flag:LANG_FLAG_DEFAULT[$("d-lang").value]||void 0,transcript:$("d-transcript").value,note:"Voice Design: "+$("design-instruct").value.slice(0,240)}).catch(()=>{}),await loadVoiceLibrary().catch(()=>{}),$("design-save-result").style.display="flex",$("design-save-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Exported to Voice Clone Library: "+saved.voice_id,"success"),status("Exported to Voice Clone Library: "+saved.voice_id)}catch(e){toast("Save failed: "+e.message,"error")}finally{$("design-save-btn").disabled=!1}}),$("design-download-btn").addEventListener("click",()=>{if(!designedFileId)return;const a=document.createElement("a");a.href="/api/audio/"+designedFileId,a.download=($("d-voice-id").value.trim()||"voice_design")+".wav",a.click()}),(_y=$("clone-refresh-stt-btn"))==null||_y.addEventListener("click",async()=>{var _a2;$("clone-refresh-stt-btn").disabled=!0;try{await refreshSttBackends((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)}finally{$("clone-refresh-stt-btn").disabled=!1}}),$("transcribe-btn").addEventListener("click",async()=>{var _a2,_b2;const id=trimmedFileId||designedFileId||currentFileId;if(!id){toast("No audio to transcribe","error");return}const btn=$("transcribe-btn"),status2=$("transcribe-status"),area=$("transcript-area"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Transcribing\u2026',status2&&(status2.className="clone-tr-status working",status2.innerHTML=' Listening to your recording\u2026'),area&&(area.classList.add("transcribing"),area.placeholder="Transcribing your audio \u2014 please wait\u2026");try{const backend=((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)||"configured",r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id,backend})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();area&&(area.value=d.text),status2&&(status2.className="clone-tr-status done",status2.innerHTML=' Transcribed'),toast("Transcription complete","success"),(_b2=window._cloneScheduleAutoSave)==null||_b2.call(window)}catch(e){status2&&(status2.className="clone-tr-status error",status2.innerHTML=` Failed: ${escHtml(e.message||String(e))}`),toast("Transcription failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig,area&&(area.classList.remove("transcribing"),area.placeholder="Type or auto-transcribe the spoken text\u2026")}}),function(){var _a2,_b2,_c2;const g=id=>document.getElementById(id);let _idManual=!1,_prevName="Sam",_autoSaveTimer=null,_lastAutoSaved="";function buildVoiceId(){var _a3,_b3,_c3;if(_idManual){scheduleAutoSave();return}const lang=((_a3=g("lang-select"))==null?void 0:_a3.value)||"EN",gender=((_b3=g("gender-select"))==null?void 0:_b3.value)||"N",name=(((_c3=g("name-input"))==null?void 0:_c3.value)||"").trim().replace(/\s+/g,""),vid=g("voice-id-input");vid&&name&&(vid.value=`${lang}_${gender}_${name}`,vid.dispatchEvent(new Event("input"))),scheduleAutoSave()}const nameField=g("clone-your-name");nameField==null||nameField.addEventListener("input",()=>{const name=nameField.value.trim();if(!name)return;const sample=g("clone-sample-text");if(sample){const prev=sample.dataset.sampleName||_prevName,re=new RegExp("\\b"+prev.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b");re.test(sample.value)&&(sample.value=sample.value.replace(re,name)),sample.dataset.sampleName=name}_prevName=name;const ni=g("name-input");ni&&(ni.value=name.replace(/\s+/g,"")),buildVoiceId()}),["lang-select","gender-select"].forEach(id=>{var _a3;return(_a3=g(id))==null?void 0:_a3.addEventListener("change",buildVoiceId)}),(_a2=g("name-input"))==null||_a2.addEventListener("input",buildVoiceId),(_b2=g("voice-id-input"))==null||_b2.addEventListener("input",e=>{e.isTrusted&&(_idManual=!0),scheduleAutoSave()}),(_c2=g("transcript-area"))==null||_c2.addEventListener("input",scheduleAutoSave),window._cloneAutoTranscribe=function(){var _a3;const ta=g("transcript-area");if(ta&&ta.value.trim()){scheduleAutoSave();return}(_a3=g("transcribe-btn"))==null||_a3.click()};function canAutoSave(){var _a3,_b3;const vid=(((_a3=g("voice-id-input"))==null?void 0:_a3.value)||"").trim(),tr=(((_b3=g("transcript-area"))==null?void 0:_b3.value)||"").trim();return!!((typeof trimmedFileId!="undefined"&&trimmedFileId||typeof designedFileId!="undefined"&&designedFileId)&&vid&&tr&&(typeof validateVoiceId!="function"||validateVoiceId(vid)))}function scheduleAutoSave(){const toggle=g("clone-autosave-toggle");!toggle||!toggle.checked||(clearTimeout(_autoSaveTimer),_autoSaveTimer=setTimeout(()=>{var _a3;if(!canAutoSave())return;const sig=(g("voice-id-input").value+"|"+g("transcript-area").value).trim();sig!==_lastAutoSaved&&(_lastAutoSaved=sig,(_a3=g("save-btn"))==null||_a3.click())},1600))}window._cloneScheduleAutoSave=scheduleAutoSave}(),function(){const picker=document.getElementById("clone-src-picker");if(!picker)return;const cards=[...document.querySelectorAll(".clone-src-card")],tabs=[...picker.querySelectorAll(".clone-src-tab")],KEY="clone-src-choice";function show(src){cards.forEach(c=>{c.hidden=c.dataset.src!==src}),tabs.forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem(KEY,src)}catch{}}tabs.forEach(t=>t.addEventListener("click",()=>show(t.dataset.src))),show(localStorage.getItem(KEY)||"mic")}(),$("save-btn").addEventListener("click",async()=>{const id=trimmedFileId||designedFileId||currentFileId;if(!id){toast("No audio ready","error");return}const voiceId=$("voice-id-input").value.trim();if(!voiceId){toast("Enter a Voice ID","error");return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("save-btn").disabled=!0;try{const payload={id,voice_id:voiceId,path:editingVoicePath,transcript:$("transcript-area").value},sendSave=endpoint=>fetch(endpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});let fallbackSave=!1,r=await sendSave(editingVoiceId?"/api/voice-replace":"/api/save");if(editingVoiceId&&(r.status===404||r.status===405)&&(fallbackSave=!0,status("Update endpoint unavailable; saving as a regular voice\u2026"),r=await sendSave("/api/save")),!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();$("save-result").style.display="",toast((editingVoiceId?"Voice updated: ":"Voice saved: ")+d.voice_id,"success"),fallbackSave&&editingVoiceId&&voiceId!==editingVoiceId&&((await fetch("/api/voice/"+encodeURIComponent(editingVoiceId),{method:"DELETE"})).ok||status("Saved renamed voice; old library entry may need manual deletion.")),editingVoiceId=null,editingVoicePath=null}catch(e){toast("Save failed: "+e.message,"error")}finally{$("save-btn").disabled=!1}});let _voices=[],_pendingSelectId=null,_sortField="id",_sortDir=1,_libraryIssueFilter="",_activePlayButton=null,_activePlayVoiceId=null,_activePlayUrl=null,_libraryLoadPromise=null;const BENCHMARK_SAMPLE_STORAGE_KEY="vcf-benchmark-sample-text",_VL_CACHE_KEY="ttsvc_vc";function _vlCacheRead(){try{return JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY)||"null")}catch{return null}}function _vlCacheWrite(voices){try{sessionStorage.setItem(_VL_CACHE_KEY,JSON.stringify(voices))}catch{}}function _vlCacheClear(){try{sessionStorage.removeItem(_VL_CACHE_KEY)}catch{}}window._vlCacheClear=_vlCacheClear;const _libraryFilters={text:"",lang:"",sex:"",type:"",rating:""};let _libraryFilterOptionsSig="";const DEFAULT_BENCHMARK_SAMPLE_TEXT="Hello, how are you today? Please read this sample clearly for a fair voice benchmark.",BENCHMARK_PRESETS={de:"Die Welt ist voller Geschichten, die darauf warten, erz\xE4hlt zu werden \u2014 von mutigen Helden und stillen Tr\xE4umern.",en:"The old lighthouse stood firm against the crashing waves, its beam sweeping silently across the dark and restless sea.",de2:"Victor jagt zw\xF6lf Boxk\xE4mpfer quer \xFCber den gro\xDFen Sylter Deich. Im Winter ist es kalt und die Tage sind kurz.",en2:"She sells seashells by the seashore. Peter Piper picked a peck of pickled peppers on a perfectly pleasant afternoon.",reset:DEFAULT_BENCHMARK_SAMPLE_TEXT};function benchmarkSampleText(){const el=$("benchmark-sample-text");return el&&el.value.trim()||DEFAULT_BENCHMARK_SAMPLE_TEXT}function initBenchmarkSampleControls(){var _a2,_b2;const sample=$("benchmark-sample-text");if(!sample)return;sample.value=localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY)||DEFAULT_BENCHMARK_SAMPLE_TEXT,sample.addEventListener("input",debounce(()=>{localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value.trim()),status("Benchmark sample sentence saved")},500)),(_a2=$("benchmark-reset-sample-btn"))==null||_a2.addEventListener("click",()=>{sample.value=DEFAULT_BENCHMARK_SAMPLE_TEXT,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),status("Benchmark sample sentence reset")});const presetSel=$("benchmark-preset-select");presetSel&&presetSel.addEventListener("change",()=>{const key=presetSel.value;if(!key||!BENCHMARK_PRESETS[key]){presetSel.value="";return}sample.value=BENCHMARK_PRESETS[key],localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),presetSel.value="",status("Benchmark sample sentence loaded")}),(_b2=$("benchmark-use-preview-btn"))==null||_b2.addEventListener("click",()=>{var _a3;const text=(_a3=$("preview-text-area"))==null?void 0:_a3.value.trim();if(!text){toast("Preview text is empty","error");return}sample.value=text,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,text),status("Benchmark sample sentence copied from TTS preview")})}function _displaySource(v){if(v.origin)return v.origin;if(v.note){const m=v.note.match(/^Rehearser\s*·\s*(.+?)\s*·/);if(m)return m[1].trim()}if(v.tag){const tags=v.tag.split(",").map(t=>t.trim().toLowerCase());if(tags.includes("fish-audio")||tags.includes("fishaudio"))return"fish-audio"}return""}function getSortValue(v,field){var _a2,_b2,_c2,_d2,_e2,_f2;switch(field){case"has_picture":return v.has_picture?1:0;case"flag":return(v.flag||"").toLowerCase();case"gender":return(_a2={F:0,M:1,N:2}[v.gender])!=null?_a2:3;case"id":return v.id.toLowerCase();case"file_type":return voiceFileType(v);case"duration":return v.duration||0;case"dbfs":return(_b2=voiceDbfs(v))!=null?_b2:-999;case"benchmark":case"factor":return-((_c2=voiceFactor(v))!=null?_c2:-999);case"elapsed":return(_d2=voiceBenchmarkElapsed(v))!=null?_d2:999;case"bench_audio":return(_e2=voiceBenchmarkAudioSec(v))!=null?_e2:999;case"wpm":return(_f2=voiceWpm(v))!=null?_f2:-1;case"transcript":return(v.transcript||"").toLowerCase();case"note":return(v.note||"").toLowerCase();case"source":return(_displaySource(v)||"").toLowerCase();case"seed":return v.seed!=null?v.seed:9999999;case"tag":return(v.tag||"").toLowerCase();case"rating":return v.rating||0;case"enabled":return v.enabled===!1?0:1;default:return""}}function setSort(field){_sortDir=_sortField===field?_sortDir*-1:1,_sortField=field,syncSortHeaders(),renderVoiceList()}function toggleSortDir(){_sortDir*=-1,syncSortHeaders(),renderVoiceList()}function syncSortHeaders(){document.querySelectorAll(".vl-header [data-sort], .vl-table-header [data-sort]").forEach(el=>{el.classList.remove("sort-asc","sort-desc"),el.dataset.sort===_sortField&&el.classList.add(_sortDir===1?"sort-asc":"sort-desc")});const sel=document.getElementById("voice-sort-field");sel&&sel.value!==_sortField&&(sel.value=_sortField);const dirBtn=document.getElementById("voice-sort-dir");if(dirBtn){const icon=dirBtn.querySelector(".mdi");icon&&(icon.className=_sortDir===1?"mdi mdi-arrow-up":"mdi mdi-arrow-down"),dirBtn.title=_sortDir===1?"Ascending \u2014 click to reverse":"Descending \u2014 click to reverse"}}document.addEventListener("click",e=>{var _a2,_b2,_c2;if(e.target.closest("#voice-sort-dir")&&toggleSortDir(),e.target.closest("#voice-group-tag-btn")){window._voiceGroupByTag=!window._voiceGroupByTag;try{localStorage.setItem("vl-group-by-tag",window._voiceGroupByTag?"1":"0")}catch{}const btn=document.getElementById("voice-group-tag-btn");btn==null||btn.classList.toggle("active",window._voiceGroupByTag);const icon=btn==null?void 0:btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline"),renderVoiceList()}else if(e.target.closest("#voice-table-view-btn")){window._voiceTableView=!window._voiceTableView,window._voiceTableView||(window._voiceTableEditMode=!1);try{localStorage.setItem("vl-table-view",window._voiceTableView?"1":"0")}catch{}const btn=document.getElementById("voice-table-view-btn");btn==null||btn.classList.toggle("active",window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");if(editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode)),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",window._voiceTableView),(_b2=document.querySelector(".voices-workbench"))==null||_b2.classList.toggle("table-edit-mode",!!window._voiceTableEditMode),window._voiceTableView){document.querySelectorAll(".edit-open").forEach(r=>r.classList.remove("edit-open"));const inspector=document.getElementById("voices-inspector");inspector&&(inspector.innerHTML='

Table View Mode
Click a row to exit table view and edit.

')}renderVoiceList()}else if(e.target.closest("#voice-table-edit-btn")){window._voiceTableEditMode=!window._voiceTableEditMode;const editBtn=document.getElementById("voice-table-edit-btn");editBtn==null||editBtn.classList.toggle("active",window._voiceTableEditMode),(_c2=document.querySelector(".voices-workbench"))==null||_c2.classList.toggle("table-edit-mode",window._voiceTableEditMode),renderVoiceList()}});try{window._voiceGroupByTag=localStorage.getItem("vl-group-by-tag")==="1"}catch{}try{window._voiceTableView=localStorage.getItem("vl-table-view")==="1"}catch{}document.addEventListener("click",e=>{const th=e.target.closest(".vl-th-sortable");if(th){const field=th.dataset.sort;if(field){const sel=document.getElementById("voice-sort-field");sel&&(sel.value=field),setSort(field)}}}),document.addEventListener("change",e=>{e.target.id==="voice-sort-field"&&setSort(e.target.value)}),document.addEventListener("change",async e=>{if(e.target.classList.contains("vl-inline-edit")){const row=e.target.closest(".vl-row");if(!row)return;const voiceId=row.dataset.id,field=e.target.dataset.field;let value=e.target.type==="checkbox"?e.target.checked:e.target.value;field==="rating"&&(value=parseInt(value)||0);const payload={};payload[field]=value;try{await saveMeta(voiceId,payload);const v=_voices.find(vv=>vv.id===voiceId);v&&(v[field]=value),typeof toast=="function"&&toast("Saved "+field,"success")}catch{typeof toast=="function"&&toast("Failed to save "+field,"error")}}});const FLAG_LANGUAGE_CANDIDATES={GB:["EN"],US:["EN"],AU:["EN"],NZ:["EN"],IE:["EN"],ZA:["EN"],NG:["EN"],KE:["EN"],GH:["EN"],JM:["EN"],TT:["EN"],CA:["EN","FR"],IN:["EN","HI"],SG:["EN","ZH"],PH:["EN","FIL"],MT:["EN","MT"],DE:["DE"],AT:["DE"],CH:["DE","FR","IT"],FR:["FR"],BE:["FR","NL"],LU:["FR","DE"],ES:["ES"],MX:["ES"],AR:["ES"],CO:["ES"],CL:["ES"],PE:["ES"],VE:["ES"],UY:["ES"],EC:["ES"],BO:["ES"],CR:["ES"],CU:["ES"],DO:["ES"],PT:["PT"],BR:["PT"],IT:["IT"],NL:["NL"],PL:["PL"],SE:["SV"],DK:["DA"],NO:["NO"],FI:["FI"],IS:["IS"],GR:["EL"],CY:["EL","TR"],CZ:["CS"],SK:["SK"],HU:["HU"],RO:["RO"],BG:["BG"],HR:["HR"],SI:["SL"],RS:["SR"],BA:["BS"],ME:["SR"],MK:["MK"],AL:["SQ"],EE:["ET"],LV:["LV"],LT:["LT"],UA:["UK"],RU:["RU"],BY:["RU"],MD:["RO"],TR:["TR"],CN:["ZH"],TW:["ZH"],HK:["ZH"],MO:["ZH"],JP:["JA"],KR:["KO"],VN:["VI"],TH:["TH"],ID:["ID"],MY:["MS"],PK:["UR"],BD:["BN"],LK:["SI"],NP:["NE"],SA:["AR"],EG:["AR"],AE:["AR"],MA:["AR"],QA:["AR"],KW:["AR"],OM:["AR"],JO:["AR"],LB:["AR"],IQ:["AR"],IR:["FA"],IL:["HE"]},FLAG_LANGUAGE=Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc,langs])=>[cc,langs[0]])),LANGUAGE_LABELS={EN:"English",DE:"German",FR:"French",ES:"Spanish",PT:"Portuguese",IT:"Italian",NL:"Dutch",PL:"Polish",SV:"Swedish",DA:"Danish",NO:"Norwegian",FI:"Finnish",IS:"Icelandic",EL:"Greek",MT:"Maltese",CS:"Czech",SK:"Slovak",HU:"Hungarian",RO:"Romanian",BG:"Bulgarian",HR:"Croatian",SL:"Slovenian",SR:"Serbian",BS:"Bosnian",MK:"Macedonian",SQ:"Albanian",ET:"Estonian",LV:"Latvian",LT:"Lithuanian",UK:"Ukrainian",RU:"Russian",ZH:"Chinese",JA:"Japanese",KO:"Korean",VI:"Vietnamese",TH:"Thai",ID:"Indonesian",MS:"Malay",FIL:"Filipino",HI:"Hindi",UR:"Urdu",BN:"Bengali",SI:"Sinhala",NE:"Nepali",AR:"Arabic",FA:"Persian",HE:"Hebrew",TR:"Turkish"},SEX_FILTER_LABELS={F:"\u2640 Female",M:"\u2642 Male",N:"\u26A5 Diverse / neutral"};function voiceLangFromName(v){return(v.lang||String(v.id||"").split("_")[0]||"").toUpperCase()}function libraryVoiceLang(v){const fromName=voiceLangFromName(v),candidates=FLAG_LANGUAGE_CANDIDATES[String(v.flag||"").toUpperCase()];return candidates!=null&&candidates.length?candidates.includes(fromName)?fromName:candidates[0]:fromName}function libraryLanguageLabel(code){return LANGUAGE_LABELS[code]||code}function populateLibraryFilters(){const langSel=$("library-filter-lang"),sexSel=$("library-filter-sex"),typeSel=$("library-filter-type"),tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");if(!langSel||!sexSel||!typeSel)return;const langSet=new Set,sexSet=new Set,typeSet=new Set,tagSet=new Set,groupSet=new Set;(_voices||[]).forEach(v=>{const lang=libraryVoiceLang(v);lang&&langSet.add(lang),v.gender&&sexSet.add(v.gender);const type=voiceFileType(v);type&&typeSet.add(type),String(v.tag||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>tagSet.add(t));const g=(v.group||"").trim();g&&groupSet.add(g)});const langs=[...langSet].sort((a,b)=>libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b))),sexOrder=["F","M","N"],sexes=[...sexSet].sort((a,b)=>(sexOrder.indexOf(a)<0?99:sexOrder.indexOf(a))-(sexOrder.indexOf(b)<0?99:sexOrder.indexOf(b))),types=[...typeSet].sort(),tags=[...tagSet].sort((a,b)=>a.localeCompare(b)),groups=[...groupSet].sort((a,b)=>a.localeCompare(b)),sig=JSON.stringify([langs,sexes,types,tags,groups]);if(sig===_libraryFilterOptionsSig)return;_libraryFilterOptionsSig=sig;const keep={lang:langSel.value,sex:sexSel.value,type:typeSel.value,tag:tagSel==null?void 0:tagSel.value,group:groupSel==null?void 0:groupSel.value};langSel.innerHTML=''+langs.map(x=>``).join(""),sexSel.innerHTML=''+sexes.map(x=>``).join(""),typeSel.innerHTML=''+types.map(x=>``).join(""),tagSel&&(tagSel.innerHTML=''+tags.map(x=>``).join("")),groupSel&&(groupSel.innerHTML=''+groups.map(x=>``).join("")),langSel.value=langs.includes(keep.lang)?keep.lang:"",sexSel.value=sexes.includes(keep.sex)?keep.sex:"",typeSel.value=types.includes(keep.type)?keep.type:"",tagSel&&(tagSel.value=tags.includes(keep.tag)?keep.tag:""),groupSel&&(groupSel.value=groups.includes(keep.group)?keep.group:"")}function readLibraryFilters(){var _a2,_b2,_c2,_d2,_e2;_libraryFilters.text=(((_a2=$("library-filter-text"))==null?void 0:_a2.value)||"").trim().toLowerCase(),_libraryFilters.lang=((_b2=$("library-filter-lang"))==null?void 0:_b2.value)||"",_libraryFilters.sex=((_c2=$("library-filter-sex"))==null?void 0:_c2.value)||"",_libraryFilters.type=((_d2=$("library-filter-type"))==null?void 0:_d2.value)||"",_libraryFilters.rating=((_e2=$("library-filter-rating"))==null?void 0:_e2.value)||""}function libraryFilterMatch(v){const f=_libraryFilters;if(f.lang&&libraryVoiceLang(v)!==f.lang||f.sex&&(v.gender||"")!==f.sex||f.type&&voiceFileType(v)!==f.type)return!1;if(f.rating){const r=Number(v.rating||0),wanted=Number(f.rating);if(wanted===0&&r!==0||wanted===1&&r<1||wanted>1&&rString(x||"").toLowerCase()).join(" ").includes(f.text))}function clearLibraryFilters(){["library-filter-text","library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{const el=$(id);el&&(el.value="")}),readLibraryFilters(),renderVoiceList()}function libraryTtsBackend(){var _a2;return((_a2=$("library-tts-backend-select"))==null?void 0:_a2.value)||"voice_clone"}function needsDuration(v){return v.duration==null||Number.isNaN(Number(v.duration))}function voiceFileType(v){if(v.file_type)return String(v.file_type).replace(/^\./,"").toLowerCase();const match=String(v.path||v.filename||"").match(/\.([A-Za-z0-9]+)(?:$|[?#])/);return match?match[1].toLowerCase():"wav"}function voiceDbfs(v){var _a2;const value=v.loudness&&((_a2=v.loudness.dbfs)!=null?_a2:v.loudness.after_dbfs);return value==null||Number.isNaN(Number(value))?null:Number(value)}function fmtDbfs(v){const db=voiceDbfs(v);return db==null?"-":db.toFixed(1)}function voiceBenchmark(v){return v.benchmark&&typeof v.benchmark=="object"&&Object.keys(v.benchmark).length>0?v.benchmark:null}function voiceBenchmarkElapsed(v){const b=voiceBenchmark(v),value=b&&b.elapsed_sec;return value==null||Number.isNaN(Number(value))?null:Number(value)}function voiceFactor(v){const b=voiceBenchmark(v);return b&&b.ok&&b.speed!=null?Number(b.speed):null}function fmtFactor(v){const f=voiceFactor(v);return f!=null?f.toFixed(2)+"x":"-"}function fmtElapsed(v){const e=voiceBenchmarkElapsed(v),b=voiceBenchmark(v);return!b||!b.ok?b&&!b.ok?"ERR":"-":e!=null?e.toFixed(1)+"s":"-"}function fmtBenchmark(v){const elapsed=fmtElapsed(v),factor=fmtFactor(v);return elapsed==="-"&&factor==="-"?"-":[elapsed,factor].filter(x=>x!=="-").join(" \xB7 ")}function benchmarkClass(v){const b=voiceBenchmark(v);if(!b)return"";if(!b.ok||b.clipped||b.realtime_ok===!1)return"bench-bad";const elapsed=voiceBenchmarkElapsed(v);return elapsed!=null&&elapsed<=4?"bench-ok":"bench-warn"}function voiceBenchmarkAudioSec(v){const b=voiceBenchmark(v);return b&&b.ok&&b.audio_sec!=null?Number(b.audio_sec):null}function fmtBenchmarkAudio(v){const sec=voiceBenchmarkAudioSec(v);return sec!=null?sec.toFixed(1)+"s":"-"}function voiceWpm(v){const b=voiceBenchmark(v);if(!b||!b.ok||!b.audio_sec||!b.text)return null;const words=b.text.trim().split(/\s+/).length;return Math.round(words/(b.audio_sec/60))}function fmtWpm(v){const wpm=voiceWpm(v);return wpm!=null?wpm+" wpm":"-"}function voiceFileUrl(v){const bust=v._audioVersion||v.updated_at||v.benchmarked_at||""||Date.now();return`/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`}function markVoiceAudioChanged(v){v._audioVersion=Date.now()}function benchmarkTitle(v){const b=voiceBenchmark(v);if(!b)return"Not benchmarked yet";const parts=[];return b.ok?(parts.push(`total ${Number(b.elapsed_sec||0).toFixed(2)}s`),b.ttfa_ms!=null&&parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`),b.audio_sec!=null&&parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`),b.rtf!=null&&parts.push(`RTF ${Number(b.rtf).toFixed(2)}`),b.speed!=null&&parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`),b.clipped&&parts.push("output clipped")):(parts.push("benchmark failed"),b.error&&parts.push(b.error)),Array.isArray(b.advice)&&b.advice.length&&parts.push(b.advice.join(" | ")),b.benchmarked_at&&parts.push(`saved ${b.benchmarked_at}`),parts.join(" \xB7 ")}async function clientVoiceLoudness(v){if(!v.path)throw new Error("No audio path");const resp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const audioData=await resp.arrayBuffer(),buffer=await new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(audioData.slice(0));let sum=0,peak=0,count=0;for(let ch=0;ch0?20*Math.log10(rms):null,peakDbfs=peak>0?20*Math.log10(peak):null;return{dbfs:dbfs==null?null:Number(dbfs.toFixed(2)),peak_dbfs:peakDbfs==null?null:Number(peakDbfs.toFixed(2))}}async function clientCalculateVoiceDb(){const voices=_bulkSelected&&_bulkSelected.size>0?(_voices||[]).filter(v=>_bulkSelected.has(v.id)):visibleLibraryVoices(),errors=[];let calculated=0;const stats={startedAt:Date.now(),ok:0,slow:0,errors:0,middleLabel:"Skipped"};setBenchmarkProgress(0,voices.length,"Preparing dB scan...",stats);for(const v of voices){setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats);try{v.loudness=await clientVoiceLoudness(v),await saveMeta(v.id,{loudness:v.loudness}).catch(()=>{}),calculated++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs(v)} dBFS`,status(`Calculated dB: ${calculated} / ${voices.length}`)}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats),await new Promise(resolve=>setTimeout(resolve,0))}return setBenchmarkProgress(voices.length,voices.length,"dB scan complete",stats),{calculated,errors,voices:voices.map(v=>({voice_id:v.id,loudness:v.loudness}))}}async function hydrateVoiceDuration(v,el){if(!(!v.path||!needsDuration(v)||v._durationLoading)){v._durationLoading=!0;try{const audio=new Audio;audio.preload="metadata",audio.src=voiceFileUrl(v),await new Promise((resolve,reject)=>{audio.onloadedmetadata=resolve,audio.onerror=()=>reject(new Error("Could not read duration"))}),Number.isFinite(audio.duration)&&audio.duration>0&&(v.duration=audio.duration,el&&document.body.contains(el)&&(el.textContent=fmtDuration(v.duration),el.title=String(v.duration.toFixed(2)))),audio.removeAttribute("src"),audio.load()}catch(e){el&&document.body.contains(el)&&(el.title=e.message)}finally{v._durationLoading=!1}}}document.querySelectorAll(".vl-header [data-sort]").forEach(el=>el.addEventListener("click",()=>setSort(el.dataset.sort)));function dominantLanguages(limit=3){const counts=new Map;return(_voices||[]).forEach(v=>{const lang=(v.lang||String(v.id||"").split("_")[0]||"?").toUpperCase();counts.set(lang,(counts.get(lang)||0)+1)}),[...counts.entries()].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0])).slice(0,limit).map(([lang,count])=>`${lang} ${count}`).join(" \xB7 ")||"-"}function updateLibraryInsights(state="ready"){const el=$("library-insights");if(!el)return;if(state==="loading"){el.innerHTML=[["\u2026","Loading"],["\u2026","Active"],["\u2026","Languages"],["\u2026","Benchmarks"],["\u2026","Quality"],["\u2026","Actions"]].map(([value,label])=>`
${value}${label}
`).join("");return}if(state==="error"){el.innerHTML='
FailedLibrary load
';return}const total=_voices.length,active=_voices.filter(v=>v.enabled!==!1).length,hidden=total-active,bench=_voices.map(voiceBenchmark).filter(Boolean),slow=bench.filter(b=>b&&b.ok&&b.realtime_ok===!1).length,dbValues=_voices.map(voiceDbfs).filter(v=>v!=null),avgDb=dbValues.length?(dbValues.reduce((a,b)=>a+b,0)/dbValues.length).toFixed(1):"-",missingRef=_voices.filter(v=>!v.transcript).length,restart=_voices.filter(v=>v.needs_tts_restart).length,tiles=[{value:`${_voices.filter(v=>$("show-disabled-cb").checked||v.enabled!==!1).length}/${total}`,label:"Visible"},{value:`${active} on`,label:hidden?`${hidden} hidden`:"Active"},{value:dominantLanguages(),label:"Languages"},{value:bench.length?`${bench.length} done`:"-",label:slow?`${slow} slow`:"Benchmarks",filter:slow?"slow":"",title:slow?describeIssueVoices("slow"):"No slow voices"},{value:avgDb==="-"?"-":`${avgDb} dB`,label:missingRef?`${missingRef} no text`:"Avg loudness",filter:missingRef?"no_text":"",title:missingRef?describeIssueVoices("no_text"):"All visible voices have reference text"},{value:restart||"-",label:restart?"Need restart":"Restart flags",filter:restart?"restart":"",title:restart?describeIssueVoices("restart"):"No voices need restart"}];el.innerHTML=tiles.map(item=>{const filter=item.filter?` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"`:"",activeCls=item.filter&&item.filter===_libraryIssueFilter?" active":"",title=item.title?` title="${escHtml(item.title)}"`:"";return`
${escHtml(item.value)}${escHtml(item.label)}
`}).join(""),el.querySelectorAll("[data-filter]").forEach(tile=>{const activate=()=>setLibraryIssueFilter(tile.dataset.filter||"");tile.addEventListener("click",activate),tile.addEventListener("keydown",e=>{(e.key==="Enter"||e.key===" ")&&(e.preventDefault(),activate())})})}function shouldRenderVoiceLibrary(){const section=$("s-voices");return!section||section.classList.contains("is-active")}async function loadVoiceLibrary(options={}){const forceRefresh=!!(options&&options.refresh);return _libraryLoadPromise?_libraryLoadPromise.then(()=>{shouldRenderVoiceLibrary()&&_voices.length&&renderVoiceList()}):(_libraryLoadPromise=(async()=>{setBusyButton("refresh-voices-btn",!0);const list=$("voice-list"),renderVisibleList=shouldRenderVoiceLibrary(),cached=_voices.length===0?_vlCacheRead():null;cached&&Array.isArray(cached)&&cached.length&&(_voices=cached,window._voices=_voices,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices),renderVisibleList&&renderVoiceList(),updatePreviewVoiceMatchPanel(),status(`Loaded ${_voices.length} voices`));const silent=_voices.length>0;list&&!silent&&renderVisibleList&&(list.innerHTML=loadingMarkup("Loading voice library","Scanning voices, reference text, metadata, ratings, and benchmark results.",8)),!silent&&renderVisibleList&&($("voice-count").textContent="Loading voices\u2026",updateLibraryInsights("loading"),status("Loading voice library\u2026"));try{const r=await fetch("/api/voices"+(forceRefresh?"?refresh=1":""));if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const fresh=await r.json();_vlCacheWrite(fresh),_voices=fresh,window._voices=_voices,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices),shouldRenderVoiceLibrary()&&renderVoiceList(),updatePreviewVoiceMatchPanel(),typeof renderPerfHistory=="function"&&renderPerfHistory(),status(`Loaded ${_voices.length} voices`)}catch(e){if(!silent&&renderVisibleList&&(list&&(list.innerHTML='
Failed to load voices: '+(e.message||String(e))+"
"),$("voice-count").textContent="Load failed",updateLibraryInsights("error")),status("Voice library load failed: "+e.message),!silent)throw e}finally{setBusyButton("refresh-voices-btn",!1),_libraryLoadPromise=null}})(),_libraryLoadPromise)}$("refresh-voices-btn").addEventListener("click",()=>{_vlCacheClear(),loadVoiceLibrary({refresh:!0})}),$("sync-voice-folders-btn").addEventListener("click",async()=>{$("sync-voice-folders-btn").disabled=!0,status("Syncing active_voices and hidden_voices\u2026");try{const r=await fetch("/api/voices/sync-folders",{method:"POST"});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();await loadVoiceLibrary();const conflicts=d.conflicts&&d.conflicts.length?`, ${d.conflicts.length} conflicts`:"";toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`,d.conflicts&&d.conflicts.length?"error":"success"),status("Synced folders. Restart Qwen3-TTS after changing active voices.")}catch(e){toast("Sync failed: "+e.message,"error"),status("Folder sync failed")}finally{$("sync-voice-folders-btn").disabled=!1}});function visibleLibraryVoices(){const showDisabled=$("show-disabled-cb").checked;return _voices.filter(v=>showDisabled||v.enabled!==!1)}function libraryIssueMatch(v,filter=_libraryIssueFilter){const b=voiceBenchmark(v);return filter==="slow"?!!(b&&b.ok&&b.realtime_ok===!1):filter==="no_text"?!String(v.transcript||"").trim():filter==="restart"?!!v.needs_tts_restart:!0}function libraryIssueLabel(filter=_libraryIssueFilter){return{slow:"slow benchmark voices",no_text:"voices without reference text",restart:"voices needing TTS restart"}[filter]||"all voices"}function libraryIssueVoices(filter=_libraryIssueFilter){return visibleLibraryVoices().filter(v=>libraryIssueMatch(v,filter))}function describeIssueVoices(filter=_libraryIssueFilter,limit=12){const voices=libraryIssueVoices(filter).map(v=>v.id);if(!voices.length)return"No matching voices";const extra=voices.length>limit?`, +${voices.length-limit} more`:"";return voices.slice(0,limit).join(", ")+extra}function setLibraryIssueFilter(filter=""){_libraryIssueFilter=_libraryIssueFilter===filter?"":filter,renderVoiceList(),status(_libraryIssueFilter?`${libraryIssueLabel()}: ${describeIssueVoices()}`:"Showing all visible voices")}function libraryTargetDb(){var _a2;const input=$("library-target-db"),raw=Number((_a2=input==null?void 0:input.value)!=null?_a2:-20),value=Number.isFinite(raw)?Math.min(-1,Math.max(-60,raw)):-20;return input&&(input.value=String(value)),value}$("calculate-db-btn").addEventListener("click",async()=>{$("calculate-db-btn").disabled=!0;const _calcTarget=_bulkSelected&&_bulkSelected.size>0?`${_bulkSelected.size} selected`:"visible";status(`Calculating voice loudness (${_calcTarget})\u2026`);try{const d=await clientCalculateVoiceDb();renderVoiceList();const extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Calculated dB for ${d.calculated} voices${extra}`,d.errors&&d.errors.length?"error":"success"),status("Calculated voice loudness. Use Normalize volume for visible WAV voices.")}catch(e){toast("Calculate dB failed: "+e.message,"error"),status("dB calculation failed")}finally{$("calculate-db-btn").disabled=!1}}),$("normalize-volume-btn").addEventListener("click",async()=>{var _a2;const target=libraryTargetDb(),visible=visibleLibraryVoices(),voices=visible.filter(v=>voiceFileType(v)==="wav"),skipped=visible.length-voices.length;if(!voices.length){toast("No visible WAV voices to normalize","error");return}if(!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped?` ${skipped} non-WAV voices will be skipped.`:""}`))return;$("normalize-volume-btn").disabled=!0,$("calculate-db-btn").disabled=!0;const stats={startedAt:Date.now(),ok:0,slow:skipped,errors:0,middleLabel:"Skipped"},errors=[];let normalized=0;setBenchmarkProgress(0,voices.length,`Normalizing to ${target} dBFS...`,stats),status(`Normalizing ${voices.length} voices to ${target} dBFS...`);try{for(const v of voices){setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats);try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a2=d.duration)!=null?_a2:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),normalized++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs(v)} dBFS`}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats),status(`Normalized ${normalized} / ${voices.length}`),await new Promise(resolve=>setTimeout(resolve,0))}setBenchmarkProgress(voices.length,voices.length,"Volume normalization complete",stats),renderVoiceList(),updateLibraryInsights();const extra=`${skipped?`, ${skipped} skipped`:""}${errors.length?`, ${errors.length} errors`:""}`;toast(`Normalized ${normalized} voices${extra}`,errors.length?"error":"success"),status("Volume normalized. Restart TTS before rebenchmarking these voices.")}catch(e){toast("Normalize volume failed: "+e.message,"error"),status("Normalize volume failed")}finally{$("normalize-volume-btn").disabled=!1,$("calculate-db-btn").disabled=!1}});function fmtClock(ms){if(!Number.isFinite(ms)||ms<0)return"-";const total=Math.round(ms/1e3),m=Math.floor(total/60),s=total%60;return`${m}:${String(s).padStart(2,"0")}`}function setBenchmarkProgress(done,total,label="",stats={}){const panel=$("benchmark-progress"),track=panel.querySelector(".benchmark-progress-track"),pct=total?Math.round(done/total*100):0;panel.hidden=!1,$("benchmark-progress-label").textContent=label||(done>=total?"Benchmark complete":"Benchmarking voices..."),$("benchmark-progress-count").textContent=`${done} / ${total}`,$("benchmark-progress-bar").style.width=pct+"%",track.setAttribute("aria-valuenow",String(pct));const live=$("benchmark-live-stats");if(live){const elapsed=stats.startedAt?Date.now()-stats.startedAt:0,avg=done>0?elapsed/done:0,eta=done>0&&total>done?avg*(total-done):0;live.innerHTML=[`Elapsed ${fmtClock(elapsed)}`,`Avg ${done?(avg/1e3).toFixed(1)+"s":"-"}`,`ETA ${done&&total>done?fmtClock(eta):"-"}`,`OK ${stats.ok||0}`,`${stats.middleLabel||"Slow"} ${stats.slow||0}`,`${stats.errorLabel||"Errors"} ${stats.errors||0}`].map(x=>`${escHtml(x)}`).join("")}const last=$("benchmark-live-last");last&&stats.last&&(last.textContent=stats.last)}function hideBenchmarkProgress(){$("benchmark-progress").hidden=!0,$("benchmark-progress-bar").style.width="0%",$("benchmark-live-last")&&($("benchmark-live-last").textContent="")}async function clearTtsRestartFlags(){const r=await fetch("/api/tts/restart-flags/clear",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();return _voices.forEach(voice=>{voice.needs_tts_restart=!1}),document.querySelectorAll(".vl-row.edit-open").forEach(row=>row.classList.remove("opt-restart-needed")),updateLibraryInsights(),d}async function runVoiceBenchmark(voiceId="",opts={}){var _a2;const text=(_a2=opts.text)!=null?_a2:benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;const payload={active_only:!0,text};voiceId&&(payload.voice_id=voiceId);const r=await fetch("/api/voices/benchmark",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return r.json()}async function runVoiceBenchmarkBatch(){const voices=benchmarkTargetVoices(),text=benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;if(!voices.length)return toast("No voices to benchmark","error"),null;const total=voices.length,aggregate={benchmarked:0,errors:[],voices:[],text,active_only:!0},stats={startedAt:Date.now(),ok:0,slow:0,errors:0,last:""};voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&(row.classList.remove("benchmarking-active","benchmarking-done"),row.classList.add("benchmarking-pending"))}),setBenchmarkProgress(0,total,"Starting benchmark...",stats);for(let i=0;ix.voice_id===voice.id),b=hit&&hit.benchmark;if(b&&b.ok){if(stats.ok++,b.realtime_ok===!1&&stats.slow++,stats.last=`${voice.id}: ${Number(b.elapsed_sec||0).toFixed(1)}s${b.speed!=null?` \xB7 ${Number(b.speed).toFixed(2)}x`:""}${b.realtime_ok===!1?" \xB7 slow":""}`,row){const bc=benchmarkClass(voice),btitle=benchmarkTitle(voice),durCell=row.querySelector(".vl-tbl-dur"),factorCell=row.querySelector(".vl-tbl-factor"),timeCell=row.querySelector(".vl-tbl-time"),wpmCell=row.querySelector(".vl-tbl-wpm");if(durCell&&(durCell.textContent=fmtBenchmarkAudio(voice),durCell.title=`${fmtBenchmarkAudio(voice)} \u2014 length of synthesised benchmark audio`),factorCell&&(factorCell.textContent=fmtFactor(voice),factorCell.className=`vl-tbl-factor ${bc}`,factorCell.title=btitle),timeCell&&(timeCell.textContent=fmtElapsed(voice),timeCell.className=`vl-tbl-time ${bc}`,timeCell.title=btitle),wpmCell){const wpm=voiceWpm(voice);wpmCell.textContent=fmtWpm(voice),wpmCell.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}}else stats.errors++,stats.last=`${voice.id}: failed${b&&b.error?" \xB7 "+b.error:""}`}}catch(e){aggregate.errors.push({voice_id:voice.id,detail:e.message}),stats.errors++,stats.last=`${voice.id}: failed \xB7 ${e.message}`}row&&(row.classList.remove("benchmarking-active"),row.classList.add("benchmarking-done")),setBenchmarkProgress(i+1,total,`Finished ${voice.id}`,stats)}return voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&row.classList.remove("benchmarking-pending")}),setBenchmarkProgress(total,total,"Benchmark complete",stats),aggregate}function mergeBenchmarkResults(d){const byId=new Map((d.voices||[]).map(x=>[x.voice_id,x]));_voices.forEach(v=>{const hit=byId.get(v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark)})}window.loadVoiceLibrary=loadVoiceLibrary,window.mergeBenchmarkResults=mergeBenchmarkResults;function activeBenchmarkVoices(){return(_voices||[]).filter(v=>v.enabled!==!1)}function benchmarkTargetVoices(){return typeof _bulkSelected!="undefined"&&_bulkSelected.size>0?(_voices||[]).filter(v=>_bulkSelected.has(v.id)):activeBenchmarkVoices()}function showBenchmarkConfirm(){const voices=benchmarkTargetVoices(),onlySelected=typeof _bulkSelected!="undefined"&&_bulkSelected.size>0;if(!benchmarkSampleText()){toast("Enter a benchmark sample sentence","error");return}if(!voices.length){toast("No voices to benchmark","error");return}const staleCount=voices.filter(v=>v.needs_tts_restart).length;$("benchmark-confirm-title").textContent=`Benchmark ${voices.length} ${onlySelected?"selected":"active"} voice${voices.length===1?"":"s"}?`,$("benchmark-confirm-text").textContent="This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice."+(staleCount?` ${staleCount} edited voice${staleCount===1?"":"s"} should be restarted first, otherwise cached old voices may be benchmarked.`:""),$("benchmark-confirm").hidden=!1,$("benchmark-confirm-start").focus()}function hideBenchmarkConfirm(){const panel=$("benchmark-confirm");panel&&(panel.hidden=!0)}$("benchmark-voices-btn").addEventListener("click",showBenchmarkConfirm),(_z=$("benchmark-confirm-cancel"))==null||_z.addEventListener("click",hideBenchmarkConfirm),(_A=$("benchmark-confirm-start"))==null||_A.addEventListener("click",async()=>{hideBenchmarkConfirm(),$("benchmark-voices-btn").disabled=!0,$("benchmark-confirm-start").disabled=!0,status("Benchmarking active voices...");try{const d=await runVoiceBenchmarkBatch();if(!d)return;mergeBenchmarkResults(d),await loadVoiceLibrary();const slow=(d.voices||[]).filter(x=>x.benchmark&&x.benchmark.realtime_ok===!1).length,extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Benchmarked ${d.benchmarked} voices${slow?`, ${slow} slow`:""}${extra}`,d.errors&&d.errors.length?"error":"success"),status("Benchmark saved with TTFA, total time, RTF, and speed.")}catch(e){toast("Benchmark failed: "+e.message,"error"),status("Benchmark failed")}finally{$("benchmark-voices-btn").disabled=!1,$("benchmark-confirm-start").disabled=!1}}),$("copy-active-voices-btn").addEventListener("click",async()=>{const useSelected=_bulkSelected&&_bulkSelected.size>0,ids=useSelected?[..._bulkSelected]:activeVoiceIds(),label=useSelected?`${ids.length} selected`:`${ids.length} active`;if(!ids.length){toast("No voices to copy","error");return}await copyText(ids.join(", ")),toast("Copied "+label+" voices","success"),status("Copied "+label+" voices to clipboard")}),(_B=$("precompute-embeddings-btn"))==null||_B.addEventListener("click",async()=>{const backend=libraryTtsBackend(),_useSelected=_bulkSelected&&_bulkSelected.size>0,ids=_useSelected?[..._bulkSelected]:activeVoiceIds(),_scopeLabel=_useSelected?`${ids.length} selected`:`${ids.length} active`;if(!ids.length){toast("No voices to precompute","error");return}if(!confirm(`Precompute speaker embeddings for ${_scopeLabel} voice(s) via \u201C${backend}\u201D? This warms each voice so the engine caches its .pt and first playback is instant.`))return;const btn=$("precompute-embeddings-btn");btn&&(btn.disabled=!0);const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="precompute-overlay",ov.innerHTML=`
Precomputing embeddings
0 / ${ids.length}
-
`,document.body.appendChild(ov);let cancel=!1;ov.querySelector("#pc-cancel").addEventListener("click",()=>{cancel=!0});const fill=ov.querySelector("#pc-fill"),msg=ov.querySelector("#pc-msg");let done=0,ok=0,failed=0;const queue=ids.slice(),worker=async()=>{for(;queue.length&&!cancel;){const id=queue.shift();msg&&(msg.textContent=`${done} / ${ids.length} \xB7 ${id}`);try{await fetchTtsPreviewBlob(id,"Hallo.","wav","",backend),ok++}catch{failed++}done++,fill&&(fill.style.width=done/ids.length*100+"%")}};try{await Promise.all(Array.from({length:Math.min(2,ids.length)},worker))}finally{ov.remove(),btn&&(btn.disabled=!1)}toast(cancel?`Cancelled \u2014 ${ok} warmed`:`Precomputed ${ok} embedding(s)${failed?`, ${failed} skipped/failed`:""}`,!ok&&failed?"error":"success")});const LIB_ADD_SAMPLE_TEXTS={EN:"The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.",DE:"Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.",IT:"La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.",ES:"La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.",FR:"La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.",PT:"A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.",NL:"Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.",PL:"Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie."},LIB_ADD_SAMPLE_STORAGE_KEY="vcf-lib-add-sample-texts";function libAddSampleOverrides(){try{return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY)||"{}")||{}}catch{return{}}}function getLibAddSampleText(code){return libAddSampleOverrides()[code]||LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN}function saveLibAddSampleText(){const code=$("lib-add-sample-lang").value,text=$("lib-add-sample-text").value.trim(),overrides=libAddSampleOverrides();text&&text!==LIB_ADD_SAMPLE_TEXTS[code]?overrides[code]=text:delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),setLibAddStatus("Sample sentence saved")}function resetLibAddSampleText(){const code=$("lib-add-sample-lang").value,overrides=libAddSampleOverrides();delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),$("lib-add-sample-text").value=LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN,setLibAddStatus("Sample sentence reset")}function updateLibAddSampleLanguage(lang){const code=LIB_ADD_SAMPLE_TEXTS[lang]?lang:"EN";$("lib-add-sample-lang").value=code,$("lib-add-lang").value=code,$("lib-add-sample-text").value=getLibAddSampleText(code);const voiceId=$("lib-add-voice-id").value.trim();voiceId&&/^[A-Z]{2}_/.test(voiceId)&&($("lib-add-voice-id").value=voiceId.replace(/^[A-Z]{2}_/,code+"_"))}function renderLibAddMeter(level=0,db=-1/0,clipped=!1){const meter=$("lib-add-mic-meter");if(!meter.children.length)for(let i=0;i<18;i++){const bar=document.createElement("div");bar.className="bar",meter.appendChild(bar)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))}),$("lib-add-db-readout").textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB"}function syncLibAddMicGain(){const gain=parseFloat($("lib-add-mic-gain").value)||0;$("lib-add-mic-gain-value").textContent=gain.toFixed(2)+"x",libAddState.gainNode&&(libAddState.gainNode.gain.value=gain)}function startLibAddMeter(){if(!libAddState.analyser)return;libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf);const data=new Float32Array(libAddState.analyser.fftSize),tick=()=>{libAddState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const sample of data)sum+=sample*sample,peak=Math.max(peak,Math.abs(sample));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;renderLibAddMeter(level,db,peak>.98),libAddState.meterRaf=requestAnimationFrame(tick)};tick()}async function ensureLibAddMicMonitor(){if(libAddState.recordStream)return;const AudioCtx=window.AudioContext||window.webkitAudioContext;if(libAddState.stream=await requestMicrophoneStream({raw:!0}),AudioCtx){libAddState.audioCtx=new AudioCtx,libAddState.sourceNode=libAddState.audioCtx.createMediaStreamSource(libAddState.stream),libAddState.gainNode=libAddState.audioCtx.createGain(),libAddState.analyser=libAddState.audioCtx.createAnalyser(),libAddState.analyser.fftSize=1024;const dest=libAddState.audioCtx.createMediaStreamDestination();syncLibAddMicGain(),libAddState.sourceNode.connect(libAddState.gainNode),libAddState.gainNode.connect(libAddState.analyser),libAddState.gainNode.connect(dest),libAddState.recordStream=dest.stream,startLibAddMeter()}else libAddState.recordStream=libAddState.stream;libAddState.monitoring=!0,$("lib-add-monitor-btn").disabled=!0,$("lib-add-monitor-stop").disabled=!1}function stopLibAddMic(){libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf),libAddState.meterRaf=null,[libAddState.sourceNode,libAddState.gainNode,libAddState.analyser].forEach(node=>{try{node&&node.disconnect()}catch{}}),libAddState.stream&&libAddState.stream.getTracks().forEach(t=>t.stop()),libAddState.recordStream&&libAddState.recordStream.getTracks().forEach(t=>t.stop()),libAddState.audioCtx&&libAddState.audioCtx.close().catch(()=>{}),libAddState.stream=null,libAddState.recordStream=null,libAddState.sourceNode=null,libAddState.gainNode=null,libAddState.analyser=null,libAddState.audioCtx=null,libAddState.monitoring=!1,$("lib-add-monitor-btn").disabled=!1,$("lib-add-monitor-stop").disabled=!0,renderLibAddMeter(0,-1/0,!1)}let libAddState={id:null,duration:0,audio:null,buffer:null,recorder:null,chunks:[],pendingSource:null,stream:null,recordStream:null,timer:null,secs:0,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,monitoring:!1};window.libAddState=libAddState,$("add-new-voice-btn").addEventListener("click",()=>{$("lib-add-panel").classList.toggle("open")}),$("lib-add-sample-lang").addEventListener("change",()=>updateLibAddSampleLanguage($("lib-add-sample-lang").value)),$("lib-add-lang").addEventListener("change",()=>updateLibAddSampleLanguage($("lib-add-lang").value)),$("lib-add-sample-text").addEventListener("input",debounce(saveLibAddSampleText,500)),$("lib-add-use-sample").addEventListener("click",()=>{$("lib-add-transcript").value=$("lib-add-sample-text").value.trim(),setLibAddStatus("Sample sentence copied to transcript")}),$("lib-add-reset-sample").addEventListener("click",resetLibAddSampleText),$("lib-add-mic-help-btn").addEventListener("click",()=>{$("lib-add-mic-help").classList.toggle("open")}),$("lib-add-monitor-btn").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),setLibAddStatus("Mic level monitor active")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message)}}),$("lib-add-monitor-stop").addEventListener("click",()=>{stopLibAddMic(),setLibAddStatus("Mic level monitor stopped")}),$("lib-add-mic-gain").addEventListener("input",syncLibAddMicGain),renderLibAddMeter(),syncLibAddMicGain(),updateLibAddSampleLanguage("EN");function setLibAddStatus(msg){$("lib-add-status").textContent=msg,status(msg)}function suggestLibVoiceId(filename){if($("lib-add-voice-id").value.trim())return;const base=String(filename||"NewVoice").replace(/\.[^.]+$/,"").replace(/[^A-Za-z0-9_-]+/g,"_").replace(/^_+|_+$/g,"").slice(0,60)||"NewVoice";$("lib-add-voice-id").value=`${$("lib-add-lang").value||"EN"}_${$("lib-add-gender").value||"N"}_${base}`}function loadLibAddAudio(id,duration,label="Audio"){libAddState.id=id,libAddState.duration=Number(duration)||0,libAddState.buffer=null,$("lib-add-start").value="0.00",$("lib-add-end").value=libAddState.duration?Math.min(libAddState.duration,20).toFixed(2):"0.00",$("lib-add-audio").src="/api/audio/"+id,$("lib-add-audio").style.display="",$("lib-add-wave").style.display="",attachLibAddWaveSelection(),decodeTempAudio(id).then(buffer=>{libAddState.id===id&&(libAddState.buffer=buffer,drawLibAddWave())}).catch(()=>{}),setLibAddStatus(`${label} loaded${libAddState.duration?" ("+libAddState.duration.toFixed(1)+" s)":""}`)}async function decodeTempAudio(id){const resp=await fetch("/api/audio/"+encodeURIComponent(id));if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const data=await resp.arrayBuffer();return new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(data.slice(0))}function clampLibAddTime(value){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;return Math.max(0,Math.min(duration,Number(value)||0))}function setLibAddCropRange(start,end){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;let a=clampLibAddTime(start),b=clampLibAddTime(end);Math.abs(b-a)<.05&&(b=Math.min(duration,a+Math.min(1,duration||1))),b=3&&dur<=20?"ok":dur?"warn":"")}function drawLibAddWave(){libAddState.buffer&&(drawOptimizerWave($("lib-add-wave"),libAddState.buffer,parseFloat($("lib-add-start").value)||0,parseFloat($("lib-add-end").value)||libAddState.duration||libAddState.buffer.duration),updateLibAddCropHint())}function libAddWaveSelectionPixels(e){var _a2;const rect=$("lib-add-wave").getBoundingClientRect(),duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0,start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||duration),sx=duration&&rect.width?start/duration*rect.width:0,ex=duration&&rect.width?end/duration*rect.width:rect.width;return{x:Math.max(0,Math.min(rect.width,e.clientX-rect.left)),sx,ex,start,end,duration}}function libAddWaveDragMode(e){const{x,sx,ex}=libAddWaveSelectionPixels(e),hit=16;return Math.abs(x-sx)<=hit?"start":Math.abs(x-ex)<=hit?"end":"new"}function attachLibAddWaveSelection(){const canvas=$("lib-add-wave");if(!canvas||canvas.dataset.cropReady)return;canvas.dataset.cropReady="1";let drag=null;canvas.addEventListener("pointerdown",e=>{var _a2;if(!libAddState.buffer)return;e.preventDefault();const mode=libAddWaveDragMode(e),t=libAddWaveTimeFromEvent(e),currentStart=parseFloat($("lib-add-start").value)||0,currentEnd=parseFloat($("lib-add-end").value)||libAddState.duration||0;drag={mode,anchor:t,start:currentStart,end:currentEnd},(_a2=canvas.setPointerCapture)==null||_a2.call(canvas,e.pointerId),mode==="start"?setLibAddCropRange(t,currentEnd):setLibAddCropRange(mode==="end"?currentStart:t,t),setLibAddStatus(mode==="start"?"Dragging crop start handle":mode==="end"?"Dragging crop end handle":"Drag to choose a new crop range")}),canvas.addEventListener("pointermove",e=>{if(!libAddState.buffer)return;if(!drag){const mode=libAddWaveDragMode(e);canvas.style.cursor=mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t)});const finish=e=>{if(!drag)return;e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t),drag=null;const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||0;setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0,end-start).toFixed(1)}s) selected`)};canvas.addEventListener("pointerup",finish),canvas.addEventListener("pointerleave",()=>{drag||(canvas.style.cursor="crosshair")}),canvas.addEventListener("pointercancel",()=>{drag=null,canvas.style.cursor="crosshair"})}async function uploadLibAddFile(file){if(!file)return;const fd=new FormData;fd.append("file",file),setLibAddStatus("Uploading audio\u2026");try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();suggestLibVoiceId(file.name),loadLibAddAudio(d.id,d.duration,file.name||"Audio"),toast("Audio loaded","success")}catch(e){toast("Load failed: "+e.message,"error"),setLibAddStatus("Load failed")}}const libAddDrop=$("lib-add-drop");libAddDrop.addEventListener("click",()=>$("lib-add-file").click()),libAddDrop.addEventListener("dragover",e=>{e.preventDefault(),libAddDrop.classList.add("drag-over")}),libAddDrop.addEventListener("dragleave",()=>libAddDrop.classList.remove("drag-over")),libAddDrop.addEventListener("drop",e=>{e.preventDefault(),libAddDrop.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadLibAddFile(e.dataTransfer.files[0])}),$("lib-add-file").addEventListener("change",async()=>{$("lib-add-file").files.length&&await uploadLibAddFile($("lib-add-file").files[0]),$("lib-add-file").value=""}),$("lib-add-url-btn").addEventListener("click",()=>{const url=$("lib-add-url").value.trim();if(!url){toast("Enter a YouTube or audio URL","error");return}$("lib-add-url-btn").disabled=!0,setLibAddStatus("Starting download\u2026");const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),setLibAddStatus(d.error),$("lib-add-url-btn").disabled=!1,es.close()):d.done?(es.close(),$("lib-add-url-btn").disabled=!1,suggestLibVoiceId(url.split("/").pop()||"DownloadedVoice"),loadLibAddAudio(d.id,d.duration,"Downloaded audio"),toast("URL audio loaded","success")):setLibAddStatus(d.msg||"Downloading\u2026")},es.onerror=()=>{es.close(),$("lib-add-url-btn").disabled=!1,setLibAddStatus("Download connection closed")}}),$("lib-add-rec-start").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),libAddState.chunks=[],libAddState.secs=0,$("lib-add-rec-time").textContent="0:00",$("lib-add-rec-start").disabled=!0,$("lib-add-rec-stop").disabled=!1,$("lib-add-monitor-stop").disabled=!0,libAddState.timer=setInterval(()=>{libAddState.secs++,$("lib-add-rec-time").textContent=Math.floor(libAddState.secs/60)+":"+String(libAddState.secs%60).padStart(2,"0")},1e3),libAddState.recorder=new MediaRecorder(libAddState.recordStream),libAddState.recorder.ondataavailable=e=>{e.data.size&&libAddState.chunks.push(e.data)},libAddState.recorder.onstop=async()=>{clearInterval(libAddState.timer),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0;const blob=new Blob(libAddState.chunks,{type:libAddState.recorder.mimeType||"audio/webm"}),ext=(libAddState.recorder.mimeType||"").includes("ogg")?".ogg":".webm";stopLibAddMic(),suggestLibVoiceId("recording"),await uploadLibAddFile(new File([blob],"recording"+ext,{type:blob.type}))},libAddState.recorder.start(100),setLibAddStatus("Recording\u2026")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0}}),$("lib-add-rec-stop").addEventListener("click",()=>{libAddState.recorder&&libAddState.recorder.state!=="inactive"&&libAddState.recorder.stop()}),$("lib-add-auto-trim").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}$("lib-add-auto-trim").disabled=!0;try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)d=await clientAutoTrimBounds(libAddState.id);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}$("lib-add-start").value=Number(d.start).toFixed(2),$("lib-add-end").value=Number(d.end).toFixed(2),drawLibAddWave(),setLibAddStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),setLibAddStatus("Auto trim failed")}finally{$("lib-add-auto-trim").disabled=!1}});async function transcribeLibAddCurrent(successMessage="Text recognised",audioId=libAddState.id){if(!audioId)throw new Error("Load audio first");setLibAddStatus("Recognising text...");const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:audioId})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const text=(await r.json()).text||"";return $("lib-add-transcript").value=text,setLibAddStatus(successMessage),text}function openSavedLibraryVoice(voiceId){const openRow=()=>{var _a2;const row=Array.from(document.querySelectorAll(".vl-row")).find(r=>r.dataset.id===voiceId);return row?(row.scrollIntoView({behavior:"smooth",block:"center"}),row.classList.contains("edit-open")||(_a2=row.querySelector(".edit-audio-btn"))==null||_a2.click(),!0):!1};openRow()||setTimeout(openRow,150)}async function applyLibAddCrop(){if(!libAddState.id){toast("Load audio first","error");return}const start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||libAddState.duration),duration=end-start;if(end<=start+.1){toast("Crop range is too short","error"),setLibAddStatus("Crop range is too short");return}(duration<3||duration>20)&&toast("Best clone references are 3-20 seconds; cropping anyway.","error"),["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!0)}),setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`);try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start,end})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();loadLibAddAudio(d.id,d.duration,"Cropped audio"),toast("Crop applied","success");try{await transcribeLibAddCurrent("Cropped audio loaded and text recognised",d.id)}catch(e){toast("Crop applied, but recognition failed: "+e.message,"error"),setLibAddStatus("Cropped audio loaded; recognition failed")}}catch(e){toast("Crop failed: "+e.message,"error"),setLibAddStatus("Crop failed")}finally{["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!1)})}}$("lib-add-save-crop").addEventListener("click",applyLibAddCrop),$("lib-add-save-crop-bottom").addEventListener("click",applyLibAddCrop),["lib-add-start","lib-add-end"].forEach(id=>$(id).addEventListener("input",drawLibAddWave)),$("lib-add-play").addEventListener("click",()=>{if(!libAddState.id)return;libAddState.audio&&libAddState.audio.pause(),libAddState.audio=new Audio("/api/audio/"+libAddState.id);const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||libAddState.duration;libAddState.audio.currentTime=start,libAddState.audio.ontimeupdate=()=>{libAddState.audio.currentTime>=end&&libAddState.audio.pause()},libAddState.audio.play()}),$("lib-add-recognize").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}try{await transcribeLibAddCurrent("Text recognised")}catch(e){toast("Recognition failed: "+e.message,"error"),setLibAddStatus("Recognition failed")}}),$("lib-add-save").addEventListener("click",async()=>{var _a2,_b2;if(!libAddState.id){toast("Load audio first","error");return}const voiceId=$("lib-add-voice-id").value.trim()||`${$("lib-add-lang").value}_${$("lib-add-gender").value}_NewVoice`;if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}setLibAddStatus("Saving voice..."),$("lib-add-save").disabled=!0;try{const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start:parseFloat($("lib-add-start").value)||0,end:parseFloat($("lib-add-end").value)||libAddState.duration})});if(!pr.ok){const e=await pr.json().catch(()=>({}));throw new Error(e.detail||pr.statusText)}const p=await pr.json();let transcript=$("lib-add-transcript").value.trim();if(!transcript&&(transcript=await transcribeLibAddCurrent("Final clip recognised; saving voice...",p.id),!transcript.trim()))throw new Error("Recognition returned no transcript; add text or try recognising again.");const sr=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:voiceId,transcript})});if(!sr.ok){const e=await sr.json().catch(()=>({}));throw new Error(e.detail||sr.statusText)}if((_a2=libAddState.pendingSource)!=null&&_a2.imageUrl)try{await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,image_url:libAddState.pendingSource.imageUrl})})}catch{}libAddState.pendingSource=null,setLibAddSourcePreview({}),(_b2=$("lib-add-panel"))==null||_b2.classList.remove("open"),toast("Voice saved: "+voiceId,"success"),setLibAddStatus("Voice saved"),await loadVoiceLibrary(),openSavedLibraryVoice(voiceId)}catch(e){toast("Save failed: "+e.message,"error"),setLibAddStatus("Save failed")}finally{$("lib-add-save").disabled=!1}}),$("show-disabled-cb").addEventListener("change",()=>{$("disabled-info").style.display=$("show-disabled-cb").checked?"":"none",renderVoiceList()}),["library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{var _a2;(_a2=$(id))==null||_a2.addEventListener("change",()=>{readLibraryFilters(),renderVoiceList()})}),(_C=$("library-filter-text"))==null||_C.addEventListener("input",debounce(()=>{readLibraryFilters(),renderVoiceList()},180)),(_D=$("library-filter-tag"))==null||_D.addEventListener("change",function(){window._voiceTagFilter=this.value||null,renderVoiceList()}),(_E=$("library-filter-group"))==null||_E.addEventListener("change",function(){window._voiceGroupFilter=this.value||null,renderVoiceList()}),(_F=$("library-clear-filters"))==null||_F.addEventListener("click",()=>{window._voiceTagFilter=null,window._voiceGroupFilter=null;const tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");tagSel&&(tagSel.value=""),groupSel&&(groupSel.value=""),clearLibraryFilters()}),(_G=$("library-tts-backend-select"))==null||_G.addEventListener("change",()=>{var _a2;status("Library TTS engine: "+(((_a2=backendById(libraryTtsBackend()))==null?void 0:_a2.label)||libraryTtsBackend()))});function renderVoiceList(){var _a2,_b2,_c2,_d2,_e2,_f2;const showDisabled=$("show-disabled-cb").checked,list=$("voice-list");list.innerHTML="",renderVoiceGroupsBar(),populateLibraryFilters(),readLibraryFilters();const _gtBtn=$("voice-group-tag-btn");if(_gtBtn){_gtBtn.classList.toggle("active",!!window._voiceGroupByTag);const ic=_gtBtn.querySelector(".mdi");ic&&(ic.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline")}const _tvBtn=$("voice-table-view-btn");if(_tvBtn){_tvBtn.classList.toggle("active",!!window._voiceTableView),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",!!window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode))}const cat=window._voiceSidebarCat||"all",enabledOk=v=>showDisabled||v.enabled!==!1;let filtered=_voices.filter(v=>cat==="cloned"?enabledOk(v)&&v.has_ref&&v.origin!=="designed":cat==="designed"?enabledOk(v)&&(v.origin==="designed"||!v.has_ref):cat==="favorites"?enabledOk(v)&&(v.rating||0)>=4:cat==="hidden"?v.enabled===!1:enabledOk(v));window._voiceGroupFilter&&(filtered=filtered.filter(v=>(v.group||"").trim()===window._voiceGroupFilter)),window._voiceTagFilter&&(filtered=filtered.filter(v=>String(v.tag||"").split(",").map(t=>t.trim()).includes(window._voiceTagFilter)));const visibleCount=filtered.length;filtered=filtered.filter(libraryFilterMatch);const filterCount=filtered.length;if(_libraryIssueFilter&&(filtered=filtered.filter(v=>libraryIssueMatch(v))),$("voice-count").textContent=filtered.length+" / "+_voices.length+" voices",filtered=filtered.slice().sort((a,b)=>{const av=getSortValue(a,_sortField),bv=getSortValue(b,_sortField);return avbv?_sortDir:0}),updateLibraryInsights(),_libraryIssueFilter){const note=document.createElement("div");note.className="library-filter-note",note.innerHTML=`${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}`,note.querySelector("button").addEventListener("click",()=>setLibraryIssueFilter("")),list.appendChild(note)}if(!filtered.length){if(_voices.length===0){const emptyEl=document.createElement("div");emptyEl.className="voices-empty-state",emptyEl.innerHTML=` +
`,document.body.appendChild(ov);let cancel=!1;ov.querySelector("#pc-cancel").addEventListener("click",()=>{cancel=!0});const fill=ov.querySelector("#pc-fill"),msg=ov.querySelector("#pc-msg");let done=0,ok=0,failed=0;const queue=ids.slice(),worker=async()=>{for(;queue.length&&!cancel;){const id=queue.shift();msg&&(msg.textContent=`${done} / ${ids.length} \xB7 ${id}`);try{await fetchTtsPreviewBlob(id,"Hallo.","wav","",backend),ok++}catch{failed++}done++,fill&&(fill.style.width=done/ids.length*100+"%")}};try{await Promise.all(Array.from({length:Math.min(2,ids.length)},worker))}finally{ov.remove(),btn&&(btn.disabled=!1)}toast(cancel?`Cancelled \u2014 ${ok} warmed`:`Precomputed ${ok} embedding(s)${failed?`, ${failed} skipped/failed`:""}`,!ok&&failed?"error":"success")});const LIB_ADD_SAMPLE_TEXTS={EN:"The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.",DE:"Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.",IT:"La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.",ES:"La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.",FR:"La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.",PT:"A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.",NL:"Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.",PL:"Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie."},LIB_ADD_SAMPLE_STORAGE_KEY="vcf-lib-add-sample-texts";function libAddSampleOverrides(){try{return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY)||"{}")||{}}catch{return{}}}function getLibAddSampleText(code){return libAddSampleOverrides()[code]||LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN}function saveLibAddSampleText(){const code=$("lib-add-sample-lang").value,text=$("lib-add-sample-text").value.trim(),overrides=libAddSampleOverrides();text&&text!==LIB_ADD_SAMPLE_TEXTS[code]?overrides[code]=text:delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),setLibAddStatus("Sample sentence saved")}function resetLibAddSampleText(){const code=$("lib-add-sample-lang").value,overrides=libAddSampleOverrides();delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),$("lib-add-sample-text").value=LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN,setLibAddStatus("Sample sentence reset")}function updateLibAddSampleLanguage(lang){const code=LIB_ADD_SAMPLE_TEXTS[lang]?lang:"EN";$("lib-add-sample-lang").value=code,$("lib-add-lang").value=code,$("lib-add-sample-text").value=getLibAddSampleText(code);const voiceId=$("lib-add-voice-id").value.trim();voiceId&&/^[A-Z]{2}_/.test(voiceId)&&($("lib-add-voice-id").value=voiceId.replace(/^[A-Z]{2}_/,code+"_"))}function renderLibAddMeter(level=0,db=-1/0,clipped=!1){const meter=$("lib-add-mic-meter");if(!meter.children.length)for(let i=0;i<18;i++){const bar=document.createElement("div");bar.className="bar",meter.appendChild(bar)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))}),$("lib-add-db-readout").textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB"}function syncLibAddMicGain(){const gain=parseFloat($("lib-add-mic-gain").value)||0;$("lib-add-mic-gain-value").textContent=gain.toFixed(2)+"x",libAddState.gainNode&&(libAddState.gainNode.gain.value=gain)}function startLibAddMeter(){if(!libAddState.analyser)return;libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf);const data=new Float32Array(libAddState.analyser.fftSize),tick=()=>{libAddState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const sample of data)sum+=sample*sample,peak=Math.max(peak,Math.abs(sample));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;renderLibAddMeter(level,db,peak>.98),libAddState.meterRaf=requestAnimationFrame(tick)};tick()}async function ensureLibAddMicMonitor(){if(libAddState.recordStream)return;const AudioCtx=window.AudioContext||window.webkitAudioContext;if(libAddState.stream=await requestMicrophoneStream({raw:!0}),AudioCtx){libAddState.audioCtx=new AudioCtx,libAddState.sourceNode=libAddState.audioCtx.createMediaStreamSource(libAddState.stream),libAddState.gainNode=libAddState.audioCtx.createGain(),libAddState.analyser=libAddState.audioCtx.createAnalyser(),libAddState.analyser.fftSize=1024;const dest=libAddState.audioCtx.createMediaStreamDestination();syncLibAddMicGain(),libAddState.sourceNode.connect(libAddState.gainNode),libAddState.gainNode.connect(libAddState.analyser),libAddState.gainNode.connect(dest),libAddState.recordStream=dest.stream,startLibAddMeter()}else libAddState.recordStream=libAddState.stream;libAddState.monitoring=!0,$("lib-add-monitor-btn").disabled=!0,$("lib-add-monitor-stop").disabled=!1}function stopLibAddMic(){libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf),libAddState.meterRaf=null,[libAddState.sourceNode,libAddState.gainNode,libAddState.analyser].forEach(node=>{try{node&&node.disconnect()}catch{}}),libAddState.stream&&libAddState.stream.getTracks().forEach(t=>t.stop()),libAddState.recordStream&&libAddState.recordStream.getTracks().forEach(t=>t.stop()),libAddState.audioCtx&&libAddState.audioCtx.close().catch(()=>{}),libAddState.stream=null,libAddState.recordStream=null,libAddState.sourceNode=null,libAddState.gainNode=null,libAddState.analyser=null,libAddState.audioCtx=null,libAddState.monitoring=!1,$("lib-add-monitor-btn").disabled=!1,$("lib-add-monitor-stop").disabled=!0,renderLibAddMeter(0,-1/0,!1)}let libAddState={id:null,duration:0,audio:null,buffer:null,recorder:null,chunks:[],pendingSource:null,stream:null,recordStream:null,timer:null,secs:0,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,monitoring:!1};window.libAddState=libAddState,$("add-new-voice-btn").addEventListener("click",()=>{$("lib-add-panel").classList.toggle("open")}),$("lib-add-sample-lang").addEventListener("change",()=>updateLibAddSampleLanguage($("lib-add-sample-lang").value)),$("lib-add-lang").addEventListener("change",()=>updateLibAddSampleLanguage($("lib-add-lang").value)),$("lib-add-sample-text").addEventListener("input",debounce(saveLibAddSampleText,500)),$("lib-add-use-sample").addEventListener("click",()=>{$("lib-add-transcript").value=$("lib-add-sample-text").value.trim(),setLibAddStatus("Sample sentence copied to transcript")}),$("lib-add-reset-sample").addEventListener("click",resetLibAddSampleText),$("lib-add-mic-help-btn").addEventListener("click",()=>{$("lib-add-mic-help").classList.toggle("open")}),$("lib-add-monitor-btn").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),setLibAddStatus("Mic level monitor active")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message)}}),$("lib-add-monitor-stop").addEventListener("click",()=>{stopLibAddMic(),setLibAddStatus("Mic level monitor stopped")}),$("lib-add-mic-gain").addEventListener("input",syncLibAddMicGain),renderLibAddMeter(),syncLibAddMicGain(),updateLibAddSampleLanguage("EN");function setLibAddStatus(msg){$("lib-add-status").textContent=msg,status(msg)}function suggestLibVoiceId(filename){if($("lib-add-voice-id").value.trim())return;const base=(typeof _umlautSafe=="function"?_umlautSafe(filename||"NewVoice"):String(filename||"NewVoice")).replace(/\.[^.]+$/,"").replace(/[^A-Za-z0-9_-]+/g,"_").replace(/^_+|_+$/g,"").slice(0,60)||"NewVoice";$("lib-add-voice-id").value=`${$("lib-add-lang").value||"EN"}_${$("lib-add-gender").value||"N"}_${base}`}function loadLibAddAudio(id,duration,label="Audio"){libAddState.id=id,libAddState.duration=Number(duration)||0,libAddState.buffer=null,$("lib-add-start").value="0.00",$("lib-add-end").value=libAddState.duration?Math.min(libAddState.duration,20).toFixed(2):"0.00",$("lib-add-audio").src="/api/audio/"+id,$("lib-add-audio").style.display="",$("lib-add-wave").style.display="",attachLibAddWaveSelection(),decodeTempAudio(id).then(buffer=>{libAddState.id===id&&(libAddState.buffer=buffer,drawLibAddWave())}).catch(()=>{}),setLibAddStatus(`${label} loaded${libAddState.duration?" ("+libAddState.duration.toFixed(1)+" s)":""}`)}async function decodeTempAudio(id){const resp=await fetch("/api/audio/"+encodeURIComponent(id));if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const data=await resp.arrayBuffer();return new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(data.slice(0))}function clampLibAddTime(value){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;return Math.max(0,Math.min(duration,Number(value)||0))}function setLibAddCropRange(start,end){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;let a=clampLibAddTime(start),b=clampLibAddTime(end);Math.abs(b-a)<.05&&(b=Math.min(duration,a+Math.min(1,duration||1))),b=3&&dur<=20?"ok":dur?"warn":"")}function drawLibAddWave(){libAddState.buffer&&(drawOptimizerWave($("lib-add-wave"),libAddState.buffer,parseFloat($("lib-add-start").value)||0,parseFloat($("lib-add-end").value)||libAddState.duration||libAddState.buffer.duration),updateLibAddCropHint())}function libAddWaveSelectionPixels(e){var _a2;const rect=$("lib-add-wave").getBoundingClientRect(),duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0,start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||duration),sx=duration&&rect.width?start/duration*rect.width:0,ex=duration&&rect.width?end/duration*rect.width:rect.width;return{x:Math.max(0,Math.min(rect.width,e.clientX-rect.left)),sx,ex,start,end,duration}}function libAddWaveDragMode(e){const{x,sx,ex}=libAddWaveSelectionPixels(e),hit=16;return Math.abs(x-sx)<=hit?"start":Math.abs(x-ex)<=hit?"end":"new"}function attachLibAddWaveSelection(){const canvas=$("lib-add-wave");if(!canvas||canvas.dataset.cropReady)return;canvas.dataset.cropReady="1";let drag=null;canvas.addEventListener("pointerdown",e=>{var _a2;if(!libAddState.buffer)return;e.preventDefault();const mode=libAddWaveDragMode(e),t=libAddWaveTimeFromEvent(e),currentStart=parseFloat($("lib-add-start").value)||0,currentEnd=parseFloat($("lib-add-end").value)||libAddState.duration||0;drag={mode,anchor:t,start:currentStart,end:currentEnd},(_a2=canvas.setPointerCapture)==null||_a2.call(canvas,e.pointerId),mode==="start"?setLibAddCropRange(t,currentEnd):setLibAddCropRange(mode==="end"?currentStart:t,t),setLibAddStatus(mode==="start"?"Dragging crop start handle":mode==="end"?"Dragging crop end handle":"Drag to choose a new crop range")}),canvas.addEventListener("pointermove",e=>{if(!libAddState.buffer)return;if(!drag){const mode=libAddWaveDragMode(e);canvas.style.cursor=mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t)});const finish=e=>{if(!drag)return;e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t),drag=null;const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||0;setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0,end-start).toFixed(1)}s) selected`)};canvas.addEventListener("pointerup",finish),canvas.addEventListener("pointerleave",()=>{drag||(canvas.style.cursor="crosshair")}),canvas.addEventListener("pointercancel",()=>{drag=null,canvas.style.cursor="crosshair"})}async function uploadLibAddFile(file){if(!file)return;const fd=new FormData;fd.append("file",file),setLibAddStatus("Uploading audio\u2026");try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();suggestLibVoiceId(file.name),loadLibAddAudio(d.id,d.duration,file.name||"Audio"),toast("Audio loaded","success")}catch(e){toast("Load failed: "+e.message,"error"),setLibAddStatus("Load failed")}}const libAddDrop=$("lib-add-drop");libAddDrop.addEventListener("click",()=>$("lib-add-file").click()),libAddDrop.addEventListener("dragover",e=>{e.preventDefault(),libAddDrop.classList.add("drag-over")}),libAddDrop.addEventListener("dragleave",()=>libAddDrop.classList.remove("drag-over")),libAddDrop.addEventListener("drop",e=>{e.preventDefault(),libAddDrop.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadLibAddFile(e.dataTransfer.files[0])}),$("lib-add-file").addEventListener("change",async()=>{$("lib-add-file").files.length&&await uploadLibAddFile($("lib-add-file").files[0]),$("lib-add-file").value=""}),$("lib-add-url-btn").addEventListener("click",()=>{const url=$("lib-add-url").value.trim();if(!url){toast("Enter a YouTube or audio URL","error");return}$("lib-add-url-btn").disabled=!0,setLibAddStatus("Starting download\u2026");const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),setLibAddStatus(d.error),$("lib-add-url-btn").disabled=!1,es.close()):d.done?(es.close(),$("lib-add-url-btn").disabled=!1,suggestLibVoiceId(url.split("/").pop()||"DownloadedVoice"),loadLibAddAudio(d.id,d.duration,"Downloaded audio"),toast("URL audio loaded","success")):setLibAddStatus(d.msg||"Downloading\u2026")},es.onerror=()=>{es.close(),$("lib-add-url-btn").disabled=!1,setLibAddStatus("Download connection closed")}}),$("lib-add-rec-start").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),libAddState.chunks=[],libAddState.secs=0,$("lib-add-rec-time").textContent="0:00",$("lib-add-rec-start").disabled=!0,$("lib-add-rec-stop").disabled=!1,$("lib-add-monitor-stop").disabled=!0,libAddState.timer=setInterval(()=>{libAddState.secs++,$("lib-add-rec-time").textContent=Math.floor(libAddState.secs/60)+":"+String(libAddState.secs%60).padStart(2,"0")},1e3),libAddState.recorder=new MediaRecorder(libAddState.recordStream),libAddState.recorder.ondataavailable=e=>{e.data.size&&libAddState.chunks.push(e.data)},libAddState.recorder.onstop=async()=>{clearInterval(libAddState.timer),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0;const blob=new Blob(libAddState.chunks,{type:libAddState.recorder.mimeType||"audio/webm"}),ext=(libAddState.recorder.mimeType||"").includes("ogg")?".ogg":".webm";stopLibAddMic(),suggestLibVoiceId("recording"),await uploadLibAddFile(new File([blob],"recording"+ext,{type:blob.type}))},libAddState.recorder.start(100),setLibAddStatus("Recording\u2026")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0}}),$("lib-add-rec-stop").addEventListener("click",()=>{libAddState.recorder&&libAddState.recorder.state!=="inactive"&&libAddState.recorder.stop()}),$("lib-add-auto-trim").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}$("lib-add-auto-trim").disabled=!0;try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)d=await clientAutoTrimBounds(libAddState.id);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}$("lib-add-start").value=Number(d.start).toFixed(2),$("lib-add-end").value=Number(d.end).toFixed(2),drawLibAddWave(),setLibAddStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),setLibAddStatus("Auto trim failed")}finally{$("lib-add-auto-trim").disabled=!1}});async function transcribeLibAddCurrent(successMessage="Text recognised",audioId=libAddState.id){if(!audioId)throw new Error("Load audio first");setLibAddStatus("Recognising text...");const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:audioId})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const text=(await r.json()).text||"";return $("lib-add-transcript").value=text,setLibAddStatus(successMessage),text}function openSavedLibraryVoice(voiceId){const openRow=()=>{var _a2;const row=Array.from(document.querySelectorAll(".vl-row")).find(r=>r.dataset.id===voiceId);return row?(row.scrollIntoView({behavior:"smooth",block:"center"}),row.classList.contains("edit-open")||(_a2=row.querySelector(".edit-audio-btn"))==null||_a2.click(),!0):!1};openRow()||setTimeout(openRow,150)}async function applyLibAddCrop(){if(!libAddState.id){toast("Load audio first","error");return}const start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||libAddState.duration),duration=end-start;if(end<=start+.1){toast("Crop range is too short","error"),setLibAddStatus("Crop range is too short");return}(duration<3||duration>20)&&toast("Best clone references are 3-20 seconds; cropping anyway.","error"),["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!0)}),setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`);try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start,end})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();loadLibAddAudio(d.id,d.duration,"Cropped audio"),toast("Crop applied","success");try{await transcribeLibAddCurrent("Cropped audio loaded and text recognised",d.id)}catch(e){toast("Crop applied, but recognition failed: "+e.message,"error"),setLibAddStatus("Cropped audio loaded; recognition failed")}}catch(e){toast("Crop failed: "+e.message,"error"),setLibAddStatus("Crop failed")}finally{["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!1)})}}$("lib-add-save-crop").addEventListener("click",applyLibAddCrop),$("lib-add-save-crop-bottom").addEventListener("click",applyLibAddCrop),["lib-add-start","lib-add-end"].forEach(id=>$(id).addEventListener("input",drawLibAddWave)),$("lib-add-play").addEventListener("click",()=>{if(!libAddState.id)return;libAddState.audio&&libAddState.audio.pause(),libAddState.audio=new Audio("/api/audio/"+libAddState.id);const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||libAddState.duration;libAddState.audio.currentTime=start,libAddState.audio.ontimeupdate=()=>{libAddState.audio.currentTime>=end&&libAddState.audio.pause()},libAddState.audio.play()}),$("lib-add-recognize").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}try{await transcribeLibAddCurrent("Text recognised")}catch(e){toast("Recognition failed: "+e.message,"error"),setLibAddStatus("Recognition failed")}}),$("lib-add-save").addEventListener("click",async()=>{var _a2,_b2;if(!libAddState.id){toast("Load audio first","error");return}const voiceId=$("lib-add-voice-id").value.trim()||`${$("lib-add-lang").value}_${$("lib-add-gender").value}_NewVoice`;if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}setLibAddStatus("Saving voice..."),$("lib-add-save").disabled=!0;try{const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start:parseFloat($("lib-add-start").value)||0,end:parseFloat($("lib-add-end").value)||libAddState.duration})});if(!pr.ok){const e=await pr.json().catch(()=>({}));throw new Error(e.detail||pr.statusText)}const p=await pr.json();let transcript=$("lib-add-transcript").value.trim();if(!transcript&&(transcript=await transcribeLibAddCurrent("Final clip recognised; saving voice...",p.id),!transcript.trim()))throw new Error("Recognition returned no transcript; add text or try recognising again.");const sr=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:voiceId,transcript})});if(!sr.ok){const e=await sr.json().catch(()=>({}));throw new Error(e.detail||sr.statusText)}if((_a2=libAddState.pendingSource)!=null&&_a2.imageUrl)try{await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,image_url:libAddState.pendingSource.imageUrl})})}catch{}libAddState.pendingSource=null,setLibAddSourcePreview({}),(_b2=$("lib-add-panel"))==null||_b2.classList.remove("open"),toast("Voice saved: "+voiceId,"success"),setLibAddStatus("Voice saved"),await loadVoiceLibrary(),openSavedLibraryVoice(voiceId)}catch(e){toast("Save failed: "+e.message,"error"),setLibAddStatus("Save failed")}finally{$("lib-add-save").disabled=!1}}),$("show-disabled-cb").addEventListener("change",()=>{$("disabled-info").style.display=$("show-disabled-cb").checked?"":"none",renderVoiceList()}),["library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{var _a2;(_a2=$(id))==null||_a2.addEventListener("change",()=>{readLibraryFilters(),renderVoiceList()})}),(_C=$("library-filter-text"))==null||_C.addEventListener("input",debounce(()=>{readLibraryFilters(),renderVoiceList()},180)),(_D=$("library-filter-tag"))==null||_D.addEventListener("change",function(){window._voiceTagFilter=this.value||null,renderVoiceList()}),(_E=$("library-filter-group"))==null||_E.addEventListener("change",function(){window._voiceGroupFilter=this.value||null,renderVoiceList()}),(_F=$("library-clear-filters"))==null||_F.addEventListener("click",()=>{window._voiceTagFilter=null,window._voiceGroupFilter=null;const tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");tagSel&&(tagSel.value=""),groupSel&&(groupSel.value=""),clearLibraryFilters()}),(_G=$("library-tts-backend-select"))==null||_G.addEventListener("change",()=>{var _a2;status("Library TTS engine: "+(((_a2=backendById(libraryTtsBackend()))==null?void 0:_a2.label)||libraryTtsBackend()))});function renderVoiceList(){var _a2,_b2,_c2,_d2,_e2,_f2;const showDisabled=$("show-disabled-cb").checked,list=$("voice-list");list.innerHTML="",renderVoiceGroupsBar(),populateLibraryFilters(),readLibraryFilters();const _gtBtn=$("voice-group-tag-btn");if(_gtBtn){_gtBtn.classList.toggle("active",!!window._voiceGroupByTag);const ic=_gtBtn.querySelector(".mdi");ic&&(ic.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline")}const _tvBtn=$("voice-table-view-btn");if(_tvBtn){_tvBtn.classList.toggle("active",!!window._voiceTableView),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",!!window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode))}const cat=window._voiceSidebarCat||"all",enabledOk=v=>showDisabled||v.enabled!==!1;let filtered=_voices.filter(v=>cat==="cloned"?enabledOk(v)&&v.has_ref&&v.origin!=="designed":cat==="designed"?enabledOk(v)&&(v.origin==="designed"||!v.has_ref):cat==="favorites"?enabledOk(v)&&(v.rating||0)>=4:cat==="hidden"?v.enabled===!1:enabledOk(v));window._voiceGroupFilter&&(filtered=filtered.filter(v=>(v.group||"").trim()===window._voiceGroupFilter)),window._voiceTagFilter&&(filtered=filtered.filter(v=>String(v.tag||"").split(",").map(t=>t.trim()).includes(window._voiceTagFilter)));const visibleCount=filtered.length;filtered=filtered.filter(libraryFilterMatch);const filterCount=filtered.length;if(_libraryIssueFilter&&(filtered=filtered.filter(v=>libraryIssueMatch(v))),$("voice-count").textContent=filtered.length+" / "+_voices.length+" voices",filtered=filtered.slice().sort((a,b)=>{const av=getSortValue(a,_sortField),bv=getSortValue(b,_sortField);return avbv?_sortDir:0}),updateLibraryInsights(),_libraryIssueFilter){const note=document.createElement("div");note.className="library-filter-note",note.innerHTML=`${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}`,note.querySelector("button").addEventListener("click",()=>setLibraryIssueFilter("")),list.appendChild(note)}if(!filtered.length){if(_voices.length===0){const emptyEl=document.createElement("div");emptyEl.className="voices-empty-state",emptyEl.innerHTML=`
@@ -706,10 +706,10 @@ This warms each voice so the engine caches its .pt and first playback is instant
Click the pencil to load waveform and tools.
- `;const vrLengthEl=wrap.querySelector(".vr-length");hydrateVoiceDuration(v,vrLengthEl);const photoCell=wrap.querySelector(".vr-photo"),photoInput=wrap.querySelector(".photo-input"),updatePhotoImg=()=>{const ts=Date.now(),imgSrc=`/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`,img=document.createElement("img");img.src=imgSrc,img.alt="",photoCell.innerHTML="",photoCell.appendChild(img),photoCell.appendChild(photoInput);const compactAvatar=wrap.querySelector(".vl-avatar");compactAvatar&&(compactAvatar.className="vl-avatar vl-avatar-photo",compactAvatar.style.background="",compactAvatar.innerHTML=``);const inspectorAvatar=document.querySelector(".inspector-avatar");inspectorAvatar&&wrap.classList.contains("vr-selected")&&(inspectorAvatar.classList.add("insp-avatar-photo"),inspectorAvatar.style.background="",inspectorAvatar.innerHTML=``),v.has_picture=!0};photoCell.addEventListener("update-photo",updatePhotoImg),photoCell.addEventListener("click",()=>photoInput.click()),photoInput.addEventListener("change",async()=>{if(!photoInput.files.length)return;const fd=new FormData;fd.append("voice_id",v.id),fd.append("file",photoInput.files[0]);try{const r=await fetch("/api/voice/picture",{method:"POST",body:fd});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo uploaded","success")}catch(e){toast("Photo upload failed: "+e.message,"error")}}),photoCell.addEventListener("dragenter",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragover",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragleave",()=>photoCell.classList.remove("drag-over")),photoCell.addEventListener("drop",async e=>{if(e.preventDefault(),photoCell.classList.remove("drag-over"),e.dataTransfer.files&&e.dataTransfer.files.length>0){photoInput.files=e.dataTransfer.files,photoInput.dispatchEvent(new Event("change"));return}let url=e.dataTransfer.getData("text/uri-list");if(!url){const html=e.dataTransfer.getData("text/html");if(html){const match=html.match(/src=["'](.*?)["']/);match&&(url=match[1])}}if(url||(url=e.dataTransfer.getData("text/plain")),url&&/^https?:\/\//i.test(url)){status("Downloading picture from URL...");try{const r=await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,image_url:url})});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo saved from URL","success"),status("Photo saved successfully")}catch(err){toast("Photo URL download failed: "+err.message,"error"),status("Photo URL download failed")}}});const normalizeBtn=wrap.querySelector(".normalize-voice-btn"),dbValue=wrap.querySelector(".vr-db-value"),dbCell=wrap.querySelector(".vr-db");normalizeBtn.addEventListener("click",async()=>{var _a3;const target=libraryTargetDb();if(confirm(`Normalize "${v.id}" to ${target} dBFS?`)){normalizeBtn.disabled=!0,status("Normalizing "+v.id+"\u2026");try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs(v),dbCell.title=v.loudness?`avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),toast("Normalized: "+v.id,"success"),status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`)}catch(e){toast("Normalize failed: "+e.message,"error"),status("Normalize failed")}finally{normalizeBtn.disabled=!1}}});const flagEmojiEl=wrap.querySelector(".flag-emoji"),flagCodeEl=wrap.querySelector(".flag-code"),flagPicker=wrap.querySelector(".flag-picker");wrap.querySelector(".vr-flag").addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".flag-picker.open").forEach(fp=>{fp!==flagPicker&&fp.classList.remove("open")}),flagPicker.classList.toggle("open")}),flagPicker.querySelectorAll(".flag-opt").forEach(opt=>{opt.addEventListener("click",async e=>{e.stopPropagation();const cc=opt.dataset.cc;flagPicker.classList.remove("open"),flagEmojiEl.textContent=cc2flag(cc),flagCodeEl.textContent=ccDisplay(cc),flagPicker.querySelectorAll(".flag-opt").forEach(o=>o.classList.toggle("active",o.dataset.cc===cc)),v.flag=cc,await saveMeta(v.id,{flag:cc})})});const gBadge=wrap.querySelector(".gender-badge");gBadge.addEventListener("click",async()=>{const cycle=["F","M","N"];v.gender=cycle[(cycle.indexOf(v.gender||"F")+1)%3],gBadge.innerHTML=`${genderMap[v.gender]||"?"}${genderLabel[v.gender]||"\u2014"}`,gBadge.className="gender-badge "+genderClass[v.gender],await saveMeta(v.id,{gender:v.gender})});const nameText=wrap.querySelector(".vr-name-text"),renameConf=wrap.querySelector(".rename-confirm"),nameInput=wrap.querySelector(".vr-name-input"),renameOk=wrap.querySelector(".rename-ok"),renameCancel=wrap.querySelector(".rename-cancel"),startRename=()=>{nameText.style.display="none",renameConf.classList.add("show"),nameInput.focus(),nameInput.select()};nameText.addEventListener("dblclick",startRename);const cancelRename=()=>{nameText.style.display="",renameConf.classList.remove("show"),nameInput.value=v.id};renameCancel.addEventListener("click",cancelRename);const doRename=async()=>{const newId=nameInput.value.trim();if(!newId||newId===v.id){cancelRename();return}if(!/^[A-Za-z0-9_\-\.]+$/.test(newId)){toast("Invalid characters in name","error");return}try{const r=await fetch("/api/voice/rename",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({old_id:v.id,new_id:newId})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();v.id=newId,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),nameText.textContent=newId,nameText.title=newId,nameText.style.display="",renameConf.classList.remove("show"),wrap.dataset.id=newId,nameInput.value=newId,toast("Renamed to "+newId,"success")}catch(e){toast("Rename failed: "+e.message,"error")}};renameOk.addEventListener("click",doRename),nameInput.addEventListener("keydown",e=>{e.key==="Enter"&&doRename(),e.key==="Escape"&&cancelRename()}),wrap.querySelector(".vl-compact").addEventListener("click",function(e){e.target.closest("button")||selectVoice(wrap)});const editAudioBtn=wrap.querySelector(".edit-audio-btn"),optPanel=wrap.querySelector(".vr-optimizer"),vrTypeEl=wrap.querySelector(".vr-type"),optCanvas=wrap.querySelector(".opt-wave"),optStart=wrap.querySelector(".opt-start"),optEnd=wrap.querySelector(".opt-end"),optTranscript=wrap.querySelector(".opt-transcript"),optTargetDb=wrap.querySelector(".opt-target-db"),optStyleInstruct=wrap.querySelector(".opt-style-instruct"),optStyleBackend=wrap.querySelector(".opt-style-backend"),optStyleVoiceId=wrap.querySelector(".opt-style-voice-id"),optCompareBackend=wrap.querySelector(".opt-compare-backend"),optPlayReferenceBtn=wrap.querySelector(".opt-play-reference"),optSynthReferenceBtn=wrap.querySelector(".opt-synth-reference"),optCompareRefAudio=wrap.querySelector(".opt-compare-ref-audio"),optCompareSynthAudio=wrap.querySelector(".opt-compare-synth-audio"),optPreviewStyleBtn=wrap.querySelector(".opt-preview-style"),optSaveStyleBtn=wrap.querySelector(".opt-save-style"),optStyleAudio=wrap.querySelector(".opt-style-audio"),optStatus=wrap.querySelector(".opt-status"),optSaveTextBtn=wrap.querySelector(".opt-save-text"),optRestartTtsBtn=wrap.querySelector(".opt-restart-tts"),optRebenchmarkBtn=wrap.querySelector(".opt-rebenchmark"),optRestartNote=wrap.querySelector(".opt-restart-note");let optState={loaded:!1,id:null,duration:0,buffer:null,audio:null,compareSynthUrl:null};const setOptStatus=msg=>{optStatus.textContent=msg,status(msg)},setVoiceRestartState=(required,msg="")=>{v.needs_tts_restart=required,optPanel.classList.toggle("opt-restart-needed",required),optRestartNote.hidden=!required,optRestartNote.textContent=required?"Restart TTS before benchmarking; the backend may still have the old voice cached.":"",optRebenchmarkBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",benchmarkOneBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",msg&&setOptStatus(msg)},markTtsRestartRequired=msg=>setVoiceRestartState(!0,msg),refreshOptimizerFromVoice=async()=>{var _a3;markVoiceAudioChanged(v),optState.loaded=!1,optState.buffer=null,optState.id=null,await loadOptimizer(),vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),vrLengthEl&&(vrLengthEl.title=String((_a3=v.duration)!=null?_a3:"")),dbValue.textContent=fmtDbfs(v),dbCell.title=v.loudness?`avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:""},saveOptimizerText=async()=>{const transcript=optTranscript.value.trim();setOptStatus("Saving reference text..."),v.transcript=transcript,refInput.value=transcript,refInput.title=transcript,refTranscribeBtn.style.display=transcript?"none":"",await saveMeta(v.id,{transcript}),markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."),toast("Reference text saved: "+v.id,"success")},redrawOpt=()=>{!optState.buffer||optCanvas.clientWidth<4||drawOptimizerWave(optCanvas,optState.buffer,parseFloat(optStart.value)||0,parseFloat(optEnd.value)||optState.duration)};new ResizeObserver(()=>redrawOpt()).observe(optCanvas);const syncCompareReferenceAudio=()=>{if(!optState.id||!optCompareRefAudio)return;const src="/api/audio/"+optState.id;optCompareRefAudio.src.endsWith(src)||(optCompareRefAudio.src=src,optCompareRefAudio.load());const stopAtEnd=()=>{const end=parseFloat(optEnd.value)||optState.duration;optCompareRefAudio.currentTime>=end&&optCompareRefAudio.pause()};optCompareRefAudio.ontimeupdate=stopAtEnd},optWaveTimeFromEvent=e=>{const rect=optCanvas.getBoundingClientRect(),x=Math.max(0,Math.min(rect.width,e.clientX-rect.left));return optState.duration?x/Math.max(1,rect.width)*optState.duration:0},setOptCropRange=(start,end)=>{start=Math.max(0,Math.min(optState.duration||0,Number(start)||0)),end=Math.max(0,Math.min(optState.duration||0,Number(end)||0)),end{const rect=optCanvas.getBoundingClientRect(),duration=Math.max(.01,optState.duration||.01),sx=(parseFloat(optStart.value)||0)/duration*rect.width,ex=(parseFloat(optEnd.value)||optState.duration||0)/duration*rect.width;return{x:e.clientX-rect.left,sx,ex}},optWaveDragMode=e=>{const{x,sx,ex}=optWaveSelectionPixels(e),hit=18,nearStart=Math.abs(x-sx)<=hit,nearEnd=Math.abs(x-ex)<=hit;return nearStart&&nearEnd?Math.abs(x-sx)<=Math.abs(x-ex)?"start":"end":nearStart?"start":nearEnd?"end":x>sx&&x{let drag=null;optCanvas.addEventListener("pointerdown",e=>{var _a3;if(!optState.buffer||!optState.duration)return;e.preventDefault(),(_a3=optCanvas.setPointerCapture)==null||_a3.call(optCanvas,e.pointerId);const mode=optWaveDragMode(e),currentStart=parseFloat(optStart.value)||0,currentEnd=parseFloat(optEnd.value)||optState.duration;drag={mode,anchor:optWaveTimeFromEvent(e),start:currentStart,end:currentEnd,length:Math.max(.05,currentEnd-currentStart)},optCanvas.style.cursor=mode==="move"?"grabbing":"ew-resize",mode==="new"&&setOptCropRange(drag.anchor,drag.anchor)}),optCanvas.addEventListener("pointermove",e=>{if(!optState.buffer||!optState.duration)return;if(!drag){const mode=optWaveDragMode(e);optCanvas.style.cursor=mode==="move"?"grab":mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=optWaveTimeFromEvent(e);if(drag.mode==="start")setOptCropRange(Math.min(t,drag.end-.05),drag.end);else if(drag.mode==="end")setOptCropRange(drag.start,Math.max(t,drag.start+.05));else if(drag.mode==="move"){let start=t-(drag.anchor-drag.start);start=Math.max(0,Math.min((optState.duration||0)-drag.length,start)),setOptCropRange(start,start+drag.length)}else setOptCropRange(drag.anchor,t)});const finish=e=>{var _a3;drag&&((_a3=optCanvas.releasePointerCapture)==null||_a3.call(optCanvas,e.pointerId),drag=null,optCanvas.style.cursor="crosshair")};optCanvas.addEventListener("pointerup",finish),optCanvas.addEventListener("pointercancel",finish),optCanvas.addEventListener("pointerleave",()=>{drag||(optCanvas.style.cursor="crosshair")})})();const loadOptimizer=async()=>{if(optState.loaded)return;setOptStatus("Loading voice optimizer\u2026");const d=await loadLibraryVoiceAudio(v);optState.id=d.id,optState.duration=d.duration,v.duration=d.duration,optState.buffer=await decodeVoiceAudio(v),optState.loaded=!0,optStart.value="0.00",optEnd.value=d.duration.toFixed(2),optEnd.max=d.duration.toFixed(2),optTranscript.value=d.transcript||v.transcript||"",redrawOpt(),syncCompareReferenceAudio(),setVoiceRestartState(!!v.needs_tts_restart),setOptStatus(v.needs_tts_restart?"Optimizer ready. Restart TTS before benchmarking this edit.":"Optimizer ready")};if(wrap._loadOptimizer=loadOptimizer,wrap._redrawOpt=redrawOpt,editAudioBtn.addEventListener("click",async()=>{editAudioBtn.disabled=!0;try{const opening=!wrap.classList.contains("edit-open");document.querySelectorAll(".vl-row.edit-open").forEach(r=>{r!==wrap&&r.classList.remove("edit-open")}),wrap.classList.toggle("edit-open",opening),opening&&(await loadOptimizer(),wrap.scrollIntoView({behavior:"smooth",block:"nearest"}))}catch(e){toast("Edit load failed: "+e.message,"error"),status("Edit load failed")}finally{editAudioBtn.disabled=!1}}),[optStart,optEnd].forEach(inp=>inp.addEventListener("input",()=>{redrawOpt(),syncCompareReferenceAudio()})),optStyleInstruct==null||optStyleInstruct.addEventListener("input",()=>{optStyleVoiceId.value.trim()||(optStyleVoiceId.value=suggestedStyleVoiceId(v.id,optStyleInstruct.value))}),optStyleBackend==null||optStyleBackend.addEventListener("change",()=>updateStyleBackendHelp(wrap)),optCompareBackend.addEventListener("change",()=>{var _a3;return setOptStatus(`Comparison backend: ${((_a3=optCompareBackend.options[optCompareBackend.selectedIndex])==null?void 0:_a3.textContent)||optCompareBackend.value}`)}),optCompareBackend.value===""&&(optCompareBackend.innerHTML=styleBackendOptions("voice_clone"),optCompareBackend.disabled=!availableTtsBackends().length),optStyleBackend&&updateStyleBackendHelp(wrap),wrap.querySelector(".opt-db-minus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)-1).toFixed(1)}),wrap.querySelector(".opt-db-plus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)+1).toFixed(1)}),wrap.querySelector(".opt-db-auto").addEventListener("click",()=>{optTargetDb.value="-20.0"}),wrap.querySelector(".opt-play").addEventListener("click",async()=>{try{await loadOptimizer(),optState.audio&&optState.audio.pause(),optState.audio=new Audio("/api/audio/"+optState.id),optState.audio.currentTime=parseFloat(optStart.value)||0;const end=parseFloat(optEnd.value)||optState.duration;optState.audio.ontimeupdate=()=>{optState.audio.currentTime>=end&&optState.audio.pause()},optState.audio.play()}catch(e){toast("Preview failed: "+e.message,"error")}}),optPlayReferenceBtn.addEventListener("click",async()=>{try{await loadOptimizer(),syncCompareReferenceAudio(),optCompareRefAudio.currentTime=parseFloat(optStart.value)||0,await optCompareRefAudio.play().catch(()=>{}),setOptStatus("Playing reference WAV selection for comparison.")}catch(e){toast("Reference playback failed: "+e.message,"error")}}),optSynthReferenceBtn.addEventListener("click",async()=>{const text=optTranscript.value.trim();if(!text){toast("Enter reference text first","error"),optTranscript.focus();return}if(v.needs_tts_restart){if(!confirm("This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?")){setOptStatus("Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.");return}try{const d=await clearTtsRestartFlags();setVoiceRestartState(!1,`Restart flags cleared (${d.cleared_restart_flags||0}). Synthesizing comparison...`),toast("Restart flags cleared","success")}catch(e){toast("Could not clear restart flags: "+e.message,"error"),setOptStatus("Could not clear restart flags");return}}optSynthReferenceBtn.disabled=!0;try{await loadOptimizer(),setOptStatus("Synthesizing reference text for comparison...");const source=await createTtsAudioSource(v.id,text,optCompareBackend.value,"settings","");optState.compareSynthUrl&&URL.revokeObjectURL(optState.compareSynthUrl),optCompareSynthAudio.src=source.url,optState.compareSynthUrl=source.streaming?null:source.url,await optCompareSynthAudio.play().catch(()=>{}),setOptStatus(source.streaming?"Streaming synthesized comparison.":"Synthesized comparison ready.")}catch(e){toast("Synthesis comparison failed: "+e.message,"error"),setOptStatus("Synthesis comparison failed")}finally{optSynthReferenceBtn.disabled=!1}}),wrap.querySelector(".opt-auto-trim").addEventListener("click",async()=>{try{await loadOptimizer();const d=await clientAutoTrimBounds(optState.id);optStart.value=Number(d.start).toFixed(2),optEnd.value=Number(d.end).toFixed(2),redrawOpt(),setOptStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error")}}),wrap.querySelector(".opt-recognize").addEventListener("click",async()=>{try{await loadOptimizer();const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id})});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();optTranscript.value=d.text||"",setOptStatus("Reference text recognised. Review it, then Save text.")}catch(e){toast("Recognition failed: "+e.message,"error")}}),optSaveTextBtn.addEventListener("click",async()=>{optSaveTextBtn.disabled=!0;try{await saveOptimizerText()}catch(e){toast("Save text failed: "+e.message,"error"),setOptStatus("Save text failed")}finally{optSaveTextBtn.disabled=!1}}),wrap.querySelector(".opt-save-crop").addEventListener("click",async()=>{var _a3;try{await loadOptimizer();const cropStart=Math.max(0,parseFloat(optStart.value)||0),cropEnd=Math.min(optState.duration,parseFloat(optEnd.value)||optState.duration);if(cropStart<=.01&&cropEnd>=optState.duration-.05){setOptStatus("No crop range selected. Adjust Start or End first, then Save crop."),toast("No crop range selected","error");return}if(cropEnd<=cropStart+.1){setOptStatus("Crop range is too short."),toast("Crop range is too short","error");return}setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`);const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id,start:cropStart,end:cropEnd})});if(!pr.ok){const e=await pr.json();throw new Error(e.detail||pr.statusText)}const p=await pr.json(),rr=await fetch("/api/voice-replace",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:v.id,path:v.path,transcript:optTranscript.value})});if(!rr.ok){const e=await rr.json().catch(()=>({}));throw new Error(e.detail||rr.statusText)}const saved=await rr.json();v.transcript=optTranscript.value,v.duration=(_a3=saved.duration)!=null?_a3:p.duration,saved.loudness&&(v.loudness=saved.loudness),saved.path&&(v.path=saved.path),saved.file_type&&(v.file_type=saved.file_type),markVoiceAudioChanged(v),refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Voice crop saved: "+v.id,"success"),markTtsRestartRequired(saved.backup?"Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.":"Crop saved and loaded. Restart TTS before rebenchmarking.")}catch(e){toast("Save crop failed: "+e.message,"error"),setOptStatus("Save crop failed")}}),optStyleInstruct){const styleVariationInput=()=>{const style=optStyleInstruct.value.trim(),text=optTranscript.value.trim()||benchmarkSampleText(),newId=optStyleVoiceId.value.trim()||suggestedStyleVoiceId(v.id,style);return style?text?/^[A-Za-z0-9_\-.]+$/.test(newId)?{style,text,newId,backend:optStyleBackend.value}:(toast("Invalid characters in new voice ID","error"),optStyleVoiceId.focus(),null):(toast("Enter reference text first","error"),optTranscript.focus(),null):(toast("Enter a style instruction first","error"),optStyleInstruct.focus(),null)};optPreviewStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optPreviewStyleBtn.disabled=!0;try{setOptStatus("Synthesizing style preview...");const blob=await fetchTtsPreviewBlob(v.id,input.text,"wav",input.style,input.backend);optStyleAudio.src&&URL.revokeObjectURL(optStyleAudio.src),optStyleAudio.src=URL.createObjectURL(blob),optStyleAudio.style.display="",await optStyleAudio.play().catch(()=>{}),setOptStatus("Style preview ready. If it sounds right, save it as a new voice.")}catch(e){toast("Style preview failed: "+e.message,"error"),setOptStatus("Style preview failed")}finally{optPreviewStyleBtn.disabled=!1}}}),optSaveStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optSaveStyleBtn.disabled=!0;try{setOptStatus(`Synthesizing style variation ${input.newId}...`);const r=await fetch("/api/tts-style-variation",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({source_voice:v.id,voice_id:input.newId,text:input.text,instruct:input.style,backend:input.backend})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();toast("Style variation saved: "+d.voice_id,"success"),setOptStatus("Style variation saved. Restart TTS so the backend scans the new voice."),await loadVoiceLibrary(),renderIntegrationSnippets()}catch(e){toast("Style variation failed: "+e.message,"error"),setOptStatus("Style variation failed")}finally{optSaveStyleBtn.disabled=!1}}})}wrap.querySelector(".opt-undo").addEventListener("click",async()=>{var _a3;if(confirm(`Restore the original backup for "${v.id}"?`))try{setOptStatus("Restoring original\u2026");const r=await fetch("/api/voice/undo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.loudness=d.loudness||v.loudness,v.path=d.path||v.path,v.file_type=d.file_type||v.file_type,markVoiceAudioChanged(v),vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Original restored: "+v.id,"success"),markTtsRestartRequired("Original restored. Restart TTS before rebenchmarking.")}catch(e){toast("Undo failed: "+e.message,"error"),setOptStatus("Undo failed")}}),wrap.querySelector(".opt-save-volume").addEventListener("click",async()=>{var _a3;try{setOptStatus("Saving volume\u2026");const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:Number(optTargetDb.value||-20)})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs(v),dbCell.title=v.loudness?`avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",await refreshOptimizerFromVoice(),toast("Volume saved: "+v.id,"success"),markTtsRestartRequired("Volume saved. Restart TTS before rebenchmarking this voice.")}catch(e){toast("Volume save failed: "+e.message,"error"),setOptStatus("Volume save failed")}}),optRestartTtsBtn.addEventListener("click",async()=>{optRestartTtsBtn.disabled=!0;try{setOptStatus("Restarting WAV backends (Voice Clone + Streaming)\u2026");const r=await fetch("/api/tts/restart",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_voices.forEach(voice=>{voice.needs_tts_restart=!1}),updateLibraryInsights();const names=(d.restarted||[]).join(", ")||"containers",errTxt=(d.errors||[]).length?` (errors: ${d.errors.join("; ")})`:"";setVoiceRestartState(!1,`Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`),toast(`TTS restarted: ${names}`,"success")}catch(e){toast("Restart TTS failed: "+e.message,"error"),setOptStatus("Restart TTS failed: "+e.message)}finally{optRestartTtsBtn.disabled=!1}});const refInput=wrap.querySelector(".vr-ref input"),refTranscribeBtn=wrap.querySelector(".ref-transcribe-btn");refInput.addEventListener("input",debounce(async()=>{v.transcript=refInput.value,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta(v.id,{transcript:v.transcript}),wrap.classList.contains("edit-open")?markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."):v.needs_tts_restart=!0},800)),refTranscribeBtn.addEventListener("click",async()=>{refTranscribeBtn.disabled=!0;try{const d=await loadLibraryVoiceAudio(v),tr=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:d.id})});if(!tr.ok){const e=await tr.json();throw new Error(e.detail)}const text=await tr.json();v.transcript=text.text||"",refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta(v.id,{transcript:v.transcript}),v.needs_tts_restart=!0,toast("Reference text recognised; restart TTS before benchmarking","success")}catch(e){toast("Recognition failed: "+e.message,"error")}finally{refTranscribeBtn.disabled=!1}});const noteInput=wrap.querySelector(".vr-note input");noteInput.addEventListener("input",debounce(async()=>{v.note=noteInput.value,await saveMeta(v.id,{note:v.note})},800));const sourceInput=wrap.querySelector(".vr-source input");sourceInput.addEventListener("input",debounce(async()=>{v.origin=sourceInput.value.trim(),await saveMeta(v.id,{origin:v.origin});const tblCell=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"] .vl-tbl-source`);tblCell&&(tblCell.textContent=_displaySource(v)||"-")},800));const starSpans=wrap.querySelectorAll(".star");starSpans.forEach(s=>{s.addEventListener("click",async()=>{const val=parseInt(s.dataset.val),newRating=val===v.rating?0:val;v.rating=newRating,starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{const val=parseInt(s.dataset.val);starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{starSpans.forEach((ss,i)=>ss.classList.toggle("on",i<(v.rating||0)))})});const benchmarkOneBtn=wrap.querySelector(".benchmark-one-btn"),benchmarkThisVoice=async triggerBtn=>{if(v.needs_tts_restart&&!confirm("This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?")){setOptStatus("Restart TTS first, then rebenchmark this voice.");return}triggerBtn.disabled=!0,status("Benchmarking "+v.id+"...");try{setBenchmarkProgress(0,1,`Benchmarking ${v.id}`);const d=await runVoiceBenchmark(v.id);mergeBenchmarkResults(d);const hit=(d.voices||[]).find(x=>x.voice_id===v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark);const benchCell=wrap.querySelector(".vr-bench");benchCell.className="vr-bench "+benchmarkClass(v),benchCell.title=benchmarkTitle(v),benchCell.querySelector(".vr-bench-value").textContent=fmtBenchmark(v);const tblRow=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);if(tblRow){const bc=benchmarkClass(v),btitle=benchmarkTitle(v),tblDur=tblRow.querySelector(".vl-tbl-dur"),tblFactor=tblRow.querySelector(".vl-tbl-factor"),tblTime=tblRow.querySelector(".vl-tbl-time"),tblWpm=tblRow.querySelector(".vl-tbl-wpm");if(tblDur&&(tblDur.textContent=fmtBenchmarkAudio(v),tblDur.title=`${fmtBenchmarkAudio(v)} \u2014 length of synthesised benchmark audio`),tblFactor&&(tblFactor.textContent=fmtFactor(v),tblFactor.className=`vl-tbl-factor ${bc}`,tblFactor.title=btitle),tblTime&&(tblTime.textContent=fmtElapsed(v),tblTime.className=`vl-tbl-time ${bc}`,tblTime.title=btitle),tblWpm){const wpm=voiceWpm(v);tblWpm.textContent=fmtWpm(v),tblWpm.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}setBenchmarkProgress(1,1,`Finished ${v.id}`),toast("Benchmarked "+v.id,"success"),setVoiceRestartState(!1,"Benchmark saved for "+v.id)}catch(e){toast("Benchmark failed: "+e.message,"error"),setOptStatus("Benchmark failed")}finally{triggerBtn.disabled=!1}};benchmarkOneBtn.addEventListener("click",()=>benchmarkThisVoice(benchmarkOneBtn)),optRebenchmarkBtn.addEventListener("click",()=>benchmarkThisVoice(optRebenchmarkBtn));const originalPlayBtn=wrap.querySelector(".vr-play-original button"),synthPlayBtn=wrap.querySelector(".vr-play-synth button"),playIcon='',pauseIcon='',generatingIcon='';function setLibraryPlayButtonState(btn,state){btn.classList.toggle("is-generating",state==="generating"),btn.innerHTML=state==="playing"?pauseIcon:state==="generating"?generatingIcon:playIcon,btn.title=state==="generating"?"Generating synthesized sample...":state==="playing"?"Pause playback":btn.dataset.playKind==="synth"?"Generate and play synthesized sample":"Play original recording"}async function playLibraryVoice(kind,playBtn){var _a3,_b2,_c2;const bar=$("lib-audio-bar"),audio=$("lib-audio"),playKey=v.id+":"+kind;if(playBtn.dataset.playKind=kind,_activePlayVoiceId===playKey&&!audio.paused){audio.pause(),setLibraryPlayButtonState(playBtn,"idle");return}if(_activePlayVoiceId===playKey&&audio.paused&&audio.src){_activePlayButton=playBtn;try{await audio.play()}catch(e){toast("Play failed: "+e.message,"error")}return}_activePlayButton&&_activePlayButton!==playBtn&&setLibraryPlayButtonState(_activePlayButton,"idle"),_activePlayButton=playBtn,_activePlayVoiceId=playKey,_activePlayUrl&&(URL.revokeObjectURL(_activePlayUrl),_activePlayUrl=null),kind==="synth"&&setLibraryPlayButtonState(playBtn,"generating"),playBtn.disabled=!0;try{if(kind==="synth"){v.needs_tts_restart&&toast("This voice changed since backend refresh; synthesized playback may use a cached voice.","error");const synthMode=((_a3=document.querySelector("#vl-synth-mode-seg .vl-synth-seg-btn.active"))==null?void 0:_a3.dataset.mode)||"preview",text=synthMode==="transcript"&&((_b2=v.transcript)==null?void 0:_b2.trim())||benchmarkSampleText(),textLabel=synthMode==="transcript"?"reference transcript":"preview text",backend=libraryTtsBackend(),source=await createTtsAudioSource(v.id,text,backend,"settings","");audio.src=source.url,source.streaming||(_activePlayUrl=source.url),$("lib-audio-label").textContent=v.id+" \xB7 synthesized "+textLabel+" \xB7 "+(((_c2=backendById(backend))==null?void 0:_c2.label)||backend)}else audio.src=voiceFileUrl(v),$("lib-audio-label").textContent=v.id+" \xB7 original recording";bar.style.display="",audio.onended=()=>{setLibraryPlayButtonState(playBtn,"idle"),_activePlayVoiceId=null},audio.onpause=()=>{_activePlayButton===playBtn&&setLibraryPlayButtonState(playBtn,"idle")},audio.onplay=()=>{setLibraryPlayButtonState(playBtn,"playing")},await audio.play()}catch(e){setLibraryPlayButtonState(playBtn,"idle"),toast("Play failed: "+e.message,"error")}finally{playBtn.disabled=!1}}originalPlayBtn.dataset.playKind="original",synthPlayBtn.dataset.playKind="synth",setLibraryPlayButtonState(originalPlayBtn,"idle"),setLibraryPlayButtonState(synthPlayBtn,"idle"),originalPlayBtn.addEventListener("click",()=>playLibraryVoice("original",originalPlayBtn)),synthPlayBtn.addEventListener("click",()=>playLibraryVoice("synth",synthPlayBtn));const toggleCb=wrap.querySelector(".toggle input");toggleCb.addEventListener("change",async()=>{const nextEnabled=toggleCb.checked,previousEnabled=v.enabled!==!1;toggleCb.disabled=!0;try{const saved=await saveMeta(v.id,{enabled:nextEnabled});v.enabled=nextEnabled,saved&&saved.path&&(v.path=saved.path),wrap.classList.toggle("vr-disabled",!v.enabled),toast(nextEnabled?"Moved to active_voices":"Moved to hidden_voices","success"),!v.enabled&&!$("show-disabled-cb").checked&&(wrap.style.transition="opacity .4s",wrap.style.opacity="0",setTimeout(()=>wrap.remove(),400))}catch(e){toggleCb.checked=previousEnabled,v.enabled=previousEnabled,wrap.classList.toggle("vr-disabled",!v.enabled),toast("Move failed: "+e.message,"error")}finally{toggleCb.disabled=!1}});const deleteBtn=wrap.querySelector(".delete-btn"),deleteConfirm=wrap.querySelector(".delete-confirm"),deleteCancelBtn=wrap.querySelector(".delete-confirm-cancel"),deleteGoBtn=wrap.querySelector(".delete-confirm-go"),closeDeleteConfirm=()=>wrap.classList.remove("delete-pending");return deleteBtn.addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>{row!==wrap&&row.classList.remove("delete-pending")}),wrap.classList.add("delete-pending"),deleteGoBtn.focus()}),deleteCancelBtn.addEventListener("click",e=>{e.stopPropagation(),closeDeleteConfirm()}),deleteConfirm.addEventListener("click",e=>e.stopPropagation()),deleteGoBtn.addEventListener("click",async e=>{e.stopPropagation(),deleteGoBtn.disabled=!0,deleteCancelBtn.disabled=!0;try{const r=await fetch(`/api/voice/${encodeURIComponent(v.id)}`,{method:"DELETE"});if(!r.ok){const e2=await r.json();throw new Error(e2.detail)}_voices=_voices.filter(x=>x.id!==v.id),wrap.style.transition="opacity .3s",wrap.style.opacity="0",setTimeout(()=>{wrap.remove(),$("voice-count").textContent=_voices.filter(x=>$("show-disabled-cb").checked||x.enabled!==!1).length+" / "+_voices.length+" voices"},300),toast(`Deleted: ${v.id}`,"success")}catch(e2){toast("Delete failed: "+e2.message,"error"),deleteGoBtn.disabled=!1,deleteCancelBtn.disabled=!1,closeDeleteConfirm()}}),wrap}function renderVoiceGroupsBar(){var _a2;const bar=$("voice-groups-bar");if(!bar)return;const groups={};(_voices||[]).forEach(v=>{const g=(v.group||"").trim();g&&(groups[g]=(groups[g]||0)+1)});const names=Object.keys(groups).sort();if(!names.length){bar.hidden=!0,bar.innerHTML="";return}bar.hidden=!1;const active=window._voiceGroupFilter||"";bar.innerHTML=' Groups'+names.map(g=>` + `;const vrLengthEl=wrap.querySelector(".vr-length");hydrateVoiceDuration(v,vrLengthEl);const photoCell=wrap.querySelector(".vr-photo"),photoInput=wrap.querySelector(".photo-input"),updatePhotoImg=()=>{const ts=Date.now(),imgSrc=`/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`,img=document.createElement("img");img.src=imgSrc,img.alt="",photoCell.innerHTML="",photoCell.appendChild(img),photoCell.appendChild(photoInput);const compactAvatar=wrap.querySelector(".vl-avatar");compactAvatar&&(compactAvatar.className="vl-avatar vl-avatar-photo",compactAvatar.style.background="",compactAvatar.innerHTML=``);const inspectorAvatar=document.querySelector(".inspector-avatar");inspectorAvatar&&wrap.classList.contains("vr-selected")&&(inspectorAvatar.classList.add("insp-avatar-photo"),inspectorAvatar.style.background="",inspectorAvatar.innerHTML=``),v.has_picture=!0};photoCell.addEventListener("update-photo",updatePhotoImg),photoCell.addEventListener("click",()=>photoInput.click()),photoInput.addEventListener("change",async()=>{if(!photoInput.files.length)return;const fd=new FormData;fd.append("voice_id",v.id),fd.append("file",photoInput.files[0]);try{const r=await fetch("/api/voice/picture",{method:"POST",body:fd});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo uploaded","success")}catch(e){toast("Photo upload failed: "+e.message,"error")}}),photoCell.addEventListener("dragenter",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragover",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragleave",()=>photoCell.classList.remove("drag-over")),photoCell.addEventListener("drop",async e=>{if(e.preventDefault(),photoCell.classList.remove("drag-over"),e.dataTransfer.files&&e.dataTransfer.files.length>0){photoInput.files=e.dataTransfer.files,photoInput.dispatchEvent(new Event("change"));return}let url=e.dataTransfer.getData("text/uri-list");if(!url){const html=e.dataTransfer.getData("text/html");if(html){const match=html.match(/src=["'](.*?)["']/);match&&(url=match[1])}}if(url||(url=e.dataTransfer.getData("text/plain")),url&&/^https?:\/\//i.test(url)){status("Downloading picture from URL...");try{const r=await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,image_url:url})});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo saved from URL","success"),status("Photo saved successfully")}catch(err){toast("Photo URL download failed: "+err.message,"error"),status("Photo URL download failed")}}});const normalizeBtn=wrap.querySelector(".normalize-voice-btn"),dbValue=wrap.querySelector(".vr-db-value"),dbCell=wrap.querySelector(".vr-db");normalizeBtn.addEventListener("click",async()=>{var _a3;const target=libraryTargetDb();if(confirm(`Normalize "${v.id}" to ${target} dBFS?`)){normalizeBtn.disabled=!0,status("Normalizing "+v.id+"\u2026");try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs(v),dbCell.title=v.loudness?`avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),toast("Normalized: "+v.id,"success"),status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`)}catch(e){toast("Normalize failed: "+e.message,"error"),status("Normalize failed")}finally{normalizeBtn.disabled=!1}}});const flagEmojiEl=wrap.querySelector(".flag-emoji"),flagCodeEl=wrap.querySelector(".flag-code"),flagPicker=wrap.querySelector(".flag-picker");wrap.querySelector(".vr-flag").addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".flag-picker.open").forEach(fp=>{fp!==flagPicker&&fp.classList.remove("open")}),flagPicker.classList.toggle("open")}),flagPicker.querySelectorAll(".flag-opt").forEach(opt=>{opt.addEventListener("click",async e=>{e.stopPropagation();const cc=opt.dataset.cc;flagPicker.classList.remove("open"),flagEmojiEl.textContent=cc2flag(cc),flagCodeEl.textContent=ccDisplay(cc),flagPicker.querySelectorAll(".flag-opt").forEach(o=>o.classList.toggle("active",o.dataset.cc===cc)),v.flag=cc,await saveMeta(v.id,{flag:cc})})});const gBadge=wrap.querySelector(".gender-badge");gBadge.addEventListener("click",async()=>{const cycle=["F","M","N"];v.gender=cycle[(cycle.indexOf(v.gender||"F")+1)%3],gBadge.innerHTML=`${genderMap[v.gender]||"?"}${genderLabel[v.gender]||"\u2014"}`,gBadge.className="gender-badge "+genderClass[v.gender],await saveMeta(v.id,{gender:v.gender})});const nameText=wrap.querySelector(".vr-name-text"),renameConf=wrap.querySelector(".rename-confirm"),nameInput=wrap.querySelector(".vr-name-input"),renameOk=wrap.querySelector(".rename-ok"),renameCancel=wrap.querySelector(".rename-cancel"),startRename=()=>{nameText.style.display="none",renameConf.classList.add("show"),nameInput.focus(),nameInput.select()};nameText.addEventListener("dblclick",startRename);const cancelRename=()=>{nameText.style.display="",renameConf.classList.remove("show"),nameInput.value=v.id};renameCancel.addEventListener("click",cancelRename);const doRename=async()=>{const newId=nameInput.value.trim();if(!newId||newId===v.id){cancelRename();return}if(!/^[A-Za-z0-9_\-\.]+$/.test(newId)){toast("Invalid characters in name","error");return}try{const r=await fetch("/api/voice/rename",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({old_id:v.id,new_id:newId})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();v.id=newId,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),nameText.textContent=newId,nameText.title=newId,nameText.style.display="",renameConf.classList.remove("show"),wrap.dataset.id=newId,nameInput.value=newId,toast("Renamed to "+newId,"success")}catch(e){toast("Rename failed: "+e.message,"error")}};renameOk.addEventListener("click",doRename),nameInput.addEventListener("keydown",e=>{e.key==="Enter"&&doRename(),e.key==="Escape"&&cancelRename()}),wrap.querySelector(".vl-compact").addEventListener("click",function(e){e.target.closest("button")||selectVoice(wrap)});const editAudioBtn=wrap.querySelector(".edit-audio-btn"),optPanel=wrap.querySelector(".vr-optimizer"),vrTypeEl=wrap.querySelector(".vr-type"),optCanvas=wrap.querySelector(".opt-wave"),optStart=wrap.querySelector(".opt-start"),optEnd=wrap.querySelector(".opt-end"),optTranscript=wrap.querySelector(".opt-transcript"),optTargetDb=wrap.querySelector(".opt-target-db"),optStyleInstruct=wrap.querySelector(".opt-style-instruct"),optStyleBackend=wrap.querySelector(".opt-style-backend"),optStyleVoiceId=wrap.querySelector(".opt-style-voice-id"),optCompareBackend=wrap.querySelector(".opt-compare-backend"),optPlayReferenceBtn=wrap.querySelector(".opt-play-reference"),optSynthReferenceBtn=wrap.querySelector(".opt-synth-reference"),optCompareRefAudio=wrap.querySelector(".opt-compare-ref-audio"),optCompareSynthAudio=wrap.querySelector(".opt-compare-synth-audio"),optPreviewStyleBtn=wrap.querySelector(".opt-preview-style"),optSaveStyleBtn=wrap.querySelector(".opt-save-style"),optStyleAudio=wrap.querySelector(".opt-style-audio"),optStatus=wrap.querySelector(".opt-status"),optSaveTextBtn=wrap.querySelector(".opt-save-text"),optRestartTtsBtn=wrap.querySelector(".opt-restart-tts"),optRebenchmarkBtn=wrap.querySelector(".opt-rebenchmark"),optRestartNote=wrap.querySelector(".opt-restart-note");let optState={loaded:!1,id:null,duration:0,buffer:null,audio:null,compareSynthUrl:null};const setOptStatus=msg=>{optStatus.textContent=msg,status(msg)},setVoiceRestartState=(required,msg="")=>{v.needs_tts_restart=required,optPanel.classList.toggle("opt-restart-needed",required),optRestartNote.hidden=!required,optRestartNote.textContent=required?"Restart TTS before benchmarking; the backend may still have the old voice cached.":"",optRebenchmarkBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",benchmarkOneBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",msg&&setOptStatus(msg)},markTtsRestartRequired=msg=>setVoiceRestartState(!0,msg),refreshOptimizerFromVoice=async()=>{var _a3;markVoiceAudioChanged(v),optState.loaded=!1,optState.buffer=null,optState.id=null,await loadOptimizer(),vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),vrLengthEl&&(vrLengthEl.title=String((_a3=v.duration)!=null?_a3:"")),dbValue.textContent=fmtDbfs(v),dbCell.title=v.loudness?`avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:""},saveOptimizerText=async()=>{const transcript=optTranscript.value.trim();setOptStatus("Saving reference text..."),v.transcript=transcript,refInput.value=transcript,refInput.title=transcript,refTranscribeBtn.style.display=transcript?"none":"",await saveMeta(v.id,{transcript}),markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."),toast("Reference text saved: "+v.id,"success")},redrawOpt=()=>{!optState.buffer||optCanvas.clientWidth<4||drawOptimizerWave(optCanvas,optState.buffer,parseFloat(optStart.value)||0,parseFloat(optEnd.value)||optState.duration)};new ResizeObserver(()=>redrawOpt()).observe(optCanvas);const syncCompareReferenceAudio=()=>{if(!optState.id||!optCompareRefAudio)return;const src="/api/audio/"+optState.id;optCompareRefAudio.src.endsWith(src)||(optCompareRefAudio.src=src,optCompareRefAudio.load());const stopAtEnd=()=>{const end=parseFloat(optEnd.value)||optState.duration;optCompareRefAudio.currentTime>=end&&optCompareRefAudio.pause()};optCompareRefAudio.ontimeupdate=stopAtEnd},optWaveTimeFromEvent=e=>{const rect=optCanvas.getBoundingClientRect(),x=Math.max(0,Math.min(rect.width,e.clientX-rect.left));return optState.duration?x/Math.max(1,rect.width)*optState.duration:0},setOptCropRange=(start,end)=>{start=Math.max(0,Math.min(optState.duration||0,Number(start)||0)),end=Math.max(0,Math.min(optState.duration||0,Number(end)||0)),end{const rect=optCanvas.getBoundingClientRect(),duration=Math.max(.01,optState.duration||.01),sx=(parseFloat(optStart.value)||0)/duration*rect.width,ex=(parseFloat(optEnd.value)||optState.duration||0)/duration*rect.width;return{x:e.clientX-rect.left,sx,ex}},optWaveDragMode=e=>{const{x,sx,ex}=optWaveSelectionPixels(e),hit=18,nearStart=Math.abs(x-sx)<=hit,nearEnd=Math.abs(x-ex)<=hit;return nearStart&&nearEnd?Math.abs(x-sx)<=Math.abs(x-ex)?"start":"end":nearStart?"start":nearEnd?"end":x>sx&&x{let drag=null;optCanvas.addEventListener("pointerdown",e=>{var _a3;if(!optState.buffer||!optState.duration)return;e.preventDefault(),(_a3=optCanvas.setPointerCapture)==null||_a3.call(optCanvas,e.pointerId);const mode=optWaveDragMode(e),currentStart=parseFloat(optStart.value)||0,currentEnd=parseFloat(optEnd.value)||optState.duration;drag={mode,anchor:optWaveTimeFromEvent(e),start:currentStart,end:currentEnd,length:Math.max(.05,currentEnd-currentStart)},optCanvas.style.cursor=mode==="move"?"grabbing":"ew-resize",mode==="new"&&setOptCropRange(drag.anchor,drag.anchor)}),optCanvas.addEventListener("pointermove",e=>{if(!optState.buffer||!optState.duration)return;if(!drag){const mode=optWaveDragMode(e);optCanvas.style.cursor=mode==="move"?"grab":mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=optWaveTimeFromEvent(e);if(drag.mode==="start")setOptCropRange(Math.min(t,drag.end-.05),drag.end);else if(drag.mode==="end")setOptCropRange(drag.start,Math.max(t,drag.start+.05));else if(drag.mode==="move"){let start=t-(drag.anchor-drag.start);start=Math.max(0,Math.min((optState.duration||0)-drag.length,start)),setOptCropRange(start,start+drag.length)}else setOptCropRange(drag.anchor,t)});const finish=e=>{var _a3;drag&&((_a3=optCanvas.releasePointerCapture)==null||_a3.call(optCanvas,e.pointerId),drag=null,optCanvas.style.cursor="crosshair")};optCanvas.addEventListener("pointerup",finish),optCanvas.addEventListener("pointercancel",finish),optCanvas.addEventListener("pointerleave",()=>{drag||(optCanvas.style.cursor="crosshair")})})();const loadOptimizer=async()=>{if(optState.loaded)return;setOptStatus("Loading voice optimizer\u2026");const d=await loadLibraryVoiceAudio(v);optState.id=d.id,optState.duration=d.duration,v.duration=d.duration,optState.buffer=await decodeVoiceAudio(v),optState.loaded=!0,optStart.value="0.00",optEnd.value=d.duration.toFixed(2),optEnd.max=d.duration.toFixed(2),optTranscript.value=d.transcript||v.transcript||"",redrawOpt(),syncCompareReferenceAudio(),setVoiceRestartState(!!v.needs_tts_restart),setOptStatus(v.needs_tts_restart?"Optimizer ready. Restart TTS before benchmarking this edit.":"Optimizer ready")};if(wrap._loadOptimizer=loadOptimizer,wrap._redrawOpt=redrawOpt,editAudioBtn.addEventListener("click",async()=>{editAudioBtn.disabled=!0;try{const opening=!wrap.classList.contains("edit-open");document.querySelectorAll(".vl-row.edit-open").forEach(r=>{r!==wrap&&r.classList.remove("edit-open")}),wrap.classList.toggle("edit-open",opening),opening&&(await loadOptimizer(),wrap.scrollIntoView({behavior:"smooth",block:"nearest"}))}catch(e){toast("Edit load failed: "+e.message,"error"),status("Edit load failed")}finally{editAudioBtn.disabled=!1}}),[optStart,optEnd].forEach(inp=>inp.addEventListener("input",()=>{redrawOpt(),syncCompareReferenceAudio()})),optStyleInstruct==null||optStyleInstruct.addEventListener("input",()=>{optStyleVoiceId.value.trim()||(optStyleVoiceId.value=suggestedStyleVoiceId(v.id,optStyleInstruct.value))}),optStyleBackend==null||optStyleBackend.addEventListener("change",()=>updateStyleBackendHelp(wrap)),optCompareBackend.addEventListener("change",()=>{var _a3;return setOptStatus(`Comparison backend: ${((_a3=optCompareBackend.options[optCompareBackend.selectedIndex])==null?void 0:_a3.textContent)||optCompareBackend.value}`)}),optCompareBackend.value===""&&(optCompareBackend.innerHTML=styleBackendOptions("voice_clone"),optCompareBackend.disabled=!availableTtsBackends().length),optStyleBackend&&updateStyleBackendHelp(wrap),wrap.querySelector(".opt-db-minus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)-1).toFixed(1)}),wrap.querySelector(".opt-db-plus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)+1).toFixed(1)}),wrap.querySelector(".opt-db-auto").addEventListener("click",()=>{optTargetDb.value="-20.0"}),wrap.querySelector(".opt-play").addEventListener("click",async()=>{try{await loadOptimizer(),optState.audio&&optState.audio.pause(),optState.audio=new Audio("/api/audio/"+optState.id),optState.audio.currentTime=parseFloat(optStart.value)||0;const end=parseFloat(optEnd.value)||optState.duration;optState.audio.ontimeupdate=()=>{optState.audio.currentTime>=end&&optState.audio.pause()},optState.audio.play()}catch(e){toast("Preview failed: "+e.message,"error")}}),optPlayReferenceBtn.addEventListener("click",async()=>{try{await loadOptimizer(),syncCompareReferenceAudio(),optCompareRefAudio.currentTime=parseFloat(optStart.value)||0,await optCompareRefAudio.play().catch(()=>{}),setOptStatus("Playing reference WAV selection for comparison.")}catch(e){toast("Reference playback failed: "+e.message,"error")}}),optSynthReferenceBtn.addEventListener("click",async()=>{const text=optTranscript.value.trim();if(!text){toast("Enter reference text first","error"),optTranscript.focus();return}if(v.needs_tts_restart){if(!confirm("This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?")){setOptStatus("Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.");return}try{const d=await clearTtsRestartFlags();setVoiceRestartState(!1,`Restart flags cleared (${d.cleared_restart_flags||0}). Synthesizing comparison...`),toast("Restart flags cleared","success")}catch(e){toast("Could not clear restart flags: "+e.message,"error"),setOptStatus("Could not clear restart flags");return}}optSynthReferenceBtn.disabled=!0;try{await loadOptimizer(),setOptStatus("Synthesizing reference text for comparison...");const source=await createTtsAudioSource(v.id,text,optCompareBackend.value,"settings","");optState.compareSynthUrl&&URL.revokeObjectURL(optState.compareSynthUrl),optCompareSynthAudio.src=source.url,optState.compareSynthUrl=source.streaming?null:source.url,await optCompareSynthAudio.play().catch(()=>{}),setOptStatus(source.streaming?"Streaming synthesized comparison.":"Synthesized comparison ready.")}catch(e){toast("Synthesis comparison failed: "+e.message,"error"),setOptStatus("Synthesis comparison failed")}finally{optSynthReferenceBtn.disabled=!1}}),wrap.querySelector(".opt-auto-trim").addEventListener("click",async()=>{try{await loadOptimizer();const d=await clientAutoTrimBounds(optState.id);optStart.value=Number(d.start).toFixed(2),optEnd.value=Number(d.end).toFixed(2),redrawOpt(),setOptStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error")}}),wrap.querySelector(".opt-recognize").addEventListener("click",async()=>{try{await loadOptimizer();const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id})});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();optTranscript.value=d.text||"",setOptStatus("Reference text recognised. Review it, then Save text.")}catch(e){toast("Recognition failed: "+e.message,"error")}}),optSaveTextBtn.addEventListener("click",async()=>{optSaveTextBtn.disabled=!0;try{await saveOptimizerText()}catch(e){toast("Save text failed: "+e.message,"error"),setOptStatus("Save text failed")}finally{optSaveTextBtn.disabled=!1}}),wrap.querySelector(".opt-save-crop").addEventListener("click",async()=>{var _a3;try{await loadOptimizer();const cropStart=Math.max(0,parseFloat(optStart.value)||0),cropEnd=Math.min(optState.duration,parseFloat(optEnd.value)||optState.duration);if(cropStart<=.01&&cropEnd>=optState.duration-.05){setOptStatus("No crop range selected. Adjust Start or End first, then Save crop."),toast("No crop range selected","error");return}if(cropEnd<=cropStart+.1){setOptStatus("Crop range is too short."),toast("Crop range is too short","error");return}setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`);const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id,start:cropStart,end:cropEnd})});if(!pr.ok){const e=await pr.json();throw new Error(e.detail||pr.statusText)}const p=await pr.json(),rr=await fetch("/api/voice-replace",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:v.id,path:v.path,transcript:optTranscript.value})});if(!rr.ok){const e=await rr.json().catch(()=>({}));throw new Error(e.detail||rr.statusText)}const saved=await rr.json();v.transcript=optTranscript.value,v.duration=(_a3=saved.duration)!=null?_a3:p.duration,saved.loudness&&(v.loudness=saved.loudness),saved.path&&(v.path=saved.path),saved.file_type&&(v.file_type=saved.file_type),markVoiceAudioChanged(v),refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Voice crop saved: "+v.id,"success"),markTtsRestartRequired(saved.backup?"Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.":"Crop saved and loaded. Restart TTS before rebenchmarking.")}catch(e){toast("Save crop failed: "+e.message,"error"),setOptStatus("Save crop failed")}}),optStyleInstruct){const styleVariationInput=()=>{const style=optStyleInstruct.value.trim(),text=optTranscript.value.trim()||benchmarkSampleText(),newId=optStyleVoiceId.value.trim()||suggestedStyleVoiceId(v.id,style);return style?text?/^[A-Za-z0-9_\-.]+$/.test(newId)?{style,text,newId,backend:optStyleBackend.value}:(toast("Invalid characters in new voice ID","error"),optStyleVoiceId.focus(),null):(toast("Enter reference text first","error"),optTranscript.focus(),null):(toast("Enter a style instruction first","error"),optStyleInstruct.focus(),null)};optPreviewStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optPreviewStyleBtn.disabled=!0;try{setOptStatus("Synthesizing style preview...");const blob=await fetchTtsPreviewBlob(v.id,input.text,"wav",input.style,input.backend);optStyleAudio.src&&URL.revokeObjectURL(optStyleAudio.src),optStyleAudio.src=URL.createObjectURL(blob),optStyleAudio.style.display="",await optStyleAudio.play().catch(()=>{}),setOptStatus("Style preview ready. If it sounds right, save it as a new voice.")}catch(e){toast("Style preview failed: "+e.message,"error"),setOptStatus("Style preview failed")}finally{optPreviewStyleBtn.disabled=!1}}}),optSaveStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optSaveStyleBtn.disabled=!0;try{setOptStatus(`Synthesizing style variation ${input.newId}...`);const r=await fetch("/api/tts-style-variation",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({source_voice:v.id,voice_id:input.newId,text:input.text,instruct:input.style,backend:input.backend})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();toast("Style variation saved: "+d.voice_id,"success"),setOptStatus("Style variation saved. Restart TTS so the backend scans the new voice."),await loadVoiceLibrary(),renderIntegrationSnippets()}catch(e){toast("Style variation failed: "+e.message,"error"),setOptStatus("Style variation failed")}finally{optSaveStyleBtn.disabled=!1}}})}wrap.querySelector(".opt-undo").addEventListener("click",async()=>{var _a3;if(confirm(`Restore the original backup for "${v.id}"?`))try{setOptStatus("Restoring original\u2026");const r=await fetch("/api/voice/undo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.loudness=d.loudness||v.loudness,v.path=d.path||v.path,v.file_type=d.file_type||v.file_type,markVoiceAudioChanged(v),vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Original restored: "+v.id,"success"),markTtsRestartRequired("Original restored. Restart TTS before rebenchmarking.")}catch(e){toast("Undo failed: "+e.message,"error"),setOptStatus("Undo failed")}}),wrap.querySelector(".opt-save-volume").addEventListener("click",async()=>{var _a3;try{setOptStatus("Saving volume\u2026");const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:Number(optTargetDb.value||-20)})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs(v),dbCell.title=v.loudness?`avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",await refreshOptimizerFromVoice(),toast("Volume saved: "+v.id,"success"),markTtsRestartRequired("Volume saved. Restart TTS before rebenchmarking this voice.")}catch(e){toast("Volume save failed: "+e.message,"error"),setOptStatus("Volume save failed")}}),optRestartTtsBtn.addEventListener("click",async()=>{optRestartTtsBtn.disabled=!0;try{setOptStatus("Restarting WAV backends (Voice Clone + Streaming)\u2026");const r=await fetch("/api/tts/restart",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_voices.forEach(voice=>{voice.needs_tts_restart=!1}),updateLibraryInsights();const names=(d.restarted||[]).join(", ")||"containers",errTxt=(d.errors||[]).length?` (errors: ${d.errors.join("; ")})`:"";setVoiceRestartState(!1,`Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`),toast(`TTS restarted: ${names}`,"success")}catch(e){toast("Restart TTS failed: "+e.message,"error"),setOptStatus("Restart TTS failed: "+e.message)}finally{optRestartTtsBtn.disabled=!1}});const refInput=wrap.querySelector(".vr-ref input"),refTranscribeBtn=wrap.querySelector(".ref-transcribe-btn");refInput.addEventListener("input",debounce(async()=>{v.transcript=refInput.value,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta(v.id,{transcript:v.transcript}),wrap.classList.contains("edit-open")?markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."):v.needs_tts_restart=!0},800)),refTranscribeBtn.addEventListener("click",async()=>{refTranscribeBtn.disabled=!0;try{const d=await loadLibraryVoiceAudio(v),tr=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:d.id})});if(!tr.ok){const e=await tr.json();throw new Error(e.detail)}const text=await tr.json();v.transcript=text.text||"",refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta(v.id,{transcript:v.transcript}),v.needs_tts_restart=!0,toast("Reference text recognised; restart TTS before benchmarking","success")}catch(e){toast("Recognition failed: "+e.message,"error")}finally{refTranscribeBtn.disabled=!1}});const noteInput=wrap.querySelector(".vr-note input");noteInput.addEventListener("input",debounce(async()=>{v.note=noteInput.value,await saveMeta(v.id,{note:v.note})},800));const sourceInput=wrap.querySelector(".vr-source input");sourceInput.addEventListener("input",debounce(async()=>{v.origin=sourceInput.value.trim(),await saveMeta(v.id,{origin:v.origin});const tblCell=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"] .vl-tbl-source`);tblCell&&(tblCell.textContent=_displaySource(v)||"-")},800));const starSpans=wrap.querySelectorAll(".star");starSpans.forEach(s=>{s.addEventListener("click",async()=>{const val=parseInt(s.dataset.val),newRating=val===v.rating?0:val;v.rating=newRating,starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{const val=parseInt(s.dataset.val);starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{starSpans.forEach((ss,i)=>ss.classList.toggle("on",i<(v.rating||0)))})});const benchmarkOneBtn=wrap.querySelector(".benchmark-one-btn"),benchmarkThisVoice=async triggerBtn=>{if(v.needs_tts_restart&&!confirm("This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?")){setOptStatus("Restart TTS first, then rebenchmark this voice.");return}triggerBtn.disabled=!0,status("Benchmarking "+v.id+"...");try{setBenchmarkProgress(0,1,`Benchmarking ${v.id}`);const d=await runVoiceBenchmark(v.id);mergeBenchmarkResults(d);const hit=(d.voices||[]).find(x=>x.voice_id===v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark);const benchCell=wrap.querySelector(".vr-bench")||document.querySelector("#voices-inspector .vr-bench");if(benchCell){benchCell.className="vr-bench "+benchmarkClass(v),benchCell.title=benchmarkTitle(v);const benchValue=benchCell.querySelector(".vr-bench-value");benchValue&&(benchValue.textContent=fmtBenchmark(v))}const tblRow=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);if(tblRow){const bc=benchmarkClass(v),btitle=benchmarkTitle(v),tblDur=tblRow.querySelector(".vl-tbl-dur"),tblFactor=tblRow.querySelector(".vl-tbl-factor"),tblTime=tblRow.querySelector(".vl-tbl-time"),tblWpm=tblRow.querySelector(".vl-tbl-wpm");if(tblDur&&(tblDur.textContent=fmtBenchmarkAudio(v),tblDur.title=`${fmtBenchmarkAudio(v)} \u2014 length of synthesised benchmark audio`),tblFactor&&(tblFactor.textContent=fmtFactor(v),tblFactor.className=`vl-tbl-factor ${bc}`,tblFactor.title=btitle),tblTime&&(tblTime.textContent=fmtElapsed(v),tblTime.className=`vl-tbl-time ${bc}`,tblTime.title=btitle),tblWpm){const wpm=voiceWpm(v);tblWpm.textContent=fmtWpm(v),tblWpm.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}setBenchmarkProgress(1,1,`Finished ${v.id}`),toast("Benchmarked "+v.id,"success"),setVoiceRestartState(!1,"Benchmark saved for "+v.id)}catch(e){toast("Benchmark failed: "+e.message,"error"),setOptStatus("Benchmark failed")}finally{triggerBtn.disabled=!1}};benchmarkOneBtn.addEventListener("click",()=>benchmarkThisVoice(benchmarkOneBtn)),optRebenchmarkBtn.addEventListener("click",()=>benchmarkThisVoice(optRebenchmarkBtn));const originalPlayBtn=wrap.querySelector(".vr-play-original button"),synthPlayBtn=wrap.querySelector(".vr-play-synth button"),playIcon='',pauseIcon='',generatingIcon='';function setLibraryPlayButtonState(btn,state){btn.classList.toggle("is-generating",state==="generating"),btn.innerHTML=state==="playing"?pauseIcon:state==="generating"?generatingIcon:playIcon,btn.title=state==="generating"?"Generating synthesized sample...":state==="playing"?"Pause playback":btn.dataset.playKind==="synth"?"Generate and play synthesized sample":"Play original recording"}async function playLibraryVoice(kind,playBtn){var _a3,_b2,_c2;const bar=$("lib-audio-bar"),audio=$("lib-audio"),playKey=v.id+":"+kind;if(playBtn.dataset.playKind=kind,_activePlayVoiceId===playKey&&!audio.paused){audio.pause(),setLibraryPlayButtonState(playBtn,"idle");return}if(_activePlayVoiceId===playKey&&audio.paused&&audio.src){_activePlayButton=playBtn;try{await audio.play()}catch(e){toast("Play failed: "+e.message,"error")}return}_activePlayButton&&_activePlayButton!==playBtn&&setLibraryPlayButtonState(_activePlayButton,"idle"),_activePlayButton=playBtn,_activePlayVoiceId=playKey,_activePlayUrl&&(URL.revokeObjectURL(_activePlayUrl),_activePlayUrl=null),kind==="synth"&&setLibraryPlayButtonState(playBtn,"generating"),playBtn.disabled=!0;try{if(kind==="synth"){v.needs_tts_restart&&toast("This voice changed since backend refresh; synthesized playback may use a cached voice.","error");const synthMode=((_a3=document.querySelector("#vl-synth-mode-seg .vl-synth-seg-btn.active"))==null?void 0:_a3.dataset.mode)||"preview",text=synthMode==="transcript"&&((_b2=v.transcript)==null?void 0:_b2.trim())||benchmarkSampleText(),textLabel=synthMode==="transcript"?"reference transcript":"preview text",backend=isClone?libraryTtsBackend():"voice_design",source=await createTtsAudioSource(v.id,text,backend,"settings","");audio.src=source.url,source.streaming||(_activePlayUrl=source.url),$("lib-audio-label").textContent=v.id+" \xB7 synthesized "+textLabel+" \xB7 "+(((_c2=backendById(backend))==null?void 0:_c2.label)||backend)}else audio.src=voiceFileUrl(v),$("lib-audio-label").textContent=v.id+" \xB7 original recording";bar.style.display="",audio.onended=()=>{setLibraryPlayButtonState(playBtn,"idle"),_activePlayVoiceId=null},audio.onpause=()=>{_activePlayButton===playBtn&&setLibraryPlayButtonState(playBtn,"idle")},audio.onplay=()=>{setLibraryPlayButtonState(playBtn,"playing")},await audio.play()}catch(e){setLibraryPlayButtonState(playBtn,"idle"),toast("Play failed: "+e.message,"error")}finally{playBtn.disabled=!1}}originalPlayBtn.dataset.playKind="original",synthPlayBtn.dataset.playKind="synth",setLibraryPlayButtonState(originalPlayBtn,"idle"),setLibraryPlayButtonState(synthPlayBtn,"idle"),originalPlayBtn.addEventListener("click",()=>playLibraryVoice("original",originalPlayBtn)),synthPlayBtn.addEventListener("click",()=>playLibraryVoice("synth",synthPlayBtn));const toggleCb=wrap.querySelector(".toggle input");toggleCb.addEventListener("change",async()=>{const nextEnabled=toggleCb.checked,previousEnabled=v.enabled!==!1;toggleCb.disabled=!0;try{const saved=await saveMeta(v.id,{enabled:nextEnabled});v.enabled=nextEnabled,saved&&saved.path&&(v.path=saved.path),wrap.classList.toggle("vr-disabled",!v.enabled),toast(nextEnabled?"Moved to active_voices":"Moved to hidden_voices","success"),!v.enabled&&!$("show-disabled-cb").checked&&(wrap.style.transition="opacity .4s",wrap.style.opacity="0",setTimeout(()=>wrap.remove(),400))}catch(e){toggleCb.checked=previousEnabled,v.enabled=previousEnabled,wrap.classList.toggle("vr-disabled",!v.enabled),toast("Move failed: "+e.message,"error")}finally{toggleCb.disabled=!1}});const deleteBtn=wrap.querySelector(".delete-btn"),deleteConfirm=wrap.querySelector(".delete-confirm"),deleteCancelBtn=wrap.querySelector(".delete-confirm-cancel"),deleteGoBtn=wrap.querySelector(".delete-confirm-go"),closeDeleteConfirm=()=>wrap.classList.remove("delete-pending");return deleteBtn.addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>{row!==wrap&&row.classList.remove("delete-pending")}),wrap.classList.add("delete-pending"),deleteGoBtn.focus()}),deleteCancelBtn.addEventListener("click",e=>{e.stopPropagation(),closeDeleteConfirm()}),deleteConfirm.addEventListener("click",e=>e.stopPropagation()),deleteGoBtn.addEventListener("click",async e=>{e.stopPropagation(),deleteGoBtn.disabled=!0,deleteCancelBtn.disabled=!0;try{const r=await fetch(`/api/voice/${encodeURIComponent(v.id)}`,{method:"DELETE"});if(!r.ok){const e2=await r.json();throw new Error(e2.detail)}_voices=_voices.filter(x=>x.id!==v.id),wrap.style.transition="opacity .3s",wrap.style.opacity="0",setTimeout(()=>{wrap.remove(),$("voice-count").textContent=_voices.filter(x=>$("show-disabled-cb").checked||x.enabled!==!1).length+" / "+_voices.length+" voices"},300),toast(`Deleted: ${v.id}`,"success")}catch(e2){toast("Delete failed: "+e2.message,"error"),deleteGoBtn.disabled=!1,deleteCancelBtn.disabled=!1,closeDeleteConfirm()}}),wrap}function renderVoiceGroupsBar(){var _a2;const bar=$("voice-groups-bar");if(!bar)return;const groups={};(_voices||[]).forEach(v=>{const g=(v.group||"").trim();g&&(groups[g]=(groups[g]||0)+1)});const names=Object.keys(groups).sort();if(!names.length){bar.hidden=!0,bar.innerHTML="";return}bar.hidden=!1;const active=window._voiceGroupFilter||"";bar.innerHTML=' Groups'+names.map(g=>` ${escHtml(g)} ${groups[g]} - `).join("")+(active?'':""),bar.querySelectorAll(".vg-chip").forEach(chip=>{chip.addEventListener("click",e=>{if(e.target.closest(".vg-del"))return;const g=chip.dataset.group;window._voiceGroupFilter=window._voiceGroupFilter===g?"":g;const groupSel=$("library-filter-group");groupSel&&(groupSel.value=window._voiceGroupFilter||""),renderVoiceList()})}),bar.querySelectorAll(".vg-del").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),deleteVoiceGroup(btn.dataset.group)})}),(_a2=bar.querySelector(".vg-clear"))==null||_a2.addEventListener("click",()=>{window._voiceGroupFilter="";const groupSel=$("library-filter-group");groupSel&&(groupSel.value=""),renderVoiceList()})}async function deleteVoiceGroup(group){const count=(_voices||[]).filter(v=>(v.group||"").trim()===group).length;if(confirm(`Delete all ${count} voice${count!==1?"s":""} in group "${group}"? This cannot be undone.`))try{const r=await fetch("/api/voices/delete-group",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({group})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();window._voiceGroupFilter===group&&(window._voiceGroupFilter=""),toast(`Deleted ${d.count} voice${d.count!==1?"s":""} from "${group}"`,"success"),await loadVoiceLibrary()}catch(e){toast("Delete failed: "+e.message,"error")}}async function saveMeta(voiceId,patch){const r=await fetch("/api/voice/meta",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,...patch})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return r.json()}document.addEventListener("click",()=>{document.querySelectorAll(".flag-picker.open").forEach(fp=>fp.classList.remove("open")),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>row.classList.remove("delete-pending"))},{passive:!0});const _bulkSelected=new Set;function _bulkUpdate(){const bar=$("vl-bulk-bar"),count=$("vl-bulk-count"),n=_bulkSelected.size;bar&&(bar.hidden=n===0),count&&(count.textContent=`${n} selected`),document.querySelectorAll(".vl-bulk-cb").forEach(cb=>{cb.checked=_bulkSelected.has(cb.dataset.id)});const headerCb=document.getElementById("vl-select-all-header");if(headerCb){const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id),allSelected=rows.length>0&&rows.every(r=>_bulkSelected.has(r.dataset.id));headerCb.checked=allSelected}}function _bulkToggle(id,checked){checked?_bulkSelected.add(id):_bulkSelected.delete(id),_bulkUpdate()}const _origRenderVoiceList=renderVoiceList;renderVoiceList=function(){_origRenderVoiceList.apply(this,arguments),_bulkInjectCheckboxes()};function _bulkInjectCheckboxes(){document.querySelectorAll("#voice-list .vl-row").forEach(row=>{if(row.querySelector(".vl-bulk-cb"))return;const id=row.dataset.id,cb=document.createElement("input");cb.type="checkbox",cb.className="vl-bulk-cb",cb.dataset.id=id,cb.checked=_bulkSelected.has(id),cb.title="Select for bulk edit",cb.addEventListener("change",e=>{e.stopPropagation(),_bulkToggle(id,cb.checked)}),cb.addEventListener("click",e=>e.stopPropagation());const compact=row.querySelector(".vl-compact");compact&&compact.prepend(cb)})}(_H=$("vl-bulk-select-all"))==null||_H.addEventListener("click",()=>{document.querySelectorAll("#voice-list .vl-row").forEach(row=>{row.dataset.id&&_bulkSelected.add(row.dataset.id)}),_bulkUpdate()}),(_I=$("vl-select-all-header"))==null||_I.addEventListener("change",e=>{const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id);e.target.checked?rows.forEach(r=>_bulkSelected.add(r.dataset.id)):rows.forEach(r=>_bulkSelected.delete(r.dataset.id)),_bulkUpdate()}),(_J=$("vl-bulk-deselect"))==null||_J.addEventListener("click",()=>{_bulkSelected.clear(),_bulkUpdate()}),(_K=$("vl-bulk-hide"))==null||_K.addEventListener("click",async()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],r=await _bulkSetEnabled(ids,!1);toast(`Hidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected.clear(),await loadVoiceLibrary()}),(_L=$("vl-bulk-unhide"))==null||_L.addEventListener("click",async()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],r=await _bulkSetEnabled(ids,!0);toast(`Unhidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected.clear(),await loadVoiceLibrary()}),(_M=$("vl-bulk-tag"))==null||_M.addEventListener("click",()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],bar=$("vl-bulk-bar");let inp=bar.querySelector(".vl-bulk-tag-inp");if(!inp){inp=document.createElement("input"),inp.type="text",inp.className="vl-bulk-tag-inp",inp.placeholder="tag1, tag2\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const newTags=inp.value.split(",").map(t=>t.trim()).filter(Boolean);let done=0;for(const id of ids){const v=(window._voices||[]).find(vv=>vv.id===id),merged=String((v==null?void 0:v.tag)||"").split(",").map(t=>t.trim()).filter(Boolean).slice();for(const t of newTags)merged.some(e=>e.toLowerCase()===t.toLowerCase())||merged.push(t);await saveMeta(id,{tag:merged.join(", ")}).catch(()=>{}),done++}toast(`Tag added on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected.clear(),await loadVoiceLibrary()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_N=$("vl-bulk-source"))==null||_N.addEventListener("click",()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],bar=$("vl-bulk-bar");let inp=bar.querySelector(".vl-bulk-source-inp");if(!inp){inp=document.createElement("input"),inp.type="text",inp.className="vl-bulk-source-inp",inp.placeholder="e.g. fish-audio, cloned\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const val=inp.value.trim();let done=0;for(const id of ids)await saveMeta(id,{origin:val}).catch(()=>{}),done++;toast(`Source set on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected.clear(),await loadVoiceLibrary()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_O=$("vl-bulk-rating"))==null||_O.addEventListener("click",()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],bar=$("vl-bulk-bar");let sel=bar.querySelector(".vl-bulk-rating-sel");sel||(sel=document.createElement("select"),sel.className="vl-bulk-rating-sel",sel.innerHTML=''+[1,2,3,4,5].map(n=>``).join(""),sel.addEventListener("change",async()=>{const rating=Number(sel.value);if(!rating)return;let done=0;for(const id of ids)await saveMeta(id,{rating}).catch(()=>{}),done++;toast(`Rated ${done} voice${done!==1?"s":""}`,"success"),sel.remove(),_bulkSelected.clear(),await loadVoiceLibrary()}),bar.appendChild(sel),sel.focus())}),(_P=$("vl-bulk-delete"))==null||_P.addEventListener("click",()=>{_bulkSelected.size&&_showBulkDeleteConfirm([..._bulkSelected])});function _showBulkDeleteConfirm(ids){var _a2;(_a2=document.querySelector(".vl-bdc-overlay"))==null||_a2.remove();const plural=ids.length!==1?"s":"",names=ids.slice(0,12).map(id=>`${escHtml(id)}`).join(""),more=ids.length>12?`+${ids.length-12} more`:"",ov=document.createElement("div");ov.className="vl-bdc-overlay",ov.innerHTML=` + `).join("")+(active?'':""),bar.querySelectorAll(".vg-chip").forEach(chip=>{chip.addEventListener("click",e=>{if(e.target.closest(".vg-del"))return;const g=chip.dataset.group;window._voiceGroupFilter=window._voiceGroupFilter===g?"":g;const groupSel=$("library-filter-group");groupSel&&(groupSel.value=window._voiceGroupFilter||""),renderVoiceList()})}),bar.querySelectorAll(".vg-del").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),deleteVoiceGroup(btn.dataset.group)})}),(_a2=bar.querySelector(".vg-clear"))==null||_a2.addEventListener("click",()=>{window._voiceGroupFilter="";const groupSel=$("library-filter-group");groupSel&&(groupSel.value=""),renderVoiceList()})}async function deleteVoiceGroup(group){const count=(_voices||[]).filter(v=>(v.group||"").trim()===group).length;if(confirm(`Delete all ${count} voice${count!==1?"s":""} in group "${group}"? This cannot be undone.`))try{const r=await fetch("/api/voices/delete-group",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({group})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();window._voiceGroupFilter===group&&(window._voiceGroupFilter=""),toast(`Deleted ${d.count} voice${d.count!==1?"s":""} from "${group}"`,"success"),await loadVoiceLibrary()}catch(e){toast("Delete failed: "+e.message,"error")}}async function saveMeta(voiceId,patch){const r=await fetch("/api/voice/meta",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,...patch})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return r.json()}document.addEventListener("click",()=>{document.querySelectorAll(".flag-picker.open").forEach(fp=>fp.classList.remove("open")),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>row.classList.remove("delete-pending"))},{passive:!0});const _bulkSelected=new Set;function _bulkUpdate(){const bar=$("vl-bulk-bar"),count=$("vl-bulk-count"),n=_bulkSelected.size;bar&&(bar.hidden=n===0),count&&(count.textContent=`${n} selected`),document.querySelectorAll(".vl-bulk-cb").forEach(cb=>{cb.checked=_bulkSelected.has(cb.dataset.id)});const headerCb=document.getElementById("vl-select-all-header");if(headerCb){const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id),allSelected=rows.length>0&&rows.every(r=>_bulkSelected.has(r.dataset.id));headerCb.checked=allSelected}}function _bulkToggle(id,checked){checked?_bulkSelected.add(id):_bulkSelected.delete(id),_bulkUpdate()}const _origRenderVoiceList=renderVoiceList;renderVoiceList=function(){_origRenderVoiceList.apply(this,arguments),_bulkInjectCheckboxes()};let _bulkLastClickedId=null;function _bulkInjectCheckboxes(){document.querySelectorAll("#voice-list .vl-row").forEach(row=>{if(row.querySelector(".vl-bulk-cb"))return;const id=row.dataset.id,cb=document.createElement("input");cb.type="checkbox",cb.className="vl-bulk-cb",cb.dataset.id=id,cb.checked=_bulkSelected.has(id),cb.title="Select for bulk edit (shift-click to select a range)",cb.addEventListener("click",e=>{if(e.stopPropagation(),e.shiftKey&&_bulkLastClickedId){const ids=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id).map(r=>r.dataset.id),from=ids.indexOf(_bulkLastClickedId),to=ids.indexOf(id);if(from!==-1&&to!==-1){const[lo,hi]=from{document.querySelectorAll("#voice-list .vl-row").forEach(row=>{row.dataset.id&&_bulkSelected.add(row.dataset.id)}),_bulkUpdate()}),(_I=$("vl-select-all-header"))==null||_I.addEventListener("change",e=>{const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id);e.target.checked?rows.forEach(r=>_bulkSelected.add(r.dataset.id)):rows.forEach(r=>_bulkSelected.delete(r.dataset.id)),_bulkUpdate()}),(_J=$("vl-bulk-deselect"))==null||_J.addEventListener("click",()=>{_bulkSelected.clear(),_bulkUpdate()}),(_K=$("vl-bulk-hide"))==null||_K.addEventListener("click",async()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],r=await _bulkSetEnabled(ids,!1);toast(`Hidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected.clear(),await loadVoiceLibrary()}),(_L=$("vl-bulk-unhide"))==null||_L.addEventListener("click",async()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],r=await _bulkSetEnabled(ids,!0);toast(`Unhidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected.clear(),await loadVoiceLibrary()}),(_M=$("vl-bulk-tag"))==null||_M.addEventListener("click",()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],bar=$("vl-bulk-bar");let inp=bar.querySelector(".vl-bulk-tag-inp");if(!inp){inp=document.createElement("input"),inp.type="text",inp.className="vl-bulk-tag-inp",inp.placeholder="tag1, tag2\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const newTags=inp.value.split(",").map(t=>t.trim()).filter(Boolean);let done=0;for(const id of ids){const v=(window._voices||[]).find(vv=>vv.id===id),merged=String((v==null?void 0:v.tag)||"").split(",").map(t=>t.trim()).filter(Boolean).slice();for(const t of newTags)merged.some(e=>e.toLowerCase()===t.toLowerCase())||merged.push(t);await saveMeta(id,{tag:merged.join(", ")}).catch(()=>{}),done++}toast(`Tag added on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected.clear(),await loadVoiceLibrary()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_N=$("vl-bulk-source"))==null||_N.addEventListener("click",()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],bar=$("vl-bulk-bar");let inp=bar.querySelector(".vl-bulk-source-inp");if(!inp){inp=document.createElement("input"),inp.type="text",inp.className="vl-bulk-source-inp",inp.placeholder="e.g. fish-audio, cloned\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const val=inp.value.trim();let done=0;for(const id of ids)await saveMeta(id,{origin:val}).catch(()=>{}),done++;toast(`Source set on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected.clear(),await loadVoiceLibrary()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_O=$("vl-bulk-rating"))==null||_O.addEventListener("click",()=>{if(!_bulkSelected.size)return;const ids=[..._bulkSelected],bar=$("vl-bulk-bar");let sel=bar.querySelector(".vl-bulk-rating-sel");sel||(sel=document.createElement("select"),sel.className="vl-bulk-rating-sel",sel.innerHTML=''+[1,2,3,4,5].map(n=>``).join(""),sel.addEventListener("change",async()=>{const rating=Number(sel.value);if(!rating)return;let done=0;for(const id of ids)await saveMeta(id,{rating}).catch(()=>{}),done++;toast(`Rated ${done} voice${done!==1?"s":""}`,"success"),sel.remove(),_bulkSelected.clear(),await loadVoiceLibrary()}),bar.appendChild(sel),sel.focus())}),(_P=$("vl-bulk-delete"))==null||_P.addEventListener("click",()=>{_bulkSelected.size&&_showBulkDeleteConfirm([..._bulkSelected])});function _showBulkDeleteConfirm(ids){var _a2;(_a2=document.querySelector(".vl-bdc-overlay"))==null||_a2.remove();const plural=ids.length!==1?"s":"",names=ids.slice(0,12).map(id=>`${escHtml(id)}`).join(""),more=ids.length>12?`+${ids.length-12} more`:"",ov=document.createElement("div");ov.className="vl-bdc-overlay",ov.innerHTML=`
Delete ${ids.length} voice${plural}?

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 instant ${esc(status2)} `}).join(""):'No voices benchmarked.')}async function initTurnControls(){try{const settings=await fetchJson("/api/settings");q("bench-turn-llm-url")&&(q("bench-turn-llm-url").value=settings.conv_llm_url||settings.llm_url||"")}catch{}try{const d=await fetchJson("/api/stt-backends"),sel=q("bench-turn-stt");sel&&(sel.innerHTML=(d.backends||[]).map(b=>``).join("")||'')}catch{}try{typeof refreshTtsBackendAvailability=="function"&&await refreshTtsBackendAvailability();const sel=q("bench-turn-tts-backend"),backends=(window._ttsBackends||(typeof _ttsBackends!="undefined"?_ttsBackends:[])||[]).filter(Boolean);sel&&(sel.innerHTML=backends.map(b=>``).join("")||'')}catch{}}async function fetchTurnModels(){var _a2;const btn=q("bench-turn-fetch-llm"),sel=q("bench-turn-llm-model"),url=((_a2=q("bench-turn-llm-url"))==null?void 0:_a2.value.trim())||"";btn&&(btn.disabled=!0);try{const models=(await fetchJson("/api/conversation/llm-models"+(url?"?url="+encodeURIComponent(url):""))).models||[];sel&&(sel.innerHTML=models.length?models.map(m=>``).join(""):'')}catch(e){sel&&(sel.innerHTML=''),say("Model fetch failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function fetchTurnVoices(){var _a2;const btn=q("bench-turn-fetch-voices"),sel=q("bench-turn-voice"),backend=((_a2=q("bench-turn-tts-backend"))==null?void 0:_a2.value)||"voice_clone",picker=window.BenchmarkVoicePicker;btn&&(btn.disabled=!0);try{const raw=await fetchJson("/api/tts-voices?backend="+encodeURIComponent(backend)),items=(Array.isArray(raw)?raw:[]).map(v=>{const id=typeof backendVoiceId=="function"?backendVoiceId(v):typeof v=="string"?v:v.id||v.voice||v.name;return id?{id,label:id,meta:(window._voices||[]).find(x=>x&&x.id===id)||(typeof v=="object"?v:null)}:null}).filter(Boolean);picker?picker.populate("bench-turn-voice",items,{placeholder:"Fetch voices",empty:"No voices"}):sel&&(sel.innerHTML=items.length?items.map(v=>``).join(""):'')}catch(e){picker?picker.populate("bench-turn-voice",[],{placeholder:"Fetch failed",empty:"Fetch failed"}):sel&&(sel.innerHTML=''),say("Voice fetch failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function updateTurnStats(stats){const max=stats.total_ms||1;[["stt",stats.stt_ms],["ttft",stats.llm_ttft_ms],["llm",stats.llm_total_ms],["tts",stats.tts_ms],["total",stats.total_ms]].forEach(([key,ms])=>{const val=q("bench-turn-val-"+key),fill=q("bench-turn-fill-"+key);val&&(val.textContent=fmtMs(ms)),fill&&(fill.style.width=max>0?Math.min(100,(ms||0)/max*100)+"%":"0%")})}function addTurnLog(role,text){var _a2;const log=q("bench-turn-log");if(!log)return null;(_a2=log.querySelector(".conv-chat-welcome"))==null||_a2.remove();const wrap=document.createElement("div");wrap.className=`conv-bubble-wrap conv-bubble-wrap--${role}`;const bubble=document.createElement("div");return bubble.className=`conv-bubble conv-bubble--${role}`,bubble.textContent=text||"",wrap.appendChild(bubble),log.appendChild(wrap),log.scrollTop=log.scrollHeight,bubble}function addTurnHistory(total,ok){var _a2;const hist=q("bench-turn-history");if(!hist)return;(_a2=hist.querySelector(".conv-history-empty"))==null||_a2.remove(),turnHistoryCount++;const item=document.createElement("div");item.className="conv-hist-item",item.innerHTML=`#${turnHistoryCount}${fmtMs(total)}`,hist.prepend(item)}async function runTurnBenchmark(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2,_j2,_k2;const audio=(_b2=(_a2=q("bench-turn-audio"))==null?void 0:_a2.files)==null?void 0:_b2[0],text=((_c2=q("bench-turn-text"))==null?void 0:_c2.value.trim())||"";if(!audio&&!text){say("Choose turn audio or enter fallback text","error");return}const btn=q("bench-turn-run"),st=q("bench-turn-status");btn&&(btn.disabled=!0),st&&(st.textContent="Running conversation turn...");const t0=Date.now(),userBubble=addTurnLog("user",text||"Transcribing audio..."),assistantBubble=addTurnLog("assistant","...");let assistantText="",lastStats=null;try{const fd=new FormData;audio&&fd.append("audio",audio,audio.name),text&&fd.append("text",text),fd.append("stt_backend",((_d2=q("bench-turn-stt"))==null?void 0:_d2.value)||"configured"),fd.append("llm_url",((_e2=q("bench-turn-llm-url"))==null?void 0:_e2.value.trim())||""),fd.append("llm_model",((_f2=q("bench-turn-llm-model"))==null?void 0:_f2.value)||""),fd.append("tts_backend",((_g2=q("bench-turn-tts-backend"))==null?void 0:_g2.value)||"voice_clone"),fd.append("tts_voice",((_h2=q("bench-turn-voice"))==null?void 0:_h2.value)||""),fd.append("system_prompt",((_i2=q("bench-turn-system"))==null?void 0:_i2.value.trim())||"You are a helpful voice assistant."),fd.append("history","[]");const resp=await fetch("/api/conversation/turn",{method:"POST",body:fd});if(!resp.ok)throw new Error("Server error "+resp.status);const reader=resp.body.getReader(),dec=new TextDecoder;let buf="";for(;;){const{done,value}=await reader.read();if(done)break;buf+=dec.decode(value,{stream:!0});const lines=buf.split(` `);buf=lines.pop();for(const line of lines){if(!line.startsWith("data:"))continue;let evt;try{evt=JSON.parse(line.slice(5).trim())}catch{continue}if(evt.type==="transcript"&&userBubble&&(userBubble.textContent=evt.text||"(empty)"),evt.type==="token"&&(assistantText+=evt.delta||"",assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="llm_done"&&(assistantText=evt.text||assistantText,assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="audio"&&evt.b64){const bytes=Uint8Array.from(atob(evt.b64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:evt.mime||"audio/wav"})),audioEl=document.createElement("audio");audioEl.controls=!0,audioEl.src=url,audioEl.addEventListener("ended",()=>URL.revokeObjectURL(url),{once:!0}),(_j2=q("bench-turn-log"))==null||_j2.appendChild(audioEl)}if(evt.type==="stats"&&(lastStats=evt,updateTurnStats(evt)),evt.type==="error")throw new Error(`[${evt.stage||"turn"}] ${evt.message||"Unknown error"}`)}}const total=(_k2=lastStats==null?void 0:lastStats.total_ms)!=null?_k2:Date.now()-t0;addTurnHistory(total,!0),st&&(st.textContent=`Finished in ${fmtMs(total)}.`),say("Turn benchmark complete","success")}catch(e){assistantBubble&&(assistantBubble.textContent=e.message),addTurnHistory(Date.now()-t0,!1),st&&(st.textContent="Turn benchmark failed"),say("Turn benchmark failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function bindBenchmarkSection(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2;initialized||!q("bench-stt-run")||(initialized=!0,document.querySelectorAll(".bench-tab").forEach(btn=>btn.addEventListener("click",()=>setBenchTab(btn.dataset.benchTab))),window.BenchmarkVoicePicker&&(BenchmarkVoicePicker.upgrade("bench-stt-library-voice",{placeholder:"-- choose from voice library --",empty:"No library voices with reference transcripts found"}),BenchmarkVoicePicker.upgrade("perf-voice-select",{placeholder:"-- select after fetch --",empty:"No voices"}),BenchmarkVoicePicker.upgrade("bench-turn-voice",{placeholder:"Fetch voices",empty:"No voices"})),document.addEventListener("click",()=>closeBenchmarkModelPickers()),(_a2=q("bench-stt-refresh"))==null||_a2.addEventListener("click",loadBenchmarkSttEngines),(_b2=q("bench-stt-load-voice"))==null||_b2.addEventListener("click",useBenchmarkLibraryVoice),(_c2=q("bench-stt-audio"))==null||_c2.addEventListener("change",()=>{q("bench-stt-source-id")&&(q("bench-stt-source-id").value="")}),(_d2=q("bench-stt-run"))==null||_d2.addEventListener("click",runSttBenchmark),(_e2=q("bench-tts-run"))==null||_e2.addEventListener("click",runTtsBenchmark),(_f2=q("bench-turn-fetch-llm"))==null||_f2.addEventListener("click",fetchTurnModels),(_g2=q("bench-turn-fetch-voices"))==null||_g2.addEventListener("click",fetchTurnVoices),(_h2=q("bench-turn-run"))==null||_h2.addEventListener("click",runTurnBenchmark),(_i2=q("bench-turn-tts-backend"))==null||_i2.addEventListener("change",()=>{window.BenchmarkVoicePicker?BenchmarkVoicePicker.populate("bench-turn-voice",[],{placeholder:"Fetch voices",empty:"No voices"}):q("bench-turn-voice")&&(q("bench-turn-voice").innerHTML='')}))}function loadBenchmarkSectionData(){bindBenchmarkSection(),!(benchmarkDataLoaded||!q("bench-stt-run"))&&(benchmarkDataLoaded=!0,loadBenchmarkSttEngines(),loadBenchmarkVoiceLibrary(),initTurnControls())}window.loadBenchmarkSectionData=loadBenchmarkSectionData,bindBenchmarkSection(),!initialized&&document.body&&new MutationObserver(()=>bindBenchmarkSection()).observe(document.body,{childList:!0,subtree:!0})}();let sttTtsSourceId=null,sttTtsOutputBlob=null,_sttBackends=[],sttTtsRecorder=null,sttTtsRecordStream=null,sttTtsRecordChunks=[],sttTtsRecordTimer=null,sttTtsRecordSecs=0;function sttTtsSelectedSttBackend(){var _a2;return((_a2=$("stt-tts-stt-backend"))==null?void 0:_a2.value)||"configured"}function sttBackendOptionHtml(selected="configured"){var _a2;if(!_sttBackends.length)return'';const preferred=_sttBackends.some(b=>b.id===selected&&b.available)?selected:((_a2=_sttBackends.find(b=>b.available))==null?void 0:_a2.id)||selected;return _sttBackends.map(b=>{const suffix=b.available?"":" (unavailable)",disabled=b.available?"":" disabled";return``}).join("")}function updateSttBackendHelp(){const selected=sttTtsSelectedSttBackend(),b=_sttBackends.find(item=>item.id===selected)||_sttBackends.find(item=>item.available)||null,help=$("stt-tts-stt-help");if(help){if(!b){help.textContent="No STT engine status loaded yet.";return}help.innerHTML=sttBackendHelpHtml(b)}}async function refreshSttBackends(selected=""){try{_sttBackends=((await fetch("/api/stt-backends").then(r=>r.json())).backends||[]).filter(b=>b&&b.id)}catch{_sttBackends=[]}["stt-tts-stt-backend","clone-stt-backend"].forEach(id=>{const sel=$(id);if(!sel)return;const prev=selected||sel.value||"configured";sel.innerHTML=sttBackendOptionHtml(prev),sel.disabled=!_sttBackends.some(b=>b.available)}),updateSttBackendHelp(),typeof window.updateStatusBar=="function"&&window.updateStatusBar(),typeof window.refreshStatusBarEngines=="function"&&window.refreshStatusBarEngines()}function sttTtsSelectedBackend(){var _a2;return((_a2=$("stt-tts-backend-select"))==null?void 0:_a2.value)||""}function sttTtsDownload(blob,name){if(!blob)return;const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=name,a.click()}async function sttTtsUploadFile(file){if(!file)return;$("stt-tts-source-status").textContent="Uploading "+file.name+"...",sttTtsSourceId=null,sttTtsOutputBlob=null,$("stt-tts-transcribe-btn").disabled=!0,$("stt-tts-copy-preview-btn").disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;const fd=new FormData;fd.append("file",file);try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();sttTtsSourceId=d.id;const audio=$("stt-tts-source-audio");audio.src="/api/audio/"+encodeURIComponent(d.id),audio.style.display="",$("stt-tts-source-status").textContent=`${d.filename||file.name} loaded (${Number(d.duration||0).toFixed(1)} s).`,$("stt-tts-transcribe-btn").disabled=!1,toast("Speech audio loaded","success")}catch(e){$("stt-tts-source-status").textContent="Upload failed.",toast("STT source upload failed: "+e.message,"error")}}async function sttTtsFetchVoices(){const backend=sttTtsSelectedBackend();if(!backend)throw new Error("No available TTS backend");const rawVoices=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json());let voices=Array.isArray(rawVoices)?rawVoices:[];if(shouldFilterBackendVoices(backend)){const activeIds=await activeLibraryVoiceIds();voices=voices.filter(v=>activeIds.has(backendVoiceId(v)))}const sel=$("stt-tts-voice-select"),prev=sel.value;return sel.innerHTML='',voices.forEach(v=>{const id=backendVoiceId(v),opt=document.createElement("option");opt.value=opt.textContent=id,sel.appendChild(opt)}),prev&&voices.some(v=>backendVoiceId(v)===prev)&&(sel.value=prev),voices.length}(_aa=$("stt-tts-file"))==null||_aa.addEventListener("change",async()=>{const input=$("stt-tts-file");input.files&&input.files.length&&await sttTtsUploadFile(input.files[0]),input.value=""}),(_ba=$("stt-tts-refresh-stt-btn"))==null||_ba.addEventListener("click",async()=>{const btn=$("stt-tts-refresh-stt-btn");btn.disabled=!0;try{await refreshSttBackends(sttTtsSelectedSttBackend()),toast("STT engines refreshed","success")}finally{btn.disabled=!1}}),(_ca=$("stt-tts-stt-backend"))==null||_ca.addEventListener("change",updateSttBackendHelp);function sttTtsSetRecording(on){$("stt-tts-rec-start").disabled=on,$("stt-tts-rec-stop").disabled=!on}function sttTtsStopTracks(){sttTtsRecordStream&&sttTtsRecordStream.getTracks().forEach(t=>t.stop()),sttTtsRecordStream=null}(_da=$("stt-tts-rec-start"))==null||_da.addEventListener("click",async()=>{try{sttTtsRecordStream=await requestMicrophoneStream(),sttTtsRecordChunks=[],sttTtsRecordSecs=0,$("stt-tts-rec-time").textContent="0:00",$("stt-tts-source-status").textContent="Recording...",sttTtsSetRecording(!0),sttTtsRecordTimer=setInterval(()=>{sttTtsRecordSecs++,$("stt-tts-rec-time").textContent=Math.floor(sttTtsRecordSecs/60)+":"+String(sttTtsRecordSecs%60).padStart(2,"0")},1e3),sttTtsRecorder=new MediaRecorder(sttTtsRecordStream,{audioBitsPerSecond:256e3}),sttTtsRecorder.ondataavailable=e=>{e.data.size&&sttTtsRecordChunks.push(e.data)},sttTtsRecorder.onstop=async()=>{clearInterval(sttTtsRecordTimer),sttTtsRecordTimer=null,sttTtsSetRecording(!1),sttTtsStopTracks();const mime=sttTtsRecorder.mimeType||"audio/webm",blob=new Blob(sttTtsRecordChunks,{type:mime}),ext=mime.includes("ogg")?".ogg":".webm";if(!blob.size){$("stt-tts-source-status").textContent="Recording was empty.",toast("Recording was empty","error");return}await sttTtsUploadFile(new File([blob],"stt-recording"+ext,{type:mime}))},sttTtsRecorder.start(100),toast("Recording started","success")}catch(e){sttTtsSetRecording(!1),sttTtsStopTracks();const message=await microphoneErrorMessage(e);$("stt-tts-source-status").textContent=message,toast(message,"error")}}),(_ea=$("stt-tts-rec-stop"))==null||_ea.addEventListener("click",()=>{sttTtsRecorder&&sttTtsRecorder.state!=="inactive"&&sttTtsRecorder.stop()}),(_fa=$("stt-tts-transcribe-btn"))==null||_fa.addEventListener("click",async()=>{if(!sttTtsSourceId){toast("Load speech audio first","error");return}const btn=$("stt-tts-transcribe-btn");btn.disabled=!0,$("stt-tts-source-status").textContent="Transcribing...";try{const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:sttTtsSourceId,backend:sttTtsSelectedSttBackend()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();$("stt-tts-text").value=d.text||"",$("stt-tts-copy-preview-btn").disabled=!(d.text||"").trim();const used=d.backend?" via "+d.backend:"";$("stt-tts-source-status").textContent="Transcription ready"+used+".",typeof updateRefineButtonState=="function"&&updateRefineButtonState(),toast("Transcription ready","success")}catch(e){$("stt-tts-source-status").textContent="Transcription failed.",toast("STT failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_ga=$("stt-tts-copy-preview-btn"))==null||_ga.addEventListener("click",()=>{const text=$("stt-tts-text").value.trim();text&&($("preview-text-area").value=text,switchTab("generation"),toast("Copied transcription to TTS Generation","success"))}),(_ha=$("stt-tts-backend-select"))==null||_ha.addEventListener("change",()=>{$("stt-tts-voice-select").innerHTML='',sttTtsOutputBlob=null,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0,updateBackendHelp()}),(_ia=$("stt-tts-fetch-voices-btn"))==null||_ia.addEventListener("click",async()=>{const btn=$("stt-tts-fetch-voices-btn");btn.disabled=!0;try{const count=await sttTtsFetchVoices();toast("Fetched "+count+" voices","success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_ja=$("stt-tts-generate-btn"))==null||_ja.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend){toast("No available TTS backend","error");return}if(!voice){toast("Select a TTS voice","error");return}if(!text){toast("Transcribe or enter text first","error");return}const btn=$("stt-tts-generate-btn");btn.disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;try{const source=await createTtsAudioSource(voice,text,backend,$("stt-tts-playback-mode").value,instruct);sttTtsOutputBlob=source.blob;const audio=$("stt-tts-output-audio");audio.src=source.url,audio.style.display="",await audio.play(),$("stt-tts-save-mp3-btn").disabled=!1,$("stt-tts-save-wav-btn").disabled=source.streaming,toast(source.streaming?"Streaming synthesized speech":"Synthesized speech ready","success")}catch(e){toast("TTS failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_ka=$("stt-tts-save-mp3-btn"))==null||_ka.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend||!voice||!text)return;const btn=$("stt-tts-save-mp3-btn");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(voice,text,"mp3",instruct,backend);sttTtsDownload(blob,(voice||"stt_tts")+"_stt_tts.mp3"),toast("MP3 saved","success")}catch(e){toast("MP3 save failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_la=$("stt-tts-save-wav-btn"))==null||_la.addEventListener("click",()=>{sttTtsOutputBlob&&sttTtsDownload(sttTtsOutputBlob,($("stt-tts-voice-select").value||"stt_tts")+"_stt_tts.wav")});function parseScript(text){const rawLines=text.split(` -`),result=[];let state="action",currentSpeaker=null,dialogBuffer=[],actionBuffer=[];const FADE_IN_RE=/^FADE\s+IN[\s:.\-]*$/i,FIRST_SCENE_RE=/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?(INT\.|EXT\.|INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.)/i;let scanLines=rawLines;const firstIdx=rawLines.findIndex(l=>{const s=l.trim();return FADE_IN_RE.test(s)||FIRST_SCENE_RE.test(s)});firstIdx>0&&(scanLines=rawLines.slice(firstIdx));function flushDialog(){if(currentSpeaker&&dialogBuffer.length){const t=dialogBuffer.join(" ").trim();t&&result.push({type:"dialog",speaker:currentSpeaker,text:t,isDirection:!1,emotion:""})}dialogBuffer=[]}function flushAction(){if(actionBuffer.length){const t=actionBuffer.join(" ").trim();t&&result.push({type:"action",speaker:"",text:t,isDirection:!0}),actionBuffer=[]}}for(const rawLine of scanLines){const line=rawLine.trim();if(!line){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(/\d{1,2}\/\d{1,2}\/\d{2,4}/.test(line)&&line.length<=60||/^[A-Z]{0,3}\d{1,4}[A-Z]?$/.test(line)||/^\(?CONTINUED\)?:?$/i.test(line)||/^[A-Z]{0,3}\d{1,4}[A-Z]?\s+CONTINUED:?(\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)||/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?OMITTED(?:\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(line.startsWith("\f")){flushDialog(),flushAction();const pnStr=line.slice(1).trim(),pageNum=pnStr&&/^\d+$/.test(pnStr)?parseInt(pnStr,10):null;result.push({type:"pagebreak",speaker:"",text:"",page:pageNum,isDirection:!0}),state="action",currentSpeaker=null;continue}if(line.startsWith("#")){flushDialog(),flushAction();const dir=line.slice(1).trim();dir&&result.push({type:"direction",speaker:"",text:dir,isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^\[.*\]$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line.slice(1,-1),isDirection:!0});continue}{const m=line.match(/^(?:([A-Z]{0,3}\d{1,4}[A-Z]?)\s+)?((?:INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.|INT\.|EXT\.).*)$/i);if(m){flushDialog(),flushAction();let head=m[2].trim();m[1]&&head.toUpperCase().endsWith(m[1].toUpperCase())&&(head=head.slice(0,head.length-m[1].length).trim()),result.push({type:"scene",speaker:"",text:head.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}}if(/^ACT\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"act",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^SCENE\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"scene",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^(FADE\s+(IN|OUT|TO)|CUT\s+TO|SMASH\s+CUT|MATCH\s+CUT|DISSOLVE\s+TO|BLACKOUT|LIGHTS\s+(UP|DOWN|FADE)|CURTAIN|INTERMISSION|END\s+OF\s+(PLAY|ACT))[.:]?\s*$/i.test(line)){flushDialog(),flushAction(),result.push({type:"transition",speaker:"",text:line,isDirection:!0}),state="action",currentSpeaker=null;continue}const colonMatch=line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/);if(colonMatch&&colonMatch[1].trim().length<=24&&colonMatch[1].trim().split(/\s+/).length<=3){flushDialog(),flushAction(),currentSpeaker=colonMatch[1].trim(),dialogBuffer=[colonMatch[2].trim()],state="dialog";continue}if(/^\(.*\)$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line,isDirection:!0});continue}const nameRaw=line.replace(/\s*\([^)]*\)\s*$/,"").trim();if(nameRaw.length>=2&&nameRaw.length<=42&&nameRaw===nameRaw.toUpperCase()&&/^[A-Z][A-Z0-9 '.\-]+$/.test(nameRaw)&&!/^\d+$/.test(nameRaw)&&!/\.$/.test(nameRaw)){flushDialog(),flushAction(),currentSpeaker=nameRaw,state="character";continue}if(state==="character"){dialogBuffer=[line],state="dialog";continue}if(state==="dialog"){dialogBuffer.push(line);continue}actionBuffer.push(line),state="action"}return flushDialog(),flushAction(),result}function detectCharacters(lines){const speakers=[...new Set(lines.filter(l=>l.type==="dialog").map(l=>l.speaker))],cast={};return speakers.forEach((sp,i)=>{cast[sp]={voice:"",color:SPEAKER_COLORS[i%SPEAKER_COLORS.length],instruct:"",voiceData:null}}),cast}const SPEAKER_COLORS=["#89b4fa","#a6e3a1","#f38ba8","#fab387","#f9e2af","#cba6f7","#89dceb","#74c7ec"],REH_EMOTIONS=[{value:"",emoji:"\u{1F610}",label:"Neutral"},{value:"happy, cheerful and upbeat",emoji:"\u{1F60A}",label:"Happy"},{value:"sad, melancholy, somber",emoji:"\u{1F622}",label:"Sad"},{value:"angry, forceful, aggressive",emoji:"\u{1F620}",label:"Angry"},{value:"whisper, hushed and intimate",emoji:"\u{1F92B}",label:"Whisper"},{value:"excited, enthusiastic, energetic",emoji:"\u{1F929}",label:"Excited"},{value:"scared, nervous, trembling voice",emoji:"\u{1F628}",label:"Scared"},{value:"sarcastic, dry, ironic delivery",emoji:"\u{1F60F}",label:"Sarcastic"},{value:"dramatic, theatrical, intense",emoji:"\u{1F3AD}",label:"Dramatic"},{value:"gentle, warm, tender",emoji:"\u{1F970}",label:"Gentle"},{value:"confused, uncertain, hesitant",emoji:"\u{1F615}",label:"Confused"},{value:"bored, flat, disinterested",emoji:"\u{1F611}",label:"Bored"},{value:"surprised, shocked, astonished",emoji:"\u{1F632}",label:"Surprised"},{value:"confident, authoritative, bold",emoji:"\u{1F4AA}",label:"Confident"},{value:"mysterious, dark, ominous",emoji:"\u{1F311}",label:"Mysterious"},{value:"romantic, loving, passionate",emoji:"\u2764\uFE0F",label:"Romantic"},{value:"playful, teasing, mischievous",emoji:"\u{1F608}",label:"Playful"},{value:"calm, composed, measured",emoji:"\u{1F9D8}",label:"Calm"},{value:"commanding, authoritative, military",emoji:"\u2694\uFE0F",label:"Commanding"},{value:"grieving, tearful, broken",emoji:"\u{1F62D}",label:"Grieving"}];let rehCustomEmotions=[];try{rehCustomEmotions=JSON.parse(localStorage.getItem("reh-custom-emotions")||"[]")}catch{}function getEmotionInfo(value){if(!value)return{emoji:"",label:"Pick tone"};const found=[...REH_EMOTIONS,...rehCustomEmotions].find(e=>e.value===value);return found?{emoji:found.emoji,label:found.label}:{emoji:"\u2728",label:value.length>14?value.slice(0,13)+"\u2026":value}}function renderMarkdownInline(text){let s=escHtml(text);return s=s.replace(/\*\*([^*\n]+?)\*\*/g,"$1"),s=s.replace(/\*([^*\n]+?)\*/g,"$1"),s=s.replace(/__([^_\n]+?)__/g,"$1"),s=s.replace(/~~([^~\n]+?)~~/g,"$1"),s=s.replace(/==([^=\n]+?)==/g,'$1'),s}function stripMarkdown(text){return text.replace(/\*\*([^*\n]+?)\*\*/g,"$1").replace(/\*([^*\n]+?)\*/g,"$1").replace(/__([^_\n]+?)__/g,"$1").replace(/~~([^~\n]+?)~~/g,"$1").replace(/==([^=\n]+?)==/g,"$1")}const rehState={lines:[],cast:{},lineIndex:0,clips:[],voices:[],backend:"",playing:!1,repeat:!1,savedId:null,synthCache:new Map,staleLines:new Set,synthCancelled:!1,synthRunning:!1,skipDescriptions:!0,narratorVoice:"",practiceStart:null,practiceEnd:null,bulkMode:!1,bulkSel:new Set,bulkAnchor:null,showHidden:!1,recStream:null,recAudioCtx:null,recAnalyser:null,recSourceNode:null,recGainNode:null,recDestStream:null,recMeterRaf:null,recWaveRing:null,mediaRec:null,recChunks:[],recTimer:null,recSecs:0,lastRecBlob:null};window.rehState=rehState;async function rehDbGetAll(){const r=await fetch("/api/rehearsals");if(!r.ok)throw new Error("rehDbGetAll failed: "+r.status);return(await r.json()).rehearsals||[]}async function rehDbGetById(id){const r=await fetch("/api/rehearsals/"+id);if(r.status!==404){if(!r.ok)throw new Error("rehDbGetById failed: "+r.status);return r.json()}}async function rehDbAdd(record){const r=await fetch("/api/rehearsals",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbAdd failed: "+r.status);return(await r.json()).id}async function rehDbPut(record){if(!record.id)return rehDbAdd(record);const r=await fetch("/api/rehearsals/"+record.id,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbPut failed: "+r.status)}async function rehDbDelete(id){const r=await fetch("/api/rehearsals/"+id,{method:"DELETE"});if(!r.ok)throw new Error("rehDbDelete failed: "+r.status)}function _rehSanitize(rec){const out={...rec};return out.clips&&(out.clips=(out.clips||[]).map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type}))),out}(async function(){try{if((await rehDbGetAll()).length>0)return;const idbRecs=await _rehIdbGetAll().catch(()=>[]);if(!idbRecs.length)return;const r=await fetch("/api/rehearsals/migrate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(idbRecs.map(_rehSanitize))});if(r.ok){const d=await r.json();console.log(`[rehearser] migrated ${d.imported} records from IndexedDB \u2192 SQLite`)}}catch(e){console.warn("[rehearser] migration skipped:",e)}})();function _rehIdbGetAll(){return new Promise(resolve=>{const req=indexedDB.open("reh-library",1);req.onerror=()=>resolve([]),req.onsuccess=e=>{const db=e.target.result;if(!db.objectStoreNames.contains("rehearsals")){db.close(),resolve([]);return}const all=db.transaction("rehearsals","readonly").objectStore("rehearsals").getAll();all.onsuccess=ev=>{db.close(),resolve(ev.target.result||[])},all.onerror=()=>{db.close(),resolve([])}}})}window.rehDbGetById=rehDbGetById,window.rehLoadRecord=loadRecord;async function clipsToJson(clips){return Promise.all(clips.map(async c=>{if(!c.blob)return{lineIndex:c.lineIndex,speaker:c.speaker,type:c.type};const ab=await c.blob.arrayBuffer(),u8=new Uint8Array(ab);let bin="";const CHUNK=8192;for(let i=0;i{if(!c.b64)return c;const bin=atob(c.b64),u8=new Uint8Array(bin.length);for(let i=0;i{cast[sp]={voice:c.voice,color:c.color,instruct:c.instruct||"",lang:c.lang||"",gender:c.gender||"",tags:c.tags||"",soul:c.soul||"",ignored:!!c.ignored,hidden:!!c.hidden}});const emotions={},notes={},ignored={},hidden={};return rehState.lines.forEach((l,i)=>{l.type==="dialog"&&l.emotion&&(emotions[i]=l.emotion),l.type==="dialog"&&l.note&&(notes[i]=l.note),l.ignored&&(ignored[i]=!0),l.hidden&&(hidden[i]=!0)}),{title:((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||((_b2=$("reh-page-title"))==null?void 0:_b2.textContent)||"Untitled",script:rehState.lines.length?linesToScriptText():((_c2=$("reh-script-text"))==null?void 0:_c2.value.trim())||"",cast,emotions,notes,ignored,hidden,backend:rehState.backend,narratorVoice:rehState.narratorVoice,lineIndex:rehState.lineIndex,clips:rehState.clips.map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type,blob:c.blob||null})),updated:new Date}}function linesToScriptText(){return rehState.lines.map(line=>{switch(line.type){case"act":case"transition":return` +`),result=[];let state="action",currentSpeaker=null,dialogBuffer=[],actionBuffer=[];const FADE_IN_RE=/^FADE\s+IN[\s:.\-]*$/i,FIRST_SCENE_RE=/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?(INT\.|EXT\.|INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.)/i;let scanLines=rawLines;const firstIdx=rawLines.findIndex(l=>{const s=l.trim();return FADE_IN_RE.test(s)||FIRST_SCENE_RE.test(s)});firstIdx>0&&(scanLines=rawLines.slice(firstIdx));function flushDialog(){if(currentSpeaker&&dialogBuffer.length){const t=dialogBuffer.join(" ").trim();t&&result.push({type:"dialog",speaker:currentSpeaker,text:t,isDirection:!1,emotion:""})}dialogBuffer=[]}function flushAction(){if(actionBuffer.length){const t=actionBuffer.join(" ").trim();t&&result.push({type:"action",speaker:"",text:t,isDirection:!0}),actionBuffer=[]}}for(const rawLine of scanLines){const isPageBreak=rawLine.startsWith("\f"),line=isPageBreak?rawLine.slice(1).trim():rawLine.trim();if(isPageBreak){flushDialog(),flushAction();const pageNum=line&&/^\d+$/.test(line)?parseInt(line,10):null;result.push({type:"pagebreak",speaker:"",text:"",page:pageNum,isDirection:!0}),state="action",currentSpeaker=null;continue}if(!line){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(/\d{1,2}\/\d{1,2}\/\d{2,4}/.test(line)&&line.length<=60||/^[A-Z]{0,3}\d{1,4}[A-Z]?$/.test(line)||/^\(?CONTINUED\)?:?$/i.test(line)||/^[A-Z]{0,3}\d{1,4}[A-Z]?\s+CONTINUED:?(\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)||/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?OMITTED(?:\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(line.startsWith("#")){flushDialog(),flushAction();const dir=line.slice(1).trim();dir&&result.push({type:"direction",speaker:"",text:dir,isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^\[.*\]$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line.slice(1,-1),isDirection:!0});continue}{const m=line.match(/^(?:([A-Z]{0,3}\d{1,4}[A-Z]?)\s+)?((?:INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.|INT\.|EXT\.).*)$/i);if(m){flushDialog(),flushAction();let head=m[2].trim();m[1]&&head.toUpperCase().endsWith(m[1].toUpperCase())&&(head=head.slice(0,head.length-m[1].length).trim()),result.push({type:"scene",speaker:"",text:head.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}}if(/^ACT\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"act",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^SCENE\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"scene",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^(FADE\s+(IN|OUT|TO)|CUT\s+TO|SMASH\s+CUT|MATCH\s+CUT|DISSOLVE\s+TO|BLACKOUT|LIGHTS\s+(UP|DOWN|FADE)|CURTAIN|INTERMISSION|END\s+OF\s+(PLAY|ACT))[.:]?\s*$/i.test(line)){flushDialog(),flushAction(),result.push({type:"transition",speaker:"",text:line,isDirection:!0}),state="action",currentSpeaker=null;continue}const colonMatch=line.match(/^([\p{Lu}][\p{Lu}0-9 _\-ß]{0,39}):\s+(.+)$/u);if(colonMatch&&colonMatch[1].trim().length<=24&&colonMatch[1].trim().split(/\s+/).length<=3){flushDialog(),flushAction(),currentSpeaker=colonMatch[1].trim(),dialogBuffer=[colonMatch[2].trim()],state="dialog";continue}if(/^\(.*\)$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line,isDirection:!0});continue}const nameRaw=line.replace(/\s*\([^)]*\)\s*$/,"").trim();if(nameRaw.length>=2&&nameRaw.length<=42&&nameRaw===nameRaw.toUpperCase()&&/^[\p{Lu}][\p{Lu}0-9 '.\-ß]+$/u.test(nameRaw)&&!/^\d+$/.test(nameRaw)&&!/\.$/.test(nameRaw)){flushDialog(),flushAction(),currentSpeaker=nameRaw,state="character";continue}if(state==="character"){dialogBuffer=[line],state="dialog";continue}if(state==="dialog"){dialogBuffer.push(line);continue}actionBuffer.push(line),state="action"}return flushDialog(),flushAction(),result}function detectCharacters(lines){const speakers=[...new Set(lines.filter(l=>l.type==="dialog").map(l=>l.speaker))],cast={};return speakers.forEach((sp,i)=>{cast[sp]={voice:"",color:SPEAKER_COLORS[i%SPEAKER_COLORS.length],instruct:"",voiceData:null}}),cast}const SPEAKER_COLORS=["#89b4fa","#a6e3a1","#f38ba8","#fab387","#f9e2af","#cba6f7","#89dceb","#74c7ec"],REH_EMOTIONS=[{value:"",emoji:"\u{1F610}",label:"Neutral"},{value:"happy, cheerful and upbeat",emoji:"\u{1F60A}",label:"Happy"},{value:"sad, melancholy, somber",emoji:"\u{1F622}",label:"Sad"},{value:"angry, forceful, aggressive",emoji:"\u{1F620}",label:"Angry"},{value:"whisper, hushed and intimate",emoji:"\u{1F92B}",label:"Whisper"},{value:"excited, enthusiastic, energetic",emoji:"\u{1F929}",label:"Excited"},{value:"scared, nervous, trembling voice",emoji:"\u{1F628}",label:"Scared"},{value:"sarcastic, dry, ironic delivery",emoji:"\u{1F60F}",label:"Sarcastic"},{value:"dramatic, theatrical, intense",emoji:"\u{1F3AD}",label:"Dramatic"},{value:"gentle, warm, tender",emoji:"\u{1F970}",label:"Gentle"},{value:"confused, uncertain, hesitant",emoji:"\u{1F615}",label:"Confused"},{value:"bored, flat, disinterested",emoji:"\u{1F611}",label:"Bored"},{value:"surprised, shocked, astonished",emoji:"\u{1F632}",label:"Surprised"},{value:"confident, authoritative, bold",emoji:"\u{1F4AA}",label:"Confident"},{value:"mysterious, dark, ominous",emoji:"\u{1F311}",label:"Mysterious"},{value:"romantic, loving, passionate",emoji:"\u2764\uFE0F",label:"Romantic"},{value:"playful, teasing, mischievous",emoji:"\u{1F608}",label:"Playful"},{value:"calm, composed, measured",emoji:"\u{1F9D8}",label:"Calm"},{value:"commanding, authoritative, military",emoji:"\u2694\uFE0F",label:"Commanding"},{value:"grieving, tearful, broken",emoji:"\u{1F62D}",label:"Grieving"}];let rehCustomEmotions=[];try{rehCustomEmotions=JSON.parse(localStorage.getItem("reh-custom-emotions")||"[]")}catch{}function getEmotionInfo(value){if(!value)return{emoji:"",label:"Pick tone"};const found=[...REH_EMOTIONS,...rehCustomEmotions].find(e=>e.value===value);return found?{emoji:found.emoji,label:found.label}:{emoji:"\u2728",label:value.length>14?value.slice(0,13)+"\u2026":value}}function renderMarkdownInline(text){let s=escHtml(text);return s=s.replace(/\*\*([^*\n]+?)\*\*/g,"$1"),s=s.replace(/\*([^*\n]+?)\*/g,"$1"),s=s.replace(/__([^_\n]+?)__/g,"$1"),s=s.replace(/~~([^~\n]+?)~~/g,"$1"),s=s.replace(/==([^=\n]+?)==/g,'$1'),s}function stripMarkdown(text){return text.replace(/\*\*([^*\n]+?)\*\*/g,"$1").replace(/\*([^*\n]+?)\*/g,"$1").replace(/__([^_\n]+?)__/g,"$1").replace(/~~([^~\n]+?)~~/g,"$1").replace(/==([^=\n]+?)==/g,"$1")}const rehState={lines:[],cast:{},lineIndex:0,clips:[],voices:[],backend:"",playing:!1,repeat:!1,savedId:null,synthCache:new Map,staleLines:new Set,synthCancelled:!1,synthRunning:!1,skipDescriptions:!0,narratorVoice:"",practiceStart:null,practiceEnd:null,bulkMode:!1,bulkSel:new Set,bulkAnchor:null,showHidden:!1,recStream:null,recAudioCtx:null,recAnalyser:null,recSourceNode:null,recGainNode:null,recDestStream:null,recMeterRaf:null,recWaveRing:null,mediaRec:null,recChunks:[],recTimer:null,recSecs:0,lastRecBlob:null};window.rehState=rehState;async function rehDbGetAll(){const r=await fetch("/api/rehearsals");if(!r.ok)throw new Error("rehDbGetAll failed: "+r.status);return(await r.json()).rehearsals||[]}async function rehDbGetById(id){const r=await fetch("/api/rehearsals/"+id);if(r.status!==404){if(!r.ok)throw new Error("rehDbGetById failed: "+r.status);return r.json()}}async function rehDbAdd(record){const r=await fetch("/api/rehearsals",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbAdd failed: "+r.status);return(await r.json()).id}async function rehDbPut(record){if(!record.id)return rehDbAdd(record);const r=await fetch("/api/rehearsals/"+record.id,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbPut failed: "+r.status)}async function rehDbDelete(id){const r=await fetch("/api/rehearsals/"+id,{method:"DELETE"});if(!r.ok)throw new Error("rehDbDelete failed: "+r.status)}function _rehSanitize(rec){const out={...rec};return out.clips&&(out.clips=(out.clips||[]).map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type}))),out}(async function(){try{if((await rehDbGetAll()).length>0)return;const idbRecs=await _rehIdbGetAll().catch(()=>[]);if(!idbRecs.length)return;const r=await fetch("/api/rehearsals/migrate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(idbRecs.map(_rehSanitize))});if(r.ok){const d=await r.json();console.log(`[rehearser] migrated ${d.imported} records from IndexedDB \u2192 SQLite`)}}catch(e){console.warn("[rehearser] migration skipped:",e)}})();function _rehIdbGetAll(){return new Promise(resolve=>{const req=indexedDB.open("reh-library",1);req.onerror=()=>resolve([]),req.onsuccess=e=>{const db=e.target.result;if(!db.objectStoreNames.contains("rehearsals")){db.close(),resolve([]);return}const all=db.transaction("rehearsals","readonly").objectStore("rehearsals").getAll();all.onsuccess=ev=>{db.close(),resolve(ev.target.result||[])},all.onerror=()=>{db.close(),resolve([])}}})}window.rehDbGetById=rehDbGetById,window.rehLoadRecord=loadRecord;async function clipsToJson(clips){return Promise.all(clips.map(async c=>{if(!c.blob)return{lineIndex:c.lineIndex,speaker:c.speaker,type:c.type};const ab=await c.blob.arrayBuffer(),u8=new Uint8Array(ab);let bin="";const CHUNK=8192;for(let i=0;i{if(!c.b64)return c;const bin=atob(c.b64),u8=new Uint8Array(bin.length);for(let i=0;i{cast[sp]={voice:c.voice,color:c.color,instruct:c.instruct||"",lang:c.lang||"",gender:c.gender||"",tags:c.tags||"",soul:c.soul||"",ignored:!!c.ignored,hidden:!!c.hidden}});const emotions={},notes={},ignored={},hidden={};return rehState.lines.forEach((l,i)=>{l.type==="dialog"&&l.emotion&&(emotions[i]=l.emotion),l.type==="dialog"&&l.note&&(notes[i]=l.note),l.ignored&&(ignored[i]=!0),l.hidden&&(hidden[i]=!0)}),{title:((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||((_b2=$("reh-page-title"))==null?void 0:_b2.textContent)||"Untitled",script:rehState.lines.length?linesToScriptText():((_c2=$("reh-script-text"))==null?void 0:_c2.value.trim())||"",cast,emotions,notes,ignored,hidden,backend:rehState.backend,narratorVoice:rehState.narratorVoice,lineIndex:rehState.lineIndex,clips:rehState.clips.map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type,blob:c.blob||null})),updated:new Date}}function linesToScriptText(){return rehState.lines.map(line=>{switch(line.type){case"act":case"transition":return` `+line.text+` `;case"scene":return` `+line.text+` @@ -913,7 +913,7 @@ This warms each voice so the engine caches its .pt and first playback is instant `;const blob=new Blob([xml],{type:"text/xml"}),a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=(title.replace(/[^a-z0-9_\- ]/gi,"_")||"script")+".osf",a.click(),toast("Exported as .osf (Open Screenplay Format)","success")}async function loadPdfJs(){window.pdfjsLib||(await new Promise((resolve,reject)=>{const s=document.createElement("script");s.src="/static/js/pdf/pdf.min.js",s.onload=resolve,s.onerror=reject,document.head.appendChild(s)}),pdfjsLib.GlobalWorkerOptions.workerSrc="/static/js/pdf/pdf.worker.min.js")}async function importPDFScript(file){toast("Loading PDF reader\u2026","success"),await loadPdfJs();const ab=await file.arrayBuffer(),pdf=await pdfjsLib.getDocument({data:ab}).promise,allLinesByPage=[],allLines=[];for(let p=1;p<=pdf.numPages;p++){const page=await pdf.getPage(p),content=await page.getTextContent(),pageW2=page.getViewport({scale:1}).width,rows=new Map;content.items.forEach(item=>{if(!item.str)return;const y=Math.round(item.transform[5]/3)*3;rows.has(y)||rows.set(y,[]),rows.get(y).push({x:item.transform[4],w:item.width||0,str:item.str})});const pageLines=[];[...rows.entries()].sort((a,b)=>b[0]-a[0]).forEach(([,items])=>{items.sort((a,b)=>a.x-b.x);const text=items.map(i=>i.str).join("").replace(/\s+/g," ").trim();if(!text)return;const x=items[0].x,last=items[items.length-1],right=last.x+(last.w||0);pageLines.push({x,right,pageW:pageW2,text}),allLines.push({x,right,pageW:pageW2,text})}),allLinesByPage.push(pageLines)}if(!allLines.length)return"";const leftMargin=Math.min(...allLines.map(l=>l.x)),pageW=allLines[0].pageW||612,isNoise=t=>{const s=t.trim();return!!(/^\d{1,4}\.?$/.test(s)||/^\(?CONTINUED\)?:?$/i.test(s)||/^\d+\.$/.test(s)||/^(rev\.|revised|draft)\b/i.test(s)&&s.length<24)},out=[];let prevBlank=!0,prevWasAction=!1;const push=(line,blankBefore)=>{blankBefore&&!prevBlank&&out.push(""),out.push(line),prevBlank=!1},blank=()=>{prevBlank||(out.push(""),prevBlank=!0)},nonAction=()=>{prevWasAction=!1};for(let pageIdx=0;pageIdx0&&(blank(),out.push(`\f${pageIdx+1}`),blank());const pageLns=allLinesByPage[pageIdx]||[];for(const ln of pageLns){const t=ln.text.trim();if(!t||isNoise(t))continue;const indent=(ln.x-leftMargin)/pageW,rightFrac=ln.right/pageW,isCaps=t===t.toUpperCase()&&/[A-Z]/.test(t),wordCount=t.split(/\s+/).length;if(/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)){blank(),push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/,"")),blank(),nonAction();continue}if(/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)){blank(),push(t.toUpperCase()),blank(),nonAction();continue}if(isCaps&&(rightFrac>.72||indent>.45)&&/(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)){blank(),push(t),blank(),nonAction();continue}if(/^\(.*\)?$/.test(t)||indent>.18&&indent<.3&&/^\(/.test(t)){push(t.startsWith("(")?t:"("+t+")",!1),nonAction();continue}const nameCore=t.replace(/\s*\([^)]*\)\s*$/,"").trim();if(indent>.22&&isCaps&&wordCount<=6&&nameCore.length<=40&&/^[A-Z0-9 .'#\-]+$/.test(nameCore)){blank(),push(t.toUpperCase()),prevBlank=!1,nonAction();continue}if(indent>.1&&indent<.34){push(t,!1),nonAction();continue}prevWasAction?out.push(t):(blank(),push(t)),prevWasAction=!0}}return out.join(` `).replace(/\n{3,}/g,` -`).trim()}async function importFromFile(file){try{const text=await file.text(),data=JSON.parse(text);data.clips=clipsFromJson(data.clips||[]),data.created=data.created?new Date(data.created):new Date,data.updated=new Date,delete data.id;const newId=await rehDbAdd(data);toast("Imported: "+(data.title||"Untitled"),"success"),await renderLibraryList(),loadRecord({...data,id:newId})}catch(e){toast("Import failed: "+e.message,"error")}}function loadRecord(rec){typeof navTo=="function"&&navTo("s-rehearser"),$("reh-script-text")&&($("reh-script-text").value=rec.script||""),$("reh-script-title")&&($("reh-script-title").value=rec.title||""),rehState.savedId=rec.id||null,rehState._keepSavedIdOnce=!0,rehState.backend=rec.backend||"",rehState.lineIndex=rec.lineIndex||0,rehState.clips=(rec.clips||[]).map(c=>({...c})),rehState.synthCache.clear(),rehDecodedBuffers.clear(),rehState.narratorVoice=rec.narratorVoice||"";const lines=parseScript(rec.script||"");rec.emotions&&lines.forEach((l,i)=>{l.type==="dialog"&&rec.emotions[i]&&(l.emotion=rec.emotions[i])}),rec.notes&&lines.forEach((l,i)=>{l.type==="dialog"&&rec.notes[i]&&(l.note=rec.notes[i])}),rec.ignored&&lines.forEach((l,i)=>{rec.ignored[i]&&(l.ignored=!0)}),rec.hidden&&lines.forEach((l,i)=>{rec.hidden[i]&&(l.hidden=!0)}),rehState.lines=lines,rehState.cast={};const detected=detectCharacters(lines);Object.entries(detected).forEach(([sp,def])=>{var _a2,_b2,_c2;const saved=(_a2=rec.cast)==null?void 0:_a2[sp];rehState.cast[sp]={voice:(_b2=saved==null?void 0:saved.voice)!=null?_b2:def.voice,color:(_c2=saved==null?void 0:saved.color)!=null?_c2:def.color,instruct:(saved==null?void 0:saved.instruct)||"",lang:(saved==null?void 0:saved.lang)||"",gender:(saved==null?void 0:saved.gender)||"",tags:(saved==null?void 0:saved.tags)||"",soul:(saved==null?void 0:saved.soul)||"",ignored:!!(saved!=null&&saved.ignored),hidden:!!(saved!=null&&saved.hidden),voiceData:saved!=null&&saved.voice&&saved.voice!=="me"?getVoiceData(saved.voice):null}}),renderCastList(),rehApplySharedCast(rec.title),refreshRehBackends().then(()=>{rec.backend&&$("reh-backend-select")&&($("reh-backend-select").value=rec.backend),rec.narratorVoice&&$("reh-narrator-voice")&&($("reh-narrator-voice").value=rec.narratorVoice)}),showPhase(2)}async function rehApplySharedCast(title){if(typeof castForProduction!="function"||!title)return;let roster;try{roster=await castForProduction(title)}catch{return}if(!roster)return;let changed=!1;Object.keys(rehState.cast||{}).forEach(sp=>{if(String(sp).includes("NARRATOR"))return;const shared=roster[String(sp).toLowerCase()];if(!shared)return;const slot=rehState.cast[sp];shared.voice&&!slot.voice&&(slot.voice=shared.voice,slot.voiceData=shared.voice!=="me"&&typeof getVoiceData=="function"?getVoiceData(shared.voice):null,changed=!0),shared.gender&&!slot.gender&&(slot.gender=shared.gender,changed=!0),shared.soul&&!slot.soul&&(slot.soul=shared.soul,changed=!0)}),changed&&renderCastList()}function bookCover(title){let h=0;for(let i=0;inew Date(b.updated||0)-new Date(a.updated||0)),list.classList.toggle("list-view",rehLibView==="list");const vt=$("reh-lib-view-toggle");if(vt&&(vt.innerHTML=``),!all.length){list.innerHTML='

No saved rehearsals yet.
Parse a script below or drop a file to start.

';return}list.innerHTML=all.map(rec=>{const speakers=Object.keys(rec.cast||{}),meCount=speakers.filter(sp=>{var _a2;return((_a2=rec.cast[sp])==null?void 0:_a2.voice)==="me"}).length,clipCount=(rec.clips||[]).filter(c=>c.type==="me"&&c.blob).length,total=parseScript(rec.script||"").filter(l=>l.type==="dialog").length,pct=total?Math.round((rec.lineIndex||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"\u2014",isCurrent=rec.id===rehState.savedId,title=rec.title||"Untitled",{c1,c2}=bookCover(title),avatars=speakers.slice(0,5).map(sp=>`${sp[0].toUpperCase()}`).join("");return`
+`).trim()}async function importFromFile(file){try{const text=await file.text(),data=JSON.parse(text);data.clips=clipsFromJson(data.clips||[]),data.created=data.created?new Date(data.created):new Date,data.updated=new Date,delete data.id;const newId=await rehDbAdd(data);toast("Imported: "+(data.title||"Untitled"),"success"),await renderLibraryList(),loadRecord({...data,id:newId})}catch(e){toast("Import failed: "+e.message,"error")}}function loadRecord(rec){typeof navTo=="function"&&navTo("s-rehearser"),$("reh-script-text")&&($("reh-script-text").value=rec.script||""),$("reh-script-title")&&($("reh-script-title").value=rec.title||""),rehState.savedId=rec.id||null,rehState._keepSavedIdOnce=!0,rehState.backend=rec.backend||"",rehState.lineIndex=rec.lineIndex||0,rehState.clips=(rec.clips||[]).map(c=>({...c})),rehState.synthCache.clear(),rehDecodedBuffers.clear(),rehState.narratorVoice=rec.narratorVoice||"";const lines=parseScript(rec.script||"");rec.emotions&&lines.forEach((l,i)=>{l.type==="dialog"&&rec.emotions[i]&&(l.emotion=rec.emotions[i])}),rec.notes&&lines.forEach((l,i)=>{l.type==="dialog"&&rec.notes[i]&&(l.note=rec.notes[i])}),rec.ignored&&lines.forEach((l,i)=>{rec.ignored[i]&&(l.ignored=!0)}),rec.hidden&&lines.forEach((l,i)=>{rec.hidden[i]&&(l.hidden=!0)}),rehState.lines=lines,rehState.cast={};const detected=detectCharacters(lines);Object.entries(detected).forEach(([sp,def])=>{var _a2,_b2,_c2;const saved=(_a2=rec.cast)==null?void 0:_a2[sp];rehState.cast[sp]={voice:(_b2=saved==null?void 0:saved.voice)!=null?_b2:def.voice,color:(_c2=saved==null?void 0:saved.color)!=null?_c2:def.color,instruct:(saved==null?void 0:saved.instruct)||"",lang:(saved==null?void 0:saved.lang)||"",gender:(saved==null?void 0:saved.gender)||"",tags:(saved==null?void 0:saved.tags)||"",soul:(saved==null?void 0:saved.soul)||"",ignored:!!(saved!=null&&saved.ignored),hidden:!!(saved!=null&&saved.hidden),voiceData:saved!=null&&saved.voice&&saved.voice!=="me"?getVoiceData(saved.voice):null}}),renderCastList(),rehApplySharedCast(rec.title),refreshRehBackends().then(()=>{rec.backend&&$("reh-backend-select")&&($("reh-backend-select").value=rec.backend),rec.narratorVoice&&$("reh-narrator-voice")&&($("reh-narrator-voice").value=rec.narratorVoice)}),showPhase(2)}async function rehApplySharedCast(title){if(typeof castForProduction!="function"||!title)return;let roster;try{roster=await castForProduction(title)}catch{return}if(!roster)return;let changed=!1;Object.keys(rehState.cast||{}).forEach(sp=>{const lookupName=sp===REH_NARRATOR_KEY?"narrator":String(sp).toLowerCase(),shared=roster[lookupName];if(!shared)return;const slot=rehState.cast[sp];shared.voice&&!slot.voice&&(slot.voice=shared.voice,slot.voiceData=shared.voice!=="me"&&typeof getVoiceData=="function"?getVoiceData(shared.voice):null,changed=!0,sp===REH_NARRATOR_KEY&&(rehState.narratorVoice=shared.voice)),shared.gender&&!slot.gender&&(slot.gender=shared.gender,changed=!0),shared.soul&&!slot.soul&&(slot.soul=shared.soul,changed=!0)}),changed&&renderCastList()}function bookCover(title){let h=0;for(let i=0;i{const at=new Date(a.updated||0).getTime(),bt=new Date(b.updated||0).getTime();return at!==bt?bt-at:(b.id||0)-(a.id||0)});for(const rec of ordered){const bucketKey=rehTitleKey(rec.title)||`__reh__${rec.id}`;byTitle.has(bucketKey)||byTitle.set(bucketKey,rec)}return[...byTitle.values()].sort((a,b)=>{const at=new Date(a.updated||0).getTime(),bt=new Date(b.updated||0).getTime();return at!==bt?bt-at:(b.id||0)-(a.id||0)})}async function renderLibraryList(){const list=$("reh-library-list");if(!list)return;let all;try{all=await rehDbGetAll()}catch{all=[]}all=rehUniqueLibraryRecords(all),list.classList.toggle("list-view",rehLibView==="list");const vt=$("reh-lib-view-toggle");if(vt&&(vt.innerHTML=``),!all.length){list.innerHTML='

No saved rehearsals yet.
Parse a script below or drop a file to start.

';return}list.innerHTML=all.map(rec=>{const speakers=Object.keys(rec.cast||{}),meCount=speakers.filter(sp=>{var _a2;return((_a2=rec.cast[sp])==null?void 0:_a2.voice)==="me"}).length,clipCount=(rec.clips||[]).filter(c=>c.type==="me"&&c.blob).length,total=parseScript(rec.script||"").filter(l=>l.type==="dialog").length,pct=total?Math.round((rec.lineIndex||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"\u2014",isCurrent=rec.id===rehState.savedId,title=rec.title||"Untitled",{c1,c2}=bookCover(title),avatars=speakers.slice(0,5).map(sp=>`${sp[0].toUpperCase()}`).join("");return`
${isCurrent?'active':""}
@@ -933,7 +933,7 @@ This warms each voice so the engine caches its .pt and first playback is instant
- `,confirmOverlay.addEventListener("click",ce=>ce.stopPropagation()),confirmOverlay.querySelector("#btn-cancel-del").addEventListener("click",ce=>{ce.stopPropagation(),confirmOverlay.remove()}),confirmOverlay.querySelector("#btn-confirm-del").addEventListener("click",async ce=>{ce.stopPropagation(),await rehDbDelete(parseInt(btn.dataset.id)),rehState.savedId===parseInt(btn.dataset.id)&&(rehState.savedId=null),renderLibraryList()}),bookEl.appendChild(confirmOverlay)}))}(_ma=$("reh-lib-view-toggle"))==null||_ma.addEventListener("click",()=>{rehLibView=rehLibView==="shelf"?"list":"shelf",localStorage.setItem("reh-lib-view",rehLibView),renderLibraryList()});function getVoiceData(voiceId){return(window._voices||[]).find(v=>v.id===voiceId)||null}function _rehVoiceVisibleId(voiceId,include=""){if(!voiceId)return!1;if(include&&voiceId===include)return!0;const v=getVoiceData(voiceId);return!v||v.enabled!==!1}function voiceAvatarHtml(voiceId,color,size=32){const v=getVoiceData(voiceId),s=size+"px",radius=Math.round(size/2);if(v!=null&&v.has_picture)return`${escHtml(voiceId)}`;const ic=v!=null&&v.avatar&&window.VOICE_AVATAR_ICONS?window.VOICE_AVATAR_ICONS[v.avatar]:null;if(ic)return``;const initial=(voiceId||"?")[0].toUpperCase();return`${initial}`}function showPhase(n){const impex=$("reh-impex-panel");impex&&(impex.hidden=!0);for(let i=1;i<=4;i++){const el=$("reh-phase-"+i);el&&(el.hidden=i!==n)}syncPhaseTabs(n),typeof window.onRehearserPhaseChange=="function"&&window.onRehearserPhaseChange(n)}window.showRehImpEx=function(){var _a2;stopPlay(),stopRehMic();for(let i=1;i<=4;i++){const el=$("reh-phase-"+i);el&&(el.hidden=!0)}const panel=$("reh-impex-panel");panel&&(panel.hidden=!1);const note=$("reh-impex-export-note");note&&(note.textContent=rehState.lines.length?`Current session: "${((_a2=$("reh-script-title"))==null?void 0:_a2.value)||"Untitled"}" \xB7 ${rehState.lines.filter(l=>l.type==="dialog").length} lines`:"No active session \u2014 load a script first to export.")};function syncPhaseTabs(n){const hasScript=rehState.lines.length>0,hasClips=rehState.clips.length>0;document.querySelectorAll(".reh-subtab").forEach(tab=>{const p=parseInt(tab.dataset.phase);tab.classList.toggle("active",p===n);let enabled=p===1||p===2&&hasScript||p===3&&hasScript||p===4&&(hasClips||n===4);tab.disabled=!enabled})}document.querySelectorAll(".reh-subtab").forEach(tab=>{tab.addEventListener("click",()=>{if(tab.disabled)return;const p=parseInt(tab.dataset.phase);if(p!==3&&(stopPlay(),stopRehMic(),hideRecOverlay()),p===1&&renderLibraryList(),p===3&&rehState.lines.length){buildScriptPage(),showPhase(3),highlightCurrentLine();return}p===4&&renderSummary(),showPhase(p)})}),(_na=$("reh-file-input"))==null||_na.addEventListener("change",function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];if(!f)return;const guess=f.name.replace(/\.[^.]+$/,"").replace(/[-_]+/g," ").trim(),r=new FileReader;r.onload=async e=>{$("reh-script-text").value=e.target.result,await rehAutoSaveImport(guess),toast("Script loaded & saved to your library \u2014 click Parse & cast","success")},r.readAsText(f),this.value=""}),(_oa=$("reh-pdf-input"))==null||_oa.addEventListener("change",async function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];if(f){this.value="";try{const text=await importPDFScript(f);$("reh-script-text")&&($("reh-script-text").value=text);const guess=f.name.replace(/\.pdf$/i,"").replace(/[-_]+/g," ").trim();await rehAutoSaveImport(guess),toast("PDF imported & saved to your library \u2014 check formatting, then click Parse & cast","success")}catch(e){toast("PDF import failed: "+e.message,"error")}}}),(_pa=$("reh-fdx-input"))==null||_pa.addEventListener("change",async function(){var _a2,_b2;const f=(_a2=this.files)==null?void 0:_a2[0];if(f){this.value="";try{const text=await importFDX(f);if($("reh-script-text")&&($("reh-script-text").value=text),!((_b2=$("reh-script-title"))!=null&&_b2.value.trim())){const guess=f.name.replace(/\.fdx$/i,"").replace(/[-_]+/g," ").trim();$("reh-script-title")&&($("reh-script-title").value=guess)}toast("FDX imported \u2014 click Parse & cast","success")}catch(e){toast("FDX import failed: "+e.message,"error")}}});async function rehImportAnyFile(f){if(!f)return;const name=f.name.toLowerCase(),setTitle=strip=>{var _a2;if(!((_a2=$("reh-script-title"))!=null&&_a2.value.trim())){const guess=f.name.replace(strip,"").replace(/[-_]+/g," ").trim();$("reh-script-title")&&($("reh-script-title").value=guess)}};try{if(name.endsWith(".reh")||name.endsWith(".json")){await importFromFile(f);return}if(name.endsWith(".pdf")){toast("Reading PDF\u2026","success");const text2=await importPDFScript(f);$("reh-script-text")&&($("reh-script-text").value=text2),setTitle(/\.pdf$/i),await rehAutoSaveImport(),toast("PDF imported & saved to your library \u2014 check formatting, then Parse & cast","success");return}if(name.endsWith(".fdx")||name.endsWith(".osf")||name.endsWith(".xml")){const text2=await importFDX(f);$("reh-script-text")&&($("reh-script-text").value=text2),setTitle(/\.(fdx|osf|xml)$/i),await rehAutoSaveImport(),toast("Script imported & saved to your library \u2014 click Parse & cast","success");return}const text=await f.text();$("reh-script-text")&&($("reh-script-text").value=text),setTitle(/\.(txt|md|fountain)$/i),await rehAutoSaveImport(),toast("Script loaded & saved to your library \u2014 click Parse & cast","success")}catch(e){toast("Import failed: "+e.message,"error")}}(function(){const dz=$("reh-dropzone"),phase=$("reh-phase-1");if(!dz||!phase)return;let depth=0;const isFileDrag=e=>e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files");phase.addEventListener("dragenter",e=>{isFileDrag(e)&&(e.preventDefault(),depth++,dz.classList.add("dragover"),dz.scrollIntoView({behavior:"smooth",block:"nearest"}))}),phase.addEventListener("dragover",e=>{isFileDrag(e)&&e.preventDefault()}),phase.addEventListener("dragleave",e=>{isFileDrag(e)&&(depth=Math.max(0,depth-1),depth===0&&dz.classList.remove("dragover"))}),phase.addEventListener("drop",async e=>{var _a2;if(!isFileDrag(e))return;e.preventDefault(),depth=0,dz.classList.remove("dragover");const f=(_a2=e.dataTransfer.files)==null?void 0:_a2[0];await rehImportAnyFile(f)})})(),(_qa=$("reh-parse-btn"))==null||_qa.addEventListener("click",()=>{var _a2;const text=(_a2=$("reh-script-text"))==null?void 0:_a2.value.trim();if(!text){toast("Paste or upload a script first","error");return}if(rehState.lines=parseScript(text),!rehState.lines.filter(l=>l.type==="dialog").length){toast("No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.","error");return}const detected=detectCharacters(rehState.lines);Object.entries(detected).forEach(([sp])=>{rehState.cast[sp]&&(detected[sp]={...detected[sp],...rehState.cast[sp]})}),rehState.cast=detected,rehState._keepSavedIdOnce?rehState._keepSavedIdOnce=!1:rehState.savedId=null,rehState.clips=[],rehState.lineIndex=0,rehState.synthCache.clear(),rehDecodedBuffers.clear(),renderCastList(),showPhase(2),refreshRehBackends()});function castAvatarHtml(sp){const c=rehState.cast[sp];if(!c)return"";if(c.voice&&c.voice!=="me"){const vd=getVoiceData(c.voice);if(vd!=null&&vd.has_picture)return``;const ic=vd!=null&&vd.avatar&&window.VOICE_AVATAR_ICONS?window.VOICE_AVATAR_ICONS[vd.avatar]:null;if(ic)return``}const initial=sp[0].toUpperCase();return`${initial}`}let _rehAudEl=null,_rehAudBtn=null;function _rehStopAudition(){if(_rehAudEl)try{_rehAudEl.pause()}catch{}if(_rehAudBtn){_rehAudBtn.classList.remove("playing");const i=_rehAudBtn.querySelector(".mdi");i&&(i.className="mdi mdi-play")}_rehAudBtn=null}function _rehAudition(url,btn){if(!url){toast("No preview audio for this voice","error");return}if(_rehAudBtn===btn&&_rehAudEl&&!_rehAudEl.paused){_rehStopAudition();return}_rehStopAudition(),_rehAudEl||(_rehAudEl=new Audio,_rehAudEl.addEventListener("ended",_rehStopAudition)),_rehAudEl.src=url,_rehAudEl.play().then(()=>{_rehAudBtn=btn,btn.classList.add("playing");const i=btn.querySelector(".mdi");i&&(i.className="mdi mdi-stop")}).catch(()=>toast("Could not play preview","error"))}const _REH_PREVIEW_TEXT="Hello \u2014 this is how this voice sounds.",_rehSearchCache={};function _rehVoiceRow({name,meta,playUrl,playVoice,isCurrent,useAttrs}){const play=``,right=isCurrent?' Selected':``;return`
${play} + `,confirmOverlay.addEventListener("click",ce=>ce.stopPropagation()),confirmOverlay.querySelector("#btn-cancel-del").addEventListener("click",ce=>{ce.stopPropagation(),confirmOverlay.remove()}),confirmOverlay.querySelector("#btn-confirm-del").addEventListener("click",async ce=>{ce.stopPropagation(),await rehDbDelete(parseInt(btn.dataset.id)),rehState.savedId===parseInt(btn.dataset.id)&&(rehState.savedId=null),renderLibraryList()}),bookEl.appendChild(confirmOverlay)}))}(_ma=$("reh-lib-view-toggle"))==null||_ma.addEventListener("click",()=>{rehLibView=rehLibView==="shelf"?"list":"shelf",localStorage.setItem("reh-lib-view",rehLibView),renderLibraryList()});function getVoiceData(voiceId){return(window._voices||[]).find(v=>v.id===voiceId)||null}function _rehVoiceVisibleId(voiceId,include=""){if(!voiceId)return!1;if(include&&voiceId===include)return!0;const v=getVoiceData(voiceId);return!v||v.enabled!==!1}function voiceAvatarHtml(voiceId,color,size=32){const v=getVoiceData(voiceId),s=size+"px",radius=Math.round(size/2);if(v!=null&&v.has_picture)return`${escHtml(voiceId)}`;const ic=v!=null&&v.avatar&&window.VOICE_AVATAR_ICONS?window.VOICE_AVATAR_ICONS[v.avatar]:null;if(ic)return``;const initial=(voiceId||"?")[0].toUpperCase();return`${initial}`}function _rehCharAvatarHtml(speaker,voiceId,color,size=32){const rec=(_rehLibCharsCache||[]).find(r=>String(r.name||"").trim().toLowerCase()===String(speaker||"").trim().toLowerCase());if(rec&&rec.image){const s=size+"px",radius=Math.round(size/2);return`${escHtml(speaker)}`}return voiceAvatarHtml(voiceId,color,size)}function showPhase(n){const impex=$("reh-impex-panel");impex&&(impex.hidden=!0);for(let i=1;i<=4;i++){const el=$("reh-phase-"+i);el&&(el.hidden=i!==n)}syncPhaseTabs(n),typeof window.onRehearserPhaseChange=="function"&&window.onRehearserPhaseChange(n)}window.showRehImpEx=function(){var _a2;stopPlay(),stopRehMic();for(let i=1;i<=4;i++){const el=$("reh-phase-"+i);el&&(el.hidden=!0)}const panel=$("reh-impex-panel");panel&&(panel.hidden=!1);const note=$("reh-impex-export-note");note&&(note.textContent=rehState.lines.length?`Current session: "${((_a2=$("reh-script-title"))==null?void 0:_a2.value)||"Untitled"}" \xB7 ${rehState.lines.filter(l=>l.type==="dialog").length} lines`:"No active session \u2014 load a script first to export.")};function syncPhaseTabs(n){const hasScript=rehState.lines.length>0,hasClips=rehState.clips.length>0;document.querySelectorAll(".reh-subtab").forEach(tab=>{const p=parseInt(tab.dataset.phase);tab.classList.toggle("active",p===n);let enabled=p===1||p===2&&hasScript||p===3&&hasScript||p===4&&(hasClips||n===4);tab.disabled=!enabled})}document.querySelectorAll(".reh-subtab").forEach(tab=>{tab.addEventListener("click",()=>{if(tab.disabled)return;const p=parseInt(tab.dataset.phase);if(p!==3&&(stopPlay(),stopRehMic(),hideRecOverlay()),p===1&&renderLibraryList(),p===3&&rehState.lines.length){buildScriptPage(),showPhase(3),highlightCurrentLine();return}p===4&&renderSummary(),showPhase(p)})}),(_na=$("reh-file-input"))==null||_na.addEventListener("change",function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];if(!f)return;const guess=f.name.replace(/\.[^.]+$/,"").replace(/[-_]+/g," ").trim(),r=new FileReader;r.onload=async e=>{$("reh-script-text").value=e.target.result,await rehAutoSaveImport(guess),toast("Script loaded & saved to your library \u2014 click Parse & cast","success")},r.readAsText(f),this.value=""}),(_oa=$("reh-pdf-input"))==null||_oa.addEventListener("change",async function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];if(f){this.value="";try{const text=await importPDFScript(f);$("reh-script-text")&&($("reh-script-text").value=text);const guess=f.name.replace(/\.pdf$/i,"").replace(/[-_]+/g," ").trim();await rehAutoSaveImport(guess),toast("PDF imported & saved to your library \u2014 check formatting, then click Parse & cast","success")}catch(e){toast("PDF import failed: "+e.message,"error")}}}),(_pa=$("reh-fdx-input"))==null||_pa.addEventListener("change",async function(){var _a2,_b2;const f=(_a2=this.files)==null?void 0:_a2[0];if(f){this.value="";try{const text=await importFDX(f);if($("reh-script-text")&&($("reh-script-text").value=text),!((_b2=$("reh-script-title"))!=null&&_b2.value.trim())){const guess=f.name.replace(/\.fdx$/i,"").replace(/[-_]+/g," ").trim();$("reh-script-title")&&($("reh-script-title").value=guess)}toast("FDX imported \u2014 click Parse & cast","success")}catch(e){toast("FDX import failed: "+e.message,"error")}}});async function rehImportAnyFile(f){if(!f)return;const name=f.name.toLowerCase(),setTitle=strip=>{var _a2;if(!((_a2=$("reh-script-title"))!=null&&_a2.value.trim())){const guess=f.name.replace(strip,"").replace(/[-_]+/g," ").trim();$("reh-script-title")&&($("reh-script-title").value=guess)}};try{if(name.endsWith(".reh")||name.endsWith(".json")){await importFromFile(f);return}if(name.endsWith(".pdf")){toast("Reading PDF\u2026","success");const text2=await importPDFScript(f);$("reh-script-text")&&($("reh-script-text").value=text2),setTitle(/\.pdf$/i),await rehAutoSaveImport(),toast("PDF imported & saved to your library \u2014 check formatting, then Parse & cast","success");return}if(name.endsWith(".fdx")||name.endsWith(".osf")||name.endsWith(".xml")){const text2=await importFDX(f);$("reh-script-text")&&($("reh-script-text").value=text2),setTitle(/\.(fdx|osf|xml)$/i),await rehAutoSaveImport(),toast("Script imported & saved to your library \u2014 click Parse & cast","success");return}const text=await f.text();$("reh-script-text")&&($("reh-script-text").value=text),setTitle(/\.(txt|md|fountain)$/i),await rehAutoSaveImport(),toast("Script loaded & saved to your library \u2014 click Parse & cast","success")}catch(e){toast("Import failed: "+e.message,"error")}}(function(){const dz=$("reh-dropzone"),phase=$("reh-phase-1");if(!dz||!phase)return;let depth=0;const isFileDrag=e=>e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files");phase.addEventListener("dragenter",e=>{isFileDrag(e)&&(e.preventDefault(),depth++,dz.classList.add("dragover"),dz.scrollIntoView({behavior:"smooth",block:"nearest"}))}),phase.addEventListener("dragover",e=>{isFileDrag(e)&&e.preventDefault()}),phase.addEventListener("dragleave",e=>{isFileDrag(e)&&(depth=Math.max(0,depth-1),depth===0&&dz.classList.remove("dragover"))}),phase.addEventListener("drop",async e=>{var _a2;if(!isFileDrag(e))return;e.preventDefault(),depth=0,dz.classList.remove("dragover");const f=(_a2=e.dataTransfer.files)==null?void 0:_a2[0];await rehImportAnyFile(f)})})(),(_qa=$("reh-parse-btn"))==null||_qa.addEventListener("click",()=>{var _a2;const text=(_a2=$("reh-script-text"))==null?void 0:_a2.value.trim();if(!text){toast("Paste or upload a script first","error");return}if(rehState.lines=parseScript(text),!rehState.lines.filter(l=>l.type==="dialog").length){toast("No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.","error");return}const detected=detectCharacters(rehState.lines);Object.entries(detected).forEach(([sp])=>{rehState.cast[sp]&&(detected[sp]={...detected[sp],...rehState.cast[sp]})}),rehState.cast=detected,rehState._keepSavedIdOnce?rehState._keepSavedIdOnce=!1:rehState.savedId=null,rehState.clips=[],rehState.lineIndex=0,rehState.synthCache.clear(),rehDecodedBuffers.clear(),renderCastList(),showPhase(2),refreshRehBackends()});function castAvatarHtml(sp){const c=rehState.cast[sp];if(!c)return"";if(c.voice&&c.voice!=="me"){const vd=getVoiceData(c.voice);if(vd!=null&&vd.has_picture)return``;const ic=vd!=null&&vd.avatar&&window.VOICE_AVATAR_ICONS?window.VOICE_AVATAR_ICONS[vd.avatar]:null;if(ic)return``}const initial=sp[0].toUpperCase();return`${initial}`}let _rehAudEl=null,_rehAudBtn=null;function _rehStopAudition(){if(_rehAudEl)try{_rehAudEl.pause()}catch{}if(_rehAudBtn){_rehAudBtn.classList.remove("playing");const i=_rehAudBtn.querySelector(".mdi");i&&(i.className="mdi mdi-play")}_rehAudBtn=null}function _rehAudition(url,btn){if(!url){toast("No preview audio for this voice","error");return}if(_rehAudBtn===btn&&_rehAudEl&&!_rehAudEl.paused){_rehStopAudition();return}_rehStopAudition(),_rehAudEl||(_rehAudEl=new Audio,_rehAudEl.addEventListener("ended",_rehStopAudition)),_rehAudEl.src=url,_rehAudEl.play().then(()=>{_rehAudBtn=btn,btn.classList.add("playing");const i=btn.querySelector(".mdi");i&&(i.className="mdi mdi-stop")}).catch(()=>toast("Could not play preview","error"))}const _REH_PREVIEW_TEXT="Hello \u2014 this is how this voice sounds.",_rehSearchCache={};function _rehVoiceRow({name,meta,playUrl,playVoice,isCurrent,useAttrs}){const play=``,right=isCurrent?' Selected':``;return`
${play}
${escHtml(name)}${escHtml(meta)}
${right}
`}function castOnlinePanelHtml(sp,c){const o=c.online;if(!o||!Array.isArray(o.candidates)||c.voice==="me")return"";const curCand=o.candidates.find(x=>x.voice_id&&x.voice_id===c.voice);let curRow;if(curCand)curRow=_rehVoiceRow({name:curCand.title||c.voice,meta:[curCand.gender,curCand.language].filter(Boolean).join(" \xB7 ")||"fish.audio",playUrl:curCand.sample_audio,isCurrent:!0});else if(c.voice){const vd=getVoiceData(c.voice);curRow=_rehVoiceRow({name:(vd==null?void 0:vd.name)||c.voice,meta:(vd==null?void 0:vd.group)||(vd==null?void 0:vd.tag)||"library voice",playVoice:c.voice,isCurrent:!0})}else curRow='
No voice assignedpick one below
';const alts=o.candidates.map((cand,i)=>({cand,i})).filter(({cand})=>cand.voice_id!==c.voice||!cand.voice_id),altRows=alts.map(({cand,i})=>_rehVoiceRow({name:cand.title||"Voice",meta:[cand.gender,cand.language].filter(Boolean).join(" \xB7 ")||"fish.audio",playUrl:cand.sample_audio,useAttrs:` data-act="alt" data-idx="${i}"`})).join("");return`
Voice \u2014 try & change @@ -952,46 +952,53 @@ This warms each voice so the engine caches its .pt and first playback is instant
Type a query and hit Search to browse fish.audio voices.
-
`}async function _rehPreviewLocal(voiceId,btn){var _a2;const backend=(_a2=$("reh-backend-select"))==null?void 0:_a2.value;if(!backend){toast("Select a TTS backend first","error");return}if(_rehAudBtn===btn&&_rehAudEl&&!_rehAudEl.paused){_rehStopAudition();return}_rehStopAudition();const icon=btn.querySelector(".mdi");btn.classList.add("loading"),icon&&(icon.className="mdi mdi-loading");try{const blob=await fetchTtsPreviewBlob(voiceId,_REH_PREVIEW_TEXT,"wav","",backend);_rehAudEl||(_rehAudEl=new Audio,_rehAudEl.addEventListener("ended",_rehStopAudition)),_rehAudEl.src=URL.createObjectURL(blob),await _rehAudEl.play(),btn.classList.remove("loading"),_rehAudBtn=btn,btn.classList.add("playing"),icon&&(icon.className="mdi mdi-stop")}catch(e){btn.classList.remove("loading"),icon&&(icon.className="mdi mdi-play"),toast("Preview failed: "+(e.message||e),"error")}}function _rehPickOneLiner(sp){const isNarr=sp===REH_NARRATOR_KEY,pool=rehState.lines.filter(l=>isNarr?l.type!=="dialog"&&l.type!=="pagebreak"&&!l.ignored:l.type==="dialog"&&l.speaker===sp).map(l=>({text:stripMarkdown(l.text||"").trim(),emotion:l.emotion||""})).filter(l=>l.text.length>=4);if(!pool.length)return null;const best=pool.map(l=>{const len=l.text.length;let score=len>=20&&len<=110?3:len<20?1:0;return/[.!?]["']?$/.test(l.text)&&(score+=1),score-=Math.abs(len-60)/120,{l,score}}).sort((a,b)=>b.score-a.score)[0].l;return{text:best.text.slice(0,220),emotion:best.emotion}}async function _rehPreviewCastLine(sp,btn){var _a2;const c=rehState.cast[sp];if(!c||!c.voice||c.voice==="me"){toast("Assign a voice first","error");return}const backend=(_a2=$("reh-backend-select"))==null?void 0:_a2.value;if(!backend){toast("Select a TTS backend first","error");return}if(_rehAudBtn===btn&&_rehAudEl&&!_rehAudEl.paused){_rehStopAudition();return}_rehStopAudition();const pick=_rehPickOneLiner(sp);if(!pick){toast("No lines to preview yet","error");return}const icon=btn.querySelector(".mdi");btn.classList.add("loading"),icon&&(icon.className="mdi mdi-loading");try{const blob=await fetchTtsPreviewBlob(c.voice,_rehInlineTone(pick.text,pick.emotion),"wav",_buildInstruct(c.instruct,pick.emotion),backend);_rehAudEl||(_rehAudEl=new Audio,_rehAudEl.addEventListener("ended",_rehStopAudition)),_rehAudEl.src=URL.createObjectURL(blob),await _rehAudEl.play(),btn.classList.remove("loading"),_rehAudBtn=btn,btn.classList.add("playing"),icon&&(icon.className="mdi mdi-stop")}catch(e){btn.classList.remove("loading"),icon&&(icon.className="mdi mdi-play"),toast("Preview failed: "+(e.message||e),"error")}}function _rehAssignLocal(sp,voiceId){const c=rehState.cast[sp];c&&(_rehStopAudition(),c.voice=voiceId,c.voiceData=getVoiceData(voiceId),c.online&&(c.online.picked=c.online.candidates.findIndex(x=>x.voice_id===voiceId)),renderCastList(),typeof populateNarratorSelect=="function"&&populateNarratorSelect())}function _rehRenderLocalResults(card,sp,q){var _a2;const box=card.querySelector(".reh-vo-local-results");if(!box)return;const cur=(_a2=rehState.cast[sp])==null?void 0:_a2.voice,ql=(q||"").trim().toLowerCase(),matches=rehState.voices.filter(v=>{if(!_rehVoiceVisibleId(v,cur)||v===cur)return!1;if(!ql)return!0;const vd=getVoiceData(v);return v.toLowerCase().includes(ql)||((vd==null?void 0:vd.name)||"").toLowerCase().includes(ql)||((vd==null?void 0:vd.group)||"").toLowerCase().includes(ql)}).slice(0,40);if(!matches.length){box.innerHTML='
No library voices match.
';return}box.innerHTML=matches.map(v=>{const vd=getVoiceData(v);return _rehVoiceRow({name:(vd==null?void 0:vd.name)||v,meta:(vd==null?void 0:vd.group)||(vd==null?void 0:vd.tag)||"library voice",playVoice:v,useAttrs:` data-act="local" data-voice="${escHtml(v)}"`})}).join("")}async function _rehSearchOnline(card,sp,q){var _a2,_b2;const box=card.querySelector(".reh-vo-search-results");if(!box)return;if(q=(q||"").trim(),!q){box.innerHTML='
Enter a search term first.
';return}box.innerHTML='
Searching fish.audio\u2026
';const lang=(_b2=REH_FISH_LANG[((_a2=$("reh-design-lang"))==null?void 0:_a2.value)||"English"])!=null?_b2:"en";let items=[];try{const qs=[`search=${encodeURIComponent(q)}`,`language=${lang}`,"page_size=10","sort_by=score"];items=(await fetch("/api/fishaudio/voices?"+qs.join("&")).then(r=>r.json())).items||[]}catch{}const cands=items.filter(v=>v.sample_audio).slice(0,10).map(v=>({title:v.title,sample_audio:v.sample_audio,image:v.image||"",gender:v.gender||"",language:v.language||lang||"",description:v.description||"",sample_text:v.sample_text||v.default_text||""}));if(_rehSearchCache[sp]=cands,!cands.length){box.innerHTML=`
No playable voices found for \u201C${escHtml(q)}\u201D.
`;return}box.innerHTML=cands.map((cand,i)=>_rehVoiceRow({name:cand.title||"Voice",meta:[cand.gender,cand.language].filter(Boolean).join(" \xB7 ")||"fish.audio",playUrl:cand.sample_audio,useAttrs:` data-act="search" data-idx="${i}"`})).join("")}async function _rehUseSearchResult(sp,idx,btn){const c=rehState.cast[sp];if(!c)return;const cand=(_rehSearchCache[sp]||[])[idx];if(!cand)return;_rehStopAudition();const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';try{const vid=await _rehImportFishCandidate(sp,cand);cand.voice_id=vid,c.voice=vid,c.voiceData=getVoiceData(vid),c.online||(c.online={candidates:[],picked:0}),c.online.candidates.push(cand),c.online.picked=c.online.candidates.length-1,typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{}),renderCastList(),typeof populateNarratorSelect=="function"&&populateNarratorSelect(),toast(`Switched ${sp} to ${cand.title||"voice"}`,"success")}catch(err){btn.disabled=!1,btn.innerHTML=orig,toast("Import failed: "+err.message,"error")}}const _rehFishImported={};async function _rehImportFishCandidate(sp,cand){const key=(cand.sample_audio||cand.title||"").toLowerCase();if(key&&_rehFishImported[key]){const id=_rehFishImported[key];return rehState.voices.includes(id)||rehState.voices.push(id),id}const title=(cand.title||"").toLowerCase().trim(),existing=title&&(window._voices||[]).find(v=>(v.group==="fish-audio"||v.tag==="fish-audio")&&(v.name||"").toLowerCase().trim()===title);if(existing)return key&&(_rehFishImported[key]=existing.id),rehState.voices.includes(existing.id)||rehState.voices.push(existing.id),existing.id;const vid=`${(cand.language||"EN").slice(0,2).toUpperCase()}_${(cand.title||sp).replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,36)||"Voice"}`,d=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:vid,audio_url:cand.sample_audio,transcript:cand.sample_text||""})}).then(r=>r.json());if(!d.voice_id)throw new Error("import returned no id");return typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:cand.title||sp,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(cand.gender||"").charAt(0).toUpperCase(),note:(cand.description||"").slice(0,180)}).catch(e=>logErr("fish saveMeta",e)),cand.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:cand.image})}).catch(e=>logErr("fish picture",e)),rehState.voices.includes(d.voice_id)||rehState.voices.push(d.voice_id),key&&(_rehFishImported[key]=d.voice_id),d.voice_id}async function _rehUseCandidate(sp,idx,btn){const c=rehState.cast[sp],o=c&&c.online;if(!o)return;const cand=o.candidates[idx];if(!cand)return;if(_rehStopAudition(),cand.voice_id){c.voice=cand.voice_id,c.voiceData=getVoiceData(cand.voice_id),o.picked=idx,renderCastList(),typeof populateNarratorSelect=="function"&&populateNarratorSelect();return}const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';try{const vid=await _rehImportFishCandidate(sp,cand);cand.voice_id=vid,c.voice=vid,c.voiceData=getVoiceData(vid),o.picked=idx,typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{}),renderCastList(),typeof populateNarratorSelect=="function"&&populateNarratorSelect(),toast(`Switched ${sp} to ${cand.title||"voice"}`,"success")}catch(err){btn.disabled=!1,btn.innerHTML=orig,toast("Import failed: "+err.message,"error")}}const REH_NARRATOR_KEY="\u{1F4D6}NARRATOR";function _ensureNarrator(){rehState.cast[REH_NARRATOR_KEY]||(rehState.cast[REH_NARRATOR_KEY]={voice:rehState.narratorVoice||"",color:"#6b7280",instruct:"",voiceData:rehState.narratorVoice?getVoiceData(rehState.narratorVoice):null,_isNarrator:!0});const n=rehState.cast[REH_NARRATOR_KEY];n.voice?(rehState.narratorVoice=n.voice,n.voiceData||(n.voiceData=getVoiceData(n.voice))):rehState.narratorVoice&&(n.voice=rehState.narratorVoice,n.voiceData=getVoiceData(rehState.narratorVoice))}const REH_CAST_LANGS=["English","German","Auto","French","Spanish","Italian","Portuguese","Dutch","Polish"],REH_CAST_GENDERS=[["","\u2014"],["F","\u2640 Female"],["M","\u2642 Male"],["N","\u26A5 Diverse"]];function _castApplyToLines(sp,fn){rehState.lines.forEach((l,i)=>{l.speaker===sp&&l.type==="dialog"&&fn(l,i)})}function _safeDomId(value){let out="";const s=String(value||"voice");for(let i=0;irenderCastList()).catch(()=>{})),_ensureNarrator();const narr=REH_NARRATOR_KEY,lineCount=sp=>rehState.lines.filter(l=>l.speaker===sp&&l.type==="dialog").length,allOthers=Object.keys(rehState.cast).filter(s=>s!==narr),others=_castSortFilter(allOthers,lineCount),speakers=[narr,...others],scriptTitle=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"";list.innerHTML=speakers.map(sp=>{var _a3;const c=rehState.cast[sp],isNarr=sp===narr,isMe=c.voice==="me",label=isNarr?"Narrator":sp,n=lineCount(sp),sub=isNarr?"scene headings & descriptions":`${n} line${n!==1?"s":""}${scriptTitle?` \xB7 ${escHtml(scriptTitle)}`:""}`,cardCls="reh-cast-card"+(isNarr?" reh-cast-narrator":"")+(c.ignored?" reh-cast-ignored":"")+(c.hidden?" reh-cast-hidden-c":""),langSel=REH_CAST_LANGS.map(l=>`${l}`).join(""),genSel=REH_CAST_GENDERS.map(([v,t])=>``).join(""),voiceSel=` ${_rehAllVoiceIds(c.voice).map(v=>``).join("")} - `,voiceName=!isMe&&c.voice?((_a3=getVoiceData(c.voice))==null?void 0:_a3.name)||c.voice:isMe?"Your mic":"";return`
+ `,voiceName=!isMe&&c.voice?((_a3=getVoiceData(c.voice))==null?void 0:_a3.name)||c.voice:isMe?"Your mic":"",libRec=isNarr?null:libRecByName.get(String(sp).trim().toLowerCase());if(libRec){const libVoice=typeof libRec.voice=="object"?((_b2=libRec.voice)==null?void 0:_b2.id)||"":libRec.voice||"";libVoice&&(c.voice=libVoice)}const emotions=isNarr?[]:emotionsFor(sp),emotionsHtml=emotions.length?`
${emotions.map(info=>`${info.emoji} ${escHtml(info.label)}`).join("")}
`:"",controlsHtml=`
+ + ${!isMe&&c.voice?``:""} + + + + +
+ ${emotionsHtml}`;return libRec?`
+ ${typeof _charCardHtml=="function"?_charCardHtml(libRec,_rehLibCharsCache||[]):""} +
${controlsHtml}
+
`:`
${castAvatarHtml(sp)}
${isNarr?' ':""}${escHtml(label)}
${sub}
${escHtml(voiceName||"No voice assigned")}
- ${!isMe&&c.voice?``:""}
- ${isNarr?"":`
- - - - - -
`} -
`}).join("");const countEl=$("reh-cast-count");if(countEl){const shown=others.length,total=allOthers.length;countEl.textContent=shown===total?`${total} ${total===1?"character":"characters"} + narrator`:`${shown} of ${total} characters`}_wireCastControls(),applyCastView();const card=el=>el.closest(".reh-cast-card"),spOf=el=>card(el).dataset.speaker;window.VoicePicker&&list.querySelectorAll(".reh-voice-sel[id]").forEach(sel=>{const cur=sel.value;VoicePicker.upgrade(sel.id),cur&&VoicePicker.setValue(sel.id,cur)}),list.querySelectorAll(".reh-me-check").forEach(cb=>cb.addEventListener("change",function(){var _a3;const sp=spOf(this);rehState.cast[sp].voice=this.checked?"me":((_a3=card(this).querySelector(".reh-voice-sel"))==null?void 0:_a3.value)||"",rehState.cast[sp].voiceData=this.checked?null:getVoiceData(rehState.cast[sp].voice),renderCastList()})),list.querySelectorAll(".reh-voice-sel").forEach(sel=>sel.addEventListener("change",function(){const sp=this.dataset.speaker;rehState.cast[sp].voice=this.value,rehState.cast[sp].voiceData=getVoiceData(this.value),delete rehState.cast[sp].online,sp===REH_NARRATOR_KEY&&(rehState.narratorVoice=this.value),renderCastList()})),list.querySelectorAll(".reh-cast-instruct").forEach(inp=>inp.addEventListener("input",function(){rehState.cast[spOf(this)].instruct=this.value})),list.querySelectorAll(".reh-cc-lang").forEach(s=>s.addEventListener("change",function(){rehState.cast[spOf(this)].lang=this.value})),list.querySelectorAll(".reh-cc-gender").forEach(s=>s.addEventListener("change",function(){rehState.cast[spOf(this)].gender=this.value})),list.querySelectorAll(".reh-cc-tags").forEach(i=>i.addEventListener("input",function(){rehState.cast[spOf(this)].tags=this.value})),list.querySelectorAll(".reh-cc-soul-text").forEach(t=>t.addEventListener("input",function(){rehState.cast[spOf(this)].soul=this.value})),list.querySelectorAll(".reh-cc-develop").forEach(b=>b.addEventListener("click",function(e){e.preventDefault(),_castDevelop(spOf(this),this)})),list.querySelectorAll(".reh-cc-iconbtn").forEach(b=>b.addEventListener("click",function(){const sp=spOf(this),act=this.dataset.act,c=rehState.cast[sp];act==="ignore"?(c.ignored=!c.ignored,_castApplyToLines(sp,l=>l.ignored=c.ignored),renderCastList()):act==="hide"?(c.hidden=!c.hidden,_castApplyToLines(sp,l=>l.hidden=c.hidden),renderCastList()):act==="delete"&&_castDeleteCharacter(sp)})),list._voDelegated||(list._voDelegated=!0,list.addEventListener("click",e=>{var _a3,_b2,_c2,_d2,_e2,_f2;const sampleBtn=e.target.closest(".reh-cc-sample-btn");if(sampleBtn){e.preventDefault(),_rehPreviewCastLine(sampleBtn.dataset.speaker,sampleBtn);return}const playBtn=e.target.closest(".reh-vo-play");if(playBtn){e.preventDefault(),playBtn.dataset.voice?_rehPreviewLocal(playBtn.dataset.voice,playBtn):_rehAudition(playBtn.dataset.url,playBtn);return}const toggle=e.target.closest(".reh-vo-toggle");if(toggle){e.preventDefault();const alts=(_a3=toggle.closest(".reh-cc-online"))==null?void 0:_a3.querySelector(".reh-vo-alts");alts&&(alts.hidden=!alts.hidden,toggle.textContent=toggle.textContent.replace(/[▾▴]\s*$/,"")+(alts.hidden?"\u25BE":"\u25B4"));return}const tool=e.target.closest(".reh-vo-tool");if(tool){e.preventDefault();const panel=tool.closest(".reh-cc-online"),want=tool.dataset.tool,localP=panel.querySelector(".reh-vo-local-panel"),searchP=panel.querySelector(".reh-vo-search-panel"),showLocal=want==="local"&&((_b2=localP==null?void 0:localP.hidden)!=null?_b2:!0),showSearch=want==="search"&&((_c2=searchP==null?void 0:searchP.hidden)!=null?_c2:!0);if(localP&&(localP.hidden=!showLocal),searchP&&(searchP.hidden=!showSearch),panel.querySelectorAll(".reh-vo-tool").forEach(t=>t.classList.toggle("active",t.dataset.tool==="local"&&showLocal||t.dataset.tool==="search"&&showSearch)),showLocal){const card2=e.target.closest(".reh-cast-card");_rehRenderLocalResults(card2,spOf(tool),""),(_d2=card2.querySelector(".reh-vo-local-input"))==null||_d2.focus()}showSearch&&((_e2=panel.querySelector(".reh-vo-search-input"))==null||_e2.focus());return}const goBtn=e.target.closest(".reh-vo-search-go");if(goBtn){e.preventDefault();const card2=e.target.closest(".reh-cast-card");_rehSearchOnline(card2,spOf(goBtn),(_f2=card2.querySelector(".reh-vo-search-input"))==null?void 0:_f2.value);return}const use=e.target.closest(".reh-vo-use");if(use){e.preventDefault();const sp=spOf(use),act=use.dataset.act;act==="local"?_rehAssignLocal(sp,use.dataset.voice):act==="search"?_rehUseSearchResult(sp,parseInt(use.dataset.idx,10),use):_rehUseCandidate(sp,parseInt(use.dataset.idx,10),use)}}),list.addEventListener("input",e=>{const li=e.target.closest(".reh-vo-local-input");li&&_rehRenderLocalResults(e.target.closest(".reh-cast-card"),spOf(li),li.value)}),list.addEventListener("keydown",e=>{const si=e.target.closest(".reh-vo-search-input");si&&e.key==="Enter"&&(e.preventDefault(),_rehSearchOnline(e.target.closest(".reh-cast-card"),spOf(si),si.value))}))}function applyCastView(){const list=$("reh-cast-list");if(!list)return;const view=rehState.castView==="list"?"list":"card";list.classList.toggle("reh-cast-view-card",view==="card"),list.classList.toggle("reh-cast-view-list",view==="list"),document.querySelectorAll("#reh-cast-view-toggle .reh-view-btn").forEach(b=>b.classList.toggle("active",b.dataset.view===view))}try{rehState.castView=localStorage.getItem("reh-cast-view")||"card"}catch{rehState.castView="card"}rehState.castFilter={search:"",gender:"",lang:""};try{rehState.castSort=JSON.parse(localStorage.getItem("reh-cast-sort"))||{by:"name",dir:"asc"}}catch{rehState.castSort={by:"name",dir:"asc"}}function _wireCastControls(){const toggle=$("reh-cast-view-toggle");if(!toggle||toggle._wired)return;toggle._wired=!0,toggle.querySelectorAll(".reh-view-btn").forEach(b=>b.addEventListener("click",()=>{rehState.castView=b.dataset.view;try{localStorage.setItem("reh-cast-view",b.dataset.view)}catch{}applyCastView()}));const search=$("reh-cast-search"),fg=$("reh-cast-filter-gender"),fl=$("reh-cast-filter-lang"),sortSel=$("reh-cast-sort"),dirBtn=$("reh-cast-sort-dir"),setDirIcon=()=>{dirBtn&&(dirBtn.dataset.dir=rehState.castSort.dir,dirBtn.querySelector(".mdi").className="mdi mdi-sort-"+(rehState.castSort.dir==="desc"?"descending":"ascending"))},persist=()=>{try{localStorage.setItem("reh-cast-sort",JSON.stringify(rehState.castSort))}catch{}};sortSel&&(sortSel.value=rehState.castSort.by),setDirIcon(),search==null||search.addEventListener("input",()=>{rehState.castFilter.search=search.value.trim(),renderCastList(),search.focus()}),fg==null||fg.addEventListener("change",()=>{rehState.castFilter.gender=fg.value,renderCastList()}),fl==null||fl.addEventListener("change",()=>{rehState.castFilter.lang=fl.value,renderCastList()}),sortSel==null||sortSel.addEventListener("change",()=>{rehState.castSort.by=sortSel.value,rehState.castSort.dir=sortSel.value==="lines"?"desc":"asc",setDirIcon(),persist(),renderCastList()}),dirBtn==null||dirBtn.addEventListener("click",()=>{rehState.castSort.dir=rehState.castSort.dir==="desc"?"asc":"desc",setDirIcon(),persist(),renderCastList()})}function _castSortFilter(speakers,lineCount){const f=rehState.castFilter||{},s=rehState.castSort||{by:"name",dir:"asc"},out=speakers.filter(sp=>{const c=rehState.cast[sp]||{};if(f.search){const q=f.search.toLowerCase();if(!sp.toLowerCase().includes(q)&&!(c.tags||"").toLowerCase().includes(q))return!1}return!(f.gender&&(c.gender||"")!==f.gender||f.lang&&(c.lang||"")!==f.lang)}),byName=(a,b)=>a.localeCompare(b,void 0,{sensitivity:"base"}),cmp={name:byName,gender:(a,b)=>(rehState.cast[a].gender||"~").localeCompare(rehState.cast[b].gender||"~")||byName(a,b),lang:(a,b)=>(rehState.cast[a].lang||"~").localeCompare(rehState.cast[b].lang||"~")||byName(a,b),lines:(a,b)=>lineCount(a)-lineCount(b)||byName(a,b),tag:(a,b)=>(rehState.cast[a].tags||"~").localeCompare(rehState.cast[b].tags||"~")||byName(a,b)}[s.by]||byName;return out.sort(cmp),s.dir==="desc"&&out.reverse(),out}function _castDeleteCharacter(sp){const n=rehState.lines.filter(l=>l.speaker===sp&&l.type==="dialog").length;if(confirm(`Delete \u201C${sp}\u201D and their ${n} line${n!==1?"s":""}? This cannot be undone.`)){for(let i=rehState.lines.length-1;i>=0;i--)rehState.lines[i].speaker===sp&&rehState.lines[i].type==="dialog"&&(rehState.lines.splice(i,1),_reindexLineState(i));delete rehState.cast[sp],renderCastList(),rehState.lines.length&&buildScriptPage(),toast(`Removed ${sp}`,"success")}}async function _castDevelop(sp,btn){var _a2,_b2,_c2,_d2,_e2;const script=((_a2=$("reh-script-text"))==null?void 0:_a2.value.trim())||linesToScriptText();if(!script){toast("Load a script first","error");return}const c=rehState.cast[sp],orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Developing\u2026';try{const r=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script,names:[sp],llm_url:((_b2=$("reh-llm-url"))==null?void 0:_b2.value.trim())||rehDefaultLlmUrl(),model:((_c2=$("reh-llm-model"))==null?void 0:_c2.value)||"",language:c.lang||((_d2=$("reh-design-lang"))==null?void 0:_d2.value)||"English"})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const info=((_e2=(await r.json()).characters)==null?void 0:_e2[0])||{};info.gender&&(c.gender=String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/,"N")),info.description&&(c.soul=info.description,c.instruct=c.instruct||info.description),renderCastList(),toast(`Developed ${sp}`,"success")}catch(e){btn.disabled=!1,btn.innerHTML=orig,toast("Develop failed: "+e.message,"error")}}async function refreshRehBackends(){rehInitLlmField();const sel=$("reh-backend-select");if(!sel)return;let backends=typeof availableTtsBackends=="function"?availableTtsBackends():[];!backends.length&&typeof refreshTtsBackendAvailability=="function"&&(await refreshTtsBackendAvailability().catch(()=>{}),backends=typeof availableTtsBackends=="function"?availableTtsBackends():[]),!backends.length&&typeof _ttsBackends!="undefined"&&Array.isArray(_ttsBackends)&&(backends=_ttsBackends);const prev=sel.value||rehState.backend;if(sel.innerHTML=backends.length?backends.map(b=>``).join(""):'',prev&&[...sel.options].some(o=>o.value===prev))sel.value=prev;else{const pick=["fishspeech","voice_clone","customvoice","voice_design"].find(id=>[...sel.options].some(o=>o.value===id));pick&&(sel.value=pick)}rehState.backend=sel.value||"",_checkToneStyleSupport()}(window._ttsRefreshHooks=window._ttsRefreshHooks||[]).push(()=>{const sel=$("reh-backend-select");if(!sel)return;const backends=typeof availableTtsBackends=="function"?availableTtsBackends():[];if(!backends.length)return;const prev=sel.value||rehState.backend;sel.innerHTML=backends.map(b=>``).join(""),prev&&[...sel.options].some(o=>o.value===prev)?sel.value=prev:sel.options.length&&(sel.value=sel.options[0].value),rehState.backend=sel.value||""});function _rehAllVoiceIds(include){const ids=new Set((rehState.voices||[]).filter(id=>_rehVoiceVisibleId(id,include)));return(window._voices||[]).forEach(v=>{v&&v.id&&(v.enabled!==!1||v.id===include)&&ids.add(v.id)}),include&&ids.add(include),[...ids].sort((a,b)=>a.localeCompare(b,void 0,{sensitivity:"base"}))}function populateNarratorSelect(){const sel=$("reh-narrator-voice");if(!sel)return;const cur=rehState.narratorVoice||sel.value;sel.innerHTML=''+_rehAllVoiceIds(cur).map(v=>``).join("")}(_ra=$("reh-fetch-voices-btn"))==null||_ra.addEventListener("click",async()=>{var _a2;const backend=(_a2=$("reh-backend-select"))==null?void 0:_a2.value;if(!backend){toast("Select a backend first","error");return}$("reh-fetch-voices-btn").disabled=!0;try{(!window._voices||!window._voices.length)&&typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{});const raw=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json());rehState.voices=(Array.isArray(raw)?raw.map(v=>typeof v=="string"?v:v.id||String(v)):[]).filter(id=>_rehVoiceVisibleId(id)),renderCastList(),populateNarratorSelect(),toast("Fetched "+rehState.voices.length+" voices","success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{$("reh-fetch-voices-btn").disabled=!1}}),(_sa=$("reh-narrator-voice"))==null||_sa.addEventListener("change",function(){rehState.narratorVoice=this.value}),(_ta=$("reh-back-1-btn"))==null||_ta.addEventListener("click",()=>showPhase(1));function _buildInstruct(voiceProfile,emotion){const p=(voiceProfile||"").trim(),e=(emotion||"").trim();return!e&&!p?"":e?p?`Speak in a ${e} manner. ${p}`:`Speak in a ${e} manner.`:p}function _rehBackendIsFish(){var _a2;let id="";try{id=typeof backendById=="function"&&((_a2=backendById(rehState.backend))==null?void 0:_a2.id)||rehState.backend||""}catch{id=rehState.backend||""}return/fish/i.test(id)}function _rehInlineTone(text,emotion){const e=(emotion||"").trim();return!e||!_rehBackendIsFish()||/^\s*\[/.test(text)?text:`[${e.toLowerCase()}] ${text}`}const REH_LANG_CODE={English:"EN",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Auto:"EN"};function rehDefaultLlmUrl(){try{if(typeof _appSettings!="undefined"&&_appSettings&&_appSettings.llm_url)return _appSettings.llm_url}catch{}return"http://localhost:11434/v1"}function rehCollectLlmEndpoints(){const seen=new Set,results=[],add=(url,label)=>{url&&(url=url.trim(),!(!url||seen.has(url))&&(seen.add(url),results.push({url,label:label||url})))};return add(rehDefaultLlmUrl(),"Active LLM"),document.querySelectorAll(".llm-local-url-inp, [data-llm-local-key]").forEach(inp=>{var _a2,_b2,_c2;const v=(_a2=inp.value)==null?void 0:_a2.trim(),def=inp.dataset.llmLocalDefault,key=inp.dataset.llmLocalKey||inp.dataset.dcUrlKey||"",card=inp.closest('[class*="llm-local-card"], [class*="llm-local"]'),name=((_c2=(_b2=card==null?void 0:card.querySelector(".llm-local-name"))==null?void 0:_b2.textContent)==null?void 0:_c2.trim())||key;add(v||def,name)}),document.querySelectorAll(".dc-url-inp").forEach(inp=>{var _a2,_b2,_c2;const card=inp.closest('[class*="llm-local-card"]');if(!card)return;const name=((_b2=(_a2=card.querySelector(".llm-local-name"))==null?void 0:_a2.textContent)==null?void 0:_b2.trim())||"";add(((_c2=inp.value)==null?void 0:_c2.trim())||inp.dataset.dcDefault,name)}),[["http://localhost:11434/v1","Ollama"],["http://localhost:8000/v1","vLLM"],["http://localhost:1234/v1","LM Studio"],["http://localhost:28080/v1","llama-swap"],["http://localhost:14000/v1","LiteLLM"]].forEach(([u,l])=>add(u,l)),results}function rehInitLlmField(){const u=$("reh-llm-url");if(!u)return;u.value||(u.value=rehDefaultLlmUrl());const dl=$("reh-llm-url-list");dl&&(dl.innerHTML=rehCollectLlmEndpoints().map(e=>``).join(""))}(_ua=$("reh-llm-refresh"))==null||_ua.addEventListener("click",async()=>{var _a2;const url=((_a2=$("reh-llm-url"))==null?void 0:_a2.value.trim())||rehDefaultLlmUrl(),sel=$("reh-llm-model");if(sel){sel.innerHTML='';try{const models=(await(await fetch("/api/conversation/llm-models?url="+encodeURIComponent(url))).json()).models||[];sel.innerHTML=''+models.map(m=>``).join("");const want=typeof _appSettings!="undefined"&&_appSettings?_appSettings.llm_model:"";want&&models.includes(want)&&(sel.value=want),toast(models.length?`Found ${models.length} models`:"No models found",models.length?"success":"error")}catch(e){sel.innerHTML='',toast("Could not list models: "+e.message,"error")}}});const REH_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"};function _pickVoiceAvatar(gender,desc,speaker){const d=((desc||"")+" "+(speaker||"")).toLowerCase();return/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)?"robot":/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)?"animal":gender==="M"?"male":gender==="F"?"female":"neutral"}let rehDesignCancelled=!1;(_va=$("reh-autodesign-cancel"))==null||_va.addEventListener("click",()=>{rehDesignCancelled=!0}),(_wa=$("reh-autodesign-btn"))==null||_wa.addEventListener("click",async()=>{var _a2,_b2,_c2,_d2,_e2,_f2,_g2;if(!((_a2=$("reh-backend-select"))==null?void 0:_a2.value)){toast("Select a TTS backend first","error");return}const speakers=Object.keys(rehState.cast);if(!speakers.length){toast("No characters to design for","error");return}const script=((_b2=$("reh-script-text"))==null?void 0:_b2.value.trim())||linesToScriptText(),llmUrl=((_c2=$("reh-llm-url"))==null?void 0:_c2.value.trim())||rehDefaultLlmUrl(),llmModel=((_d2=$("reh-llm-model"))==null?void 0:_d2.value)||"",language=((_e2=$("reh-design-lang"))==null?void 0:_e2.value)||"English",langCode=REH_LANG_CODE[language]||"EN",scriptTitle=((_f2=$("reh-script-title"))==null?void 0:_f2.value.trim())||"Script",tag=scriptTitle.replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,24)||"Script",btn=$("reh-autodesign-btn"),prog=$("reh-autodesign-progress"),fill=$("reh-autodesign-fill"),label=$("reh-autodesign-label");btn.disabled=!0,rehDesignCancelled=!1,prog&&(prog.hidden=!1);const setProg=(d,t,msg)=>{fill&&(fill.style.width=(t?d/t*100:0)+"%"),label&&(label.textContent=msg||`${d} / ${t}`)};setProg(0,speakers.length,"Analyzing script with LLM\u2026");let characters;try{const r=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script,names:speakers,llm_url:llmUrl,model:llmModel,language})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}characters=(await r.json()).characters||[]}catch(e){toast("Character analysis failed: "+e.message,"error"),btn.disabled=!1,prog&&(prog.hidden=!0);return}const byName={};characters.forEach(c=>{c.name&&(byName[String(c.name).toUpperCase().trim()]=c)});let done=0;try{const designOnly=speakers.filter(sp=>{var _a3;return((_a3=rehState.cast[sp])==null?void 0:_a3.voice)!=="me"});for(const sp of designOnly){if(rehDesignCancelled){toast("Cancelled","error");break}if(!rehState.cast[sp]){done++;continue}const info=byName[sp.toUpperCase().trim()]||{},gender=(info.gender||"N").toUpperCase().charAt(0).replace(/[^MFN]/,"N")||"N",desc=info.description||`A ${info.age||"adult"} ${gender==="M"?"male":gender==="F"?"female":""} character named ${sp}, natural expressive voice.`,sampleLine=((_g2=rehState.lines.find(l=>l.type==="dialog"&&l.speaker===sp))==null?void 0:_g2.text)||`Hello, I am ${sp}.`,safeName=sp.replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,24)||"Char",voiceId=`${langCode}_${gender}_${safeName}_${tag}`.slice(0,90);rehMarkCastDesigning(sp,"designing",null,{gender,language,voiceId,desc,age:info.age||"",step:"Generating voice audio\u2026"}),setProg(done,designOnly.length,`Designing ${sp}\u2026 (${done+1}/${designOnly.length})`);try{const dr=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({instruct:desc,sample_text:stripMarkdown(sampleLine).slice(0,300),language,gender,dialogue:!1})});if(!dr.ok){const e=await dr.json().catch(()=>({}));throw new Error(e.detail||dr.statusText)}const dd=await dr.json(),sr=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:dd.id,voice_id:voiceId,transcript:stripMarkdown(sampleLine).slice(0,300)})});if(!sr.ok){const e=await sr.json().catch(()=>({}));throw new Error(e.detail||sr.statusText)}const saved=await sr.json(),charName=sp===REH_NARRATOR_KEY?"Narrator":sp;typeof saveMeta=="function"&&await saveMeta(saved.voice_id,{gender,name:charName,avatar:_pickVoiceAvatar(gender,desc,sp),origin:"designed",group:`Rehearser: ${scriptTitle}`,note:`Rehearser \xB7 ${scriptTitle} \xB7 ${charName} \u2014 ${desc.slice(0,180)}`,transcript:stripMarkdown(sampleLine).slice(0,300),tag:scriptTitle}).catch(()=>{}),rehState.cast[sp].voice=saved.voice_id,rehState.cast[sp].instruct=rehState.cast[sp].instruct||desc,rehState.cast[sp].soul=rehState.cast[sp].soul||[info.age?`Age: ${info.age}`:"",desc].filter(Boolean).join(" \xB7 "),rehState.cast[sp].voiceData=null,rehState.voices.includes(saved.voice_id)||rehState.voices.push(saved.voice_id),rehMarkCastDesigning(sp,"done")}catch(e){rehMarkCastDesigning(sp,"err",e.message)}done++,setProg(done,designOnly.length)}typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{}),rehDesignCancelled||toast(`Designed ${done} voice${done!==1?"s":""} \u2014 tagged "${tag}" + Rehearser`,"success")}catch(e){toast("Design all failed: "+((e==null?void 0:e.message)||e),"error")}finally{renderCastList(),populateNarratorSelect(),prog&&(prog.hidden=!0),btn.disabled=!1}});function rehWriteCharacterNote(sp,info){const c=rehState.cast[sp];if(!c||!info)return;info.gender&&!c.gender&&(c.gender=String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/,"N"));const bits=[];info.age&&bits.push(`Age: ${info.age}`),info.description&&bits.push(info.description);const note=bits.join(" \xB7 ");note&&!c.soul&&(c.soul=note),info.description&&!c.instruct&&(c.instruct=info.description)}async function rehResearchCast(speakers){var _a2,_b2,_c2,_d2;const names=speakers.filter(sp=>sp!==REH_NARRATOR_KEY);if(!names.length)return{};let chars=[];try{const r=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script:((_a2=$("reh-script-text"))==null?void 0:_a2.value.trim())||linesToScriptText(),names,llm_url:((_b2=$("reh-llm-url"))==null?void 0:_b2.value.trim())||rehDefaultLlmUrl(),model:((_c2=$("reh-llm-model"))==null?void 0:_c2.value)||"",language:((_d2=$("reh-design-lang"))==null?void 0:_d2.value)||"English"})});r.ok&&(chars=(await r.json()).characters||[])}catch{}const byName={};return chars.forEach(c=>{c.name&&(byName[String(c.name).toUpperCase().trim()]=c)}),speakers.forEach(sp=>rehWriteCharacterNote(sp,byName[sp.toUpperCase().trim()])),byName}(_xa=$("reh-matchlib-btn"))==null||_xa.addEventListener("click",async()=>{var _a2,_b2,_c2,_d2;const btn=$("reh-matchlib-btn"),speakers=Object.keys(rehState.cast).filter(sp=>rehState.cast[sp].voice!=="me");if(!speakers.length){toast("No characters to match","error");return}let lib=(window._voices||[]).filter(v=>v.enabled!==!1);if(!lib.length)try{lib=(await fetch("/api/voices").then(r=>r.json())).filter(v=>v.enabled!==!1)}catch{}if(!lib.length){toast("Your voice library is empty \u2014 clone, design or import some voices first","error");return}const candidates=lib.map(v=>({id:v.id,gender:v.gender||"",language:v.lang||"",tags:v.tag||"",description:(v.note||v.name||"").slice(0,140)})),nameFor=sp=>sp===REH_NARRATOR_KEY?"Narrator":sp,byDisplay={};speakers.forEach(sp=>{byDisplay[nameFor(sp).toUpperCase().trim()]=sp});const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Matching\u2026';try{const r=await fetch("/api/match-characters-voices",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script:((_a2=$("reh-script-text"))==null?void 0:_a2.value.trim())||linesToScriptText(),names:speakers.map(nameFor),voices:candidates,llm_url:((_b2=$("reh-llm-url"))==null?void 0:_b2.value.trim())||rehDefaultLlmUrl(),model:((_c2=$("reh-llm-model"))==null?void 0:_c2.value)||"",language:((_d2=$("reh-design-lang"))==null?void 0:_d2.value)||"English"})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const assignments=(await r.json()).assignments||[],validIds=new Set(candidates.map(c=>c.id));let n=0;assignments.forEach(a=>{const sp=byDisplay[String(a.name||"").toUpperCase().trim()];sp&&a.voice_id&&validIds.has(a.voice_id)&&(rehState.cast[sp].voice=a.voice_id,rehState.cast[sp].voiceData=getVoiceData(a.voice_id),rehState.voices.includes(a.voice_id)||rehState.voices.push(a.voice_id),sp===REH_NARRATOR_KEY&&(rehState.narratorVoice=a.voice_id),n++)}),btn.innerHTML=' Researching characters\u2026',await rehResearchCast(speakers),renderCastList(),populateNarratorSelect(),toast(n?`Matched ${n} character${n!==1?"s":""} & added notes`:"No good matches \u2014 try \u201CDesign all voices\u201D instead",n?"success":"error")}catch(e){toast("Match failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig}});const REH_FISH_LANG={English:"en",German:"de",French:"fr",Spanish:"es",Italian:"it",Portuguese:"pt",Dutch:"nl",Auto:""};(_ya=$("reh-matchonline-btn"))==null||_ya.addEventListener("click",async()=>{var _a2,_b2,_c2,_d2,_e2,_f2;const btn=$("reh-matchonline-btn"),speakers=Object.keys(rehState.cast).filter(sp=>rehState.cast[sp].voice!=="me"&&sp!==REH_NARRATOR_KEY);if(!speakers.length){toast("No characters to match","error");return}const lang=(_b2=REH_FISH_LANG[((_a2=$("reh-design-lang"))==null?void 0:_a2.value)||"English"])!=null?_b2:"en",prog=$("reh-autodesign-progress"),fill=$("reh-autodesign-fill"),label=$("reh-autodesign-label"),setProg=(d,t,msg)=>{fill&&(fill.style.width=(t?d/t*100:0)+"%"),label&&(label.textContent=msg||`${d} / ${t}`)},orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Analyzing\u2026',prog&&(prog.hidden=!1),rehDesignCancelled=!1;try{let chars=[];try{const ar=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script:((_c2=$("reh-script-text"))==null?void 0:_c2.value.trim())||linesToScriptText(),names:speakers,llm_url:((_d2=$("reh-llm-url"))==null?void 0:_d2.value.trim())||rehDefaultLlmUrl(),model:((_e2=$("reh-llm-model"))==null?void 0:_e2.value)||"",language:((_f2=$("reh-design-lang"))==null?void 0:_f2.value)||"English"})});ar.ok&&(chars=(await ar.json()).characters||[])}catch{}const byName={};chars.forEach(c=>{c.name&&(byName[String(c.name).toUpperCase().trim()]=c)});const G={M:"male",F:"female",N:"neutral"};let done=0,n=0;for(let i=0;ir.json())).items||[]}catch{}let pick=items.find(v=>v.sample_audio),nameHit=!!pick;if(!pick){const fb=[`language=${lang}`,gender?`gender=${gender}`:"","page_size=12","sort_by=score",`page=${i%4+1}`].filter(Boolean);try{items=(await fetch("/api/fishaudio/voices?"+fb.join("&")).then(r=>r.json())).items||[]}catch{}pick=items.find(v=>v.sample_audio)}let cands=items.filter(v=>v.sample_audio).slice(0,6).map(v=>({title:v.title,sample_audio:v.sample_audio,image:v.image||"",gender:v.gender||"",language:v.language||lang||"",description:v.description||"",sample_text:v.sample_text||v.default_text||""}));if(!nameHit&&cands.length>1){const taken=new Set(Object.values(rehState.cast).map(c=>{var _a3,_b3,_c3;return(_c3=(_b3=(_a3=c.online)==null?void 0:_a3.candidates)==null?void 0:_b3[c.online.picked])==null?void 0:_c3.sample_audio}).filter(Boolean));cands=[...cands.slice(i%cands.length),...cands.slice(0,i%cands.length)].sort((a,b)=>(taken.has(a.sample_audio)?1:0)-(taken.has(b.sample_audio)?1:0))}if(cands.length)try{const vid=await _rehImportFishCandidate(sp,cands[0]);cands[0].voice_id=vid,rehState.cast[sp].voice=vid,rehState.cast[sp].voiceData=getVoiceData(vid),rehState.cast[sp].online={candidates:cands,picked:0},n++}catch(e){logErr("match-online import "+sp,e)}done++,setProg(done,speakers.length)}typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{}),renderCastList(),populateNarratorSelect(),toast(n?`Imported & matched ${n} online voice${n!==1?"s":""}`:"No online matches found",n?"success":"error")}catch(e){toast("Online match failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig,prog&&(prog.hidden=!0)}});function rehMarkCastDesigning(sp,state,msg,info){var _a2;const row=document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`);if(!row)return;let badge=row.querySelector(".reh-cast-design-badge");badge||(badge=document.createElement("span"),badge.className="reh-cast-design-badge",(_a2=row.querySelector("strong"))==null||_a2.after(badge)),badge.className="reh-cast-design-badge"+(state==="done"?" done":state==="err"?" err":""),badge.textContent=state==="designing"?"\u2728 designing\u2026":state==="done"?"\u2713 designed":"\u2717 failed",msg&&(badge.title=msg),row.classList.toggle("reh-cast-designing",state==="designing");const wrap=row.closest("div");let panel=wrap==null?void 0:wrap.querySelector(".reh-cast-design-panel");if(state==="designing"&&info){panel||(panel=document.createElement("div"),panel.className="reh-cast-design-panel",wrap.appendChild(panel));const genderIcon=info.gender==="M"?"\u2642":info.gender==="F"?"\u2640":"\u26A7",genderLabel=info.gender==="M"?"Male":info.gender==="F"?"Female":"Neutral";panel.innerHTML=` +
`}).join("");const countEl=$("reh-cast-count");if(countEl){const shown=others.length,total=allOthers.length;countEl.textContent=shown===total?`${total} ${total===1?"character":"characters"} + narrator`:`${shown} of ${total} characters`}if(typeof _wireCharCards=="function"&&(_rehLibCharsCache||[]).length){const recsById=new Map(_rehLibCharsCache.map(r=>[r.id,r]));_wireCharCards(list,recsById,_rehLibCharsCache,renderCastList,{container:list,onBack:renderCastList})}_wireCastControls(),applyCastView();const card=el=>el.closest(".reh-cast-card"),spOf=el=>card(el).dataset.speaker;window.VoicePicker&&list.querySelectorAll(".reh-voice-sel[id]").forEach(sel=>{const cur=sel.value;VoicePicker.upgrade(sel.id),cur&&VoicePicker.setValue(sel.id,cur)}),list.querySelectorAll(".reh-me-check").forEach(cb=>cb.addEventListener("change",function(){var _a3;const sp=spOf(this);rehState.cast[sp].voice=this.checked?"me":((_a3=card(this).querySelector(".reh-voice-sel"))==null?void 0:_a3.value)||"",rehState.cast[sp].voiceData=this.checked?null:getVoiceData(rehState.cast[sp].voice),renderCastList()})),list.querySelectorAll(".reh-voice-sel").forEach(sel=>sel.addEventListener("change",function(){const sp=this.dataset.speaker;rehState.cast[sp].voice=this.value,rehState.cast[sp].voiceData=getVoiceData(this.value),delete rehState.cast[sp].online,sp===REH_NARRATOR_KEY&&(rehState.narratorVoice=this.value),renderCastList()})),list.querySelectorAll(".reh-cast-instruct").forEach(inp=>inp.addEventListener("input",function(){rehState.cast[spOf(this)].instruct=this.value})),list.querySelectorAll(".reh-cc-lang").forEach(s=>s.addEventListener("change",function(){rehState.cast[spOf(this)].lang=this.value})),list.querySelectorAll(".reh-cc-gender").forEach(s=>s.addEventListener("change",function(){rehState.cast[spOf(this)].gender=this.value})),list.querySelectorAll(".reh-cc-tags").forEach(i=>i.addEventListener("input",function(){rehState.cast[spOf(this)].tags=this.value})),list.querySelectorAll(".reh-cc-soul-text").forEach(t=>t.addEventListener("input",function(){rehState.cast[spOf(this)].soul=this.value})),list.querySelectorAll(".reh-cc-develop").forEach(b=>b.addEventListener("click",function(e){e.preventDefault(),_castDevelop(spOf(this),this)})),list.querySelectorAll(".reh-cc-iconbtn").forEach(b=>b.addEventListener("click",function(){const sp=spOf(this),act=this.dataset.act,c=rehState.cast[sp];act==="ignore"?(c.ignored=!c.ignored,_castApplyToLines(sp,l=>l.ignored=c.ignored),renderCastList()):act==="hide"?(c.hidden=!c.hidden,_castApplyToLines(sp,l=>l.hidden=c.hidden),renderCastList()):act==="delete"&&_castDeleteCharacter(sp)})),list._voDelegated||(list._voDelegated=!0,list.addEventListener("click",e=>{var _a3,_b2,_c2,_d2,_e2,_f2;const sampleBtn=e.target.closest(".reh-cc-sample-btn");if(sampleBtn){e.preventDefault(),_rehPreviewCastLine(sampleBtn.dataset.speaker,sampleBtn);return}const playBtn=e.target.closest(".reh-vo-play");if(playBtn){e.preventDefault(),playBtn.dataset.voice?_rehPreviewLocal(playBtn.dataset.voice,playBtn):_rehAudition(playBtn.dataset.url,playBtn);return}const toggle=e.target.closest(".reh-vo-toggle");if(toggle){e.preventDefault();const alts=(_a3=toggle.closest(".reh-cc-online"))==null?void 0:_a3.querySelector(".reh-vo-alts");alts&&(alts.hidden=!alts.hidden,toggle.textContent=toggle.textContent.replace(/[▾▴]\s*$/,"")+(alts.hidden?"\u25BE":"\u25B4"));return}const tool=e.target.closest(".reh-vo-tool");if(tool){e.preventDefault();const panel=tool.closest(".reh-cc-online"),want=tool.dataset.tool,localP=panel.querySelector(".reh-vo-local-panel"),searchP=panel.querySelector(".reh-vo-search-panel"),showLocal=want==="local"&&((_b2=localP==null?void 0:localP.hidden)!=null?_b2:!0),showSearch=want==="search"&&((_c2=searchP==null?void 0:searchP.hidden)!=null?_c2:!0);if(localP&&(localP.hidden=!showLocal),searchP&&(searchP.hidden=!showSearch),panel.querySelectorAll(".reh-vo-tool").forEach(t=>t.classList.toggle("active",t.dataset.tool==="local"&&showLocal||t.dataset.tool==="search"&&showSearch)),showLocal){const card2=e.target.closest(".reh-cast-card");_rehRenderLocalResults(card2,spOf(tool),""),(_d2=card2.querySelector(".reh-vo-local-input"))==null||_d2.focus()}showSearch&&((_e2=panel.querySelector(".reh-vo-search-input"))==null||_e2.focus());return}const goBtn=e.target.closest(".reh-vo-search-go");if(goBtn){e.preventDefault();const card2=e.target.closest(".reh-cast-card");_rehSearchOnline(card2,spOf(goBtn),(_f2=card2.querySelector(".reh-vo-search-input"))==null?void 0:_f2.value);return}const use=e.target.closest(".reh-vo-use");if(use){e.preventDefault();const sp=spOf(use),act=use.dataset.act;act==="local"?_rehAssignLocal(sp,use.dataset.voice):act==="search"?_rehUseSearchResult(sp,parseInt(use.dataset.idx,10),use):_rehUseCandidate(sp,parseInt(use.dataset.idx,10),use)}}),list.addEventListener("input",e=>{const li=e.target.closest(".reh-vo-local-input");li&&_rehRenderLocalResults(e.target.closest(".reh-cast-card"),spOf(li),li.value)}),list.addEventListener("keydown",e=>{const si=e.target.closest(".reh-vo-search-input");si&&e.key==="Enter"&&(e.preventDefault(),_rehSearchOnline(e.target.closest(".reh-cast-card"),spOf(si),si.value))}))}function applyCastView(){const list=$("reh-cast-list");if(!list)return;const view=rehState.castView==="list"?"list":"card";list.classList.toggle("reh-cast-view-card",view==="card"),list.classList.toggle("reh-cast-view-list",view==="list"),document.querySelectorAll("#reh-cast-view-toggle .reh-view-btn").forEach(b=>b.classList.toggle("active",b.dataset.view===view))}try{rehState.castView=localStorage.getItem("reh-cast-view")||"card"}catch{rehState.castView="card"}rehState.castFilter={search:"",gender:"",lang:""};try{rehState.castSort=JSON.parse(localStorage.getItem("reh-cast-sort"))||{by:"name",dir:"asc"}}catch{rehState.castSort={by:"name",dir:"asc"}}function _wireCastControls(){const toggle=$("reh-cast-view-toggle");if(!toggle||toggle._wired)return;toggle._wired=!0,toggle.querySelectorAll(".reh-view-btn").forEach(b=>b.addEventListener("click",()=>{rehState.castView=b.dataset.view;try{localStorage.setItem("reh-cast-view",b.dataset.view)}catch{}applyCastView()}));const search=$("reh-cast-search"),fg=$("reh-cast-filter-gender"),fl=$("reh-cast-filter-lang"),sortSel=$("reh-cast-sort"),dirBtn=$("reh-cast-sort-dir"),setDirIcon=()=>{dirBtn&&(dirBtn.dataset.dir=rehState.castSort.dir,dirBtn.querySelector(".mdi").className="mdi mdi-sort-"+(rehState.castSort.dir==="desc"?"descending":"ascending"))},persist=()=>{try{localStorage.setItem("reh-cast-sort",JSON.stringify(rehState.castSort))}catch{}};sortSel&&(sortSel.value=rehState.castSort.by),setDirIcon(),search==null||search.addEventListener("input",()=>{rehState.castFilter.search=search.value.trim(),renderCastList(),search.focus()}),fg==null||fg.addEventListener("change",()=>{rehState.castFilter.gender=fg.value,renderCastList()}),fl==null||fl.addEventListener("change",()=>{rehState.castFilter.lang=fl.value,renderCastList()}),sortSel==null||sortSel.addEventListener("change",()=>{rehState.castSort.by=sortSel.value,rehState.castSort.dir=sortSel.value==="lines"?"desc":"asc",setDirIcon(),persist(),renderCastList()}),dirBtn==null||dirBtn.addEventListener("click",()=>{rehState.castSort.dir=rehState.castSort.dir==="desc"?"asc":"desc",setDirIcon(),persist(),renderCastList()})}function _castSortFilter(speakers,lineCount){const f=rehState.castFilter||{},s=rehState.castSort||{by:"name",dir:"asc"},out=speakers.filter(sp=>{const c=rehState.cast[sp]||{};if(f.search){const q=f.search.toLowerCase();if(!sp.toLowerCase().includes(q)&&!(c.tags||"").toLowerCase().includes(q))return!1}return!(f.gender&&(c.gender||"")!==f.gender||f.lang&&(c.lang||"")!==f.lang)}),byName=(a,b)=>a.localeCompare(b,void 0,{sensitivity:"base"}),cmp={name:byName,gender:(a,b)=>(rehState.cast[a].gender||"~").localeCompare(rehState.cast[b].gender||"~")||byName(a,b),lang:(a,b)=>(rehState.cast[a].lang||"~").localeCompare(rehState.cast[b].lang||"~")||byName(a,b),lines:(a,b)=>lineCount(a)-lineCount(b)||byName(a,b),tag:(a,b)=>(rehState.cast[a].tags||"~").localeCompare(rehState.cast[b].tags||"~")||byName(a,b)}[s.by]||byName;return out.sort(cmp),s.dir==="desc"&&out.reverse(),out}function _castDeleteCharacter(sp){const n=rehState.lines.filter(l=>l.speaker===sp&&l.type==="dialog").length;if(confirm(`Delete \u201C${sp}\u201D and their ${n} line${n!==1?"s":""}? This cannot be undone.`)){for(let i=rehState.lines.length-1;i>=0;i--)rehState.lines[i].speaker===sp&&rehState.lines[i].type==="dialog"&&(rehState.lines.splice(i,1),_reindexLineState(i));delete rehState.cast[sp],renderCastList(),rehState.lines.length&&buildScriptPage(),toast(`Removed ${sp}`,"success")}}async function _castDevelop(sp,btn){var _a2,_b2,_c2,_d2,_e2;const script=((_a2=$("reh-script-text"))==null?void 0:_a2.value.trim())||linesToScriptText();if(!script){toast("Load a script first","error");return}const c=rehState.cast[sp],orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Developing\u2026';try{const r=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script,names:[sp],llm_url:((_b2=$("reh-llm-url"))==null?void 0:_b2.value.trim())||rehDefaultLlmUrl(),model:((_c2=$("reh-llm-model"))==null?void 0:_c2.value)||"",language:c.lang||((_d2=$("reh-design-lang"))==null?void 0:_d2.value)||"English"})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const info=((_e2=(await r.json()).characters)==null?void 0:_e2[0])||{};info.gender&&(c.gender=String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/,"N")),info.description&&(c.soul=info.description,c.instruct=c.instruct||info.description),renderCastList(),toast(`Developed ${sp}`,"success")}catch(e){btn.disabled=!1,btn.innerHTML=orig,toast("Develop failed: "+e.message,"error")}}async function refreshRehBackends(){rehInitLlmField();const sel=$("reh-backend-select");if(!sel)return;let backends=typeof availableTtsBackends=="function"?availableTtsBackends():[];!backends.length&&typeof refreshTtsBackendAvailability=="function"&&(await refreshTtsBackendAvailability().catch(()=>{}),backends=typeof availableTtsBackends=="function"?availableTtsBackends():[]),!backends.length&&typeof _ttsBackends!="undefined"&&Array.isArray(_ttsBackends)&&(backends=_ttsBackends);const prev=sel.value||rehState.backend;if(sel.innerHTML=backends.length?backends.map(b=>``).join(""):'',prev&&[...sel.options].some(o=>o.value===prev))sel.value=prev;else{const pick=["fishspeech","voice_clone","customvoice","voice_design"].find(id=>[...sel.options].some(o=>o.value===id));pick&&(sel.value=pick)}rehState.backend=sel.value||"",_checkToneStyleSupport()}(window._ttsRefreshHooks=window._ttsRefreshHooks||[]).push(()=>{const sel=$("reh-backend-select");if(!sel)return;const backends=typeof availableTtsBackends=="function"?availableTtsBackends():[];if(!backends.length)return;const prev=sel.value||rehState.backend;sel.innerHTML=backends.map(b=>``).join(""),prev&&[...sel.options].some(o=>o.value===prev)?sel.value=prev:sel.options.length&&(sel.value=sel.options[0].value),rehState.backend=sel.value||""});function _rehAllVoiceIds(include){const ids=new Set((rehState.voices||[]).filter(id=>_rehVoiceVisibleId(id,include)));return(window._voices||[]).forEach(v=>{v&&v.id&&(v.enabled!==!1||v.id===include)&&ids.add(v.id)}),include&&ids.add(include),[...ids].sort((a,b)=>a.localeCompare(b,void 0,{sensitivity:"base"}))}function populateNarratorSelect(){const sel=$("reh-narrator-voice");if(!sel)return;const cur=rehState.narratorVoice||sel.value;sel.innerHTML=''+_rehAllVoiceIds(cur).map(v=>``).join("")}(_ra=$("reh-fetch-voices-btn"))==null||_ra.addEventListener("click",async()=>{var _a2;const backend=(_a2=$("reh-backend-select"))==null?void 0:_a2.value;if(!backend){toast("Select a backend first","error");return}$("reh-fetch-voices-btn").disabled=!0;try{(!window._voices||!window._voices.length)&&typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{});const raw=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json());rehState.voices=(Array.isArray(raw)?raw.map(v=>typeof v=="string"?v:v.id||String(v)):[]).filter(id=>_rehVoiceVisibleId(id)),renderCastList(),populateNarratorSelect(),toast("Fetched "+rehState.voices.length+" voices","success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{$("reh-fetch-voices-btn").disabled=!1}}),(_sa=$("reh-narrator-voice"))==null||_sa.addEventListener("change",function(){rehState.narratorVoice=this.value}),(_ta=$("reh-back-1-btn"))==null||_ta.addEventListener("click",()=>showPhase(1));function _buildInstruct(voiceProfile,emotion){const p=(voiceProfile||"").trim(),e=(emotion||"").trim();return!e&&!p?"":e?p?`Speak in a ${e} manner. ${p}`:`Speak in a ${e} manner.`:p}function _rehBackendIsFish(){var _a2;let id="";try{id=typeof backendById=="function"&&((_a2=backendById(rehState.backend))==null?void 0:_a2.id)||rehState.backend||""}catch{id=rehState.backend||""}return/fish/i.test(id)}function _rehInlineTone(text,emotion){const e=(emotion||"").trim();return!e||!_rehBackendIsFish()||/^\s*\[/.test(text)?text:`[${e.toLowerCase()}] ${text}`}const REH_LANG_CODE={English:"EN",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Auto:"EN"};function rehDefaultLlmUrl(){try{if(typeof _appSettings!="undefined"&&_appSettings&&_appSettings.llm_url)return _appSettings.llm_url}catch{}return"http://localhost:11434/v1"}function rehCollectLlmEndpoints(){const seen=new Set,results=[],add=(url,label)=>{url&&(url=url.trim(),!(!url||seen.has(url))&&(seen.add(url),results.push({url,label:label||url})))};return add(rehDefaultLlmUrl(),"Active LLM"),document.querySelectorAll(".llm-local-url-inp, [data-llm-local-key]").forEach(inp=>{var _a2,_b2,_c2;const v=(_a2=inp.value)==null?void 0:_a2.trim(),def=inp.dataset.llmLocalDefault,key=inp.dataset.llmLocalKey||inp.dataset.dcUrlKey||"",card=inp.closest('[class*="llm-local-card"], [class*="llm-local"]'),name=((_c2=(_b2=card==null?void 0:card.querySelector(".llm-local-name"))==null?void 0:_b2.textContent)==null?void 0:_c2.trim())||key;add(v||def,name)}),document.querySelectorAll(".dc-url-inp").forEach(inp=>{var _a2,_b2,_c2;const card=inp.closest('[class*="llm-local-card"]');if(!card)return;const name=((_b2=(_a2=card.querySelector(".llm-local-name"))==null?void 0:_a2.textContent)==null?void 0:_b2.trim())||"";add(((_c2=inp.value)==null?void 0:_c2.trim())||inp.dataset.dcDefault,name)}),[["http://localhost:11434/v1","Ollama"],["http://localhost:8000/v1","vLLM"],["http://localhost:1234/v1","LM Studio"],["http://localhost:28080/v1","llama-swap"],["http://localhost:14000/v1","LiteLLM"]].forEach(([u,l])=>add(u,l)),results}function rehInitLlmField(){const u=$("reh-llm-url");if(!u)return;u.value||(u.value=rehDefaultLlmUrl());const dl=$("reh-llm-url-list");dl&&(dl.innerHTML=rehCollectLlmEndpoints().map(e=>``).join(""))}(_ua=$("reh-llm-refresh"))==null||_ua.addEventListener("click",async()=>{var _a2;const url=((_a2=$("reh-llm-url"))==null?void 0:_a2.value.trim())||rehDefaultLlmUrl(),sel=$("reh-llm-model");if(sel){sel.innerHTML='';try{const models=(await(await fetch("/api/conversation/llm-models?url="+encodeURIComponent(url))).json()).models||[];sel.innerHTML=''+models.map(m=>``).join("");const want=typeof _appSettings!="undefined"&&_appSettings?_appSettings.llm_model:"";want&&models.includes(want)&&(sel.value=want),toast(models.length?`Found ${models.length} models`:"No models found",models.length?"success":"error")}catch(e){sel.innerHTML='',toast("Could not list models: "+e.message,"error")}}});const REH_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"};function _pickVoiceAvatar(gender,desc,speaker){const d=((desc||"")+" "+(speaker||"")).toLowerCase();return/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)?"robot":/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)?"animal":gender==="M"?"male":gender==="F"?"female":"neutral"}let rehDesignCancelled=!1;(_va=$("reh-autodesign-cancel"))==null||_va.addEventListener("click",()=>{rehDesignCancelled=!0}),(_wa=$("reh-autodesign-btn"))==null||_wa.addEventListener("click",async()=>{var _a2,_b2,_c2,_d2,_e2,_f2,_g2;if(!((_a2=$("reh-backend-select"))==null?void 0:_a2.value)){toast("Select a TTS backend first","error");return}const speakers=Object.keys(rehState.cast);if(!speakers.length){toast("No characters to design for","error");return}const script=((_b2=$("reh-script-text"))==null?void 0:_b2.value.trim())||linesToScriptText(),llmUrl=((_c2=$("reh-llm-url"))==null?void 0:_c2.value.trim())||rehDefaultLlmUrl(),llmModel=((_d2=$("reh-llm-model"))==null?void 0:_d2.value)||"",language=((_e2=$("reh-design-lang"))==null?void 0:_e2.value)||"English",langCode=REH_LANG_CODE[language]||"EN",scriptTitle=((_f2=$("reh-script-title"))==null?void 0:_f2.value.trim())||"Script",tag=(typeof _umlautSafe=="function"?_umlautSafe(scriptTitle):scriptTitle).replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,24)||"Script",btn=$("reh-autodesign-btn"),prog=$("reh-autodesign-progress"),fill=$("reh-autodesign-fill"),label=$("reh-autodesign-label");btn.disabled=!0,rehDesignCancelled=!1,prog&&(prog.hidden=!1);const setProg=(d,t,msg)=>{fill&&(fill.style.width=(t?d/t*100:0)+"%"),label&&(label.textContent=msg||`${d} / ${t}`)};setProg(0,speakers.length,"Analyzing script with LLM\u2026");let characters;try{const r=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script,names:speakers,llm_url:llmUrl,model:llmModel,language})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}characters=(await r.json()).characters||[]}catch(e){toast("Character analysis failed: "+e.message,"error"),btn.disabled=!1,prog&&(prog.hidden=!0);return}const byName={};characters.forEach(c=>{c.name&&(byName[String(c.name).toUpperCase().trim()]=c)});let done=0;try{const designOnly=speakers.filter(sp=>{var _a3;return((_a3=rehState.cast[sp])==null?void 0:_a3.voice)!=="me"});for(const sp of designOnly){if(rehDesignCancelled){toast("Cancelled","error");break}if(!rehState.cast[sp]){done++;continue}const info=byName[sp.toUpperCase().trim()]||{},gender=(info.gender||"N").toUpperCase().charAt(0).replace(/[^MFN]/,"N")||"N",desc=info.description||`A ${info.age||"adult"} ${gender==="M"?"male":gender==="F"?"female":""} character named ${sp}, natural expressive voice.`,sampleLine=((_g2=rehState.lines.find(l=>l.type==="dialog"&&l.speaker===sp))==null?void 0:_g2.text)||`Hello, I am ${sp}.`,safeName=(typeof _umlautSafe=="function"?_umlautSafe(sp):sp).replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,24)||"Char",voiceId=`${langCode}_${gender}_${safeName}_${tag}`.slice(0,90);rehMarkCastDesigning(sp,"designing",null,{gender,language,voiceId,desc,age:info.age||"",step:"Generating voice audio\u2026"}),setProg(done,designOnly.length,`Designing ${sp}\u2026 (${done+1}/${designOnly.length})`);try{const dr=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({instruct:desc,sample_text:stripMarkdown(sampleLine).slice(0,300),language,gender,dialogue:!1})});if(!dr.ok){const e=await dr.json().catch(()=>({}));throw new Error(e.detail||dr.statusText)}const dd=await dr.json(),sr=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:dd.id,voice_id:voiceId,transcript:stripMarkdown(sampleLine).slice(0,300)})});if(!sr.ok){const e=await sr.json().catch(()=>({}));throw new Error(e.detail||sr.statusText)}const saved=await sr.json(),charName=sp===REH_NARRATOR_KEY?"Narrator":sp;typeof saveMeta=="function"&&await saveMeta(saved.voice_id,{gender,name:charName,avatar:_pickVoiceAvatar(gender,desc,sp),origin:"designed",group:`Rehearser: ${scriptTitle}`,note:`Rehearser \xB7 ${scriptTitle} \xB7 ${charName} \u2014 ${desc.slice(0,180)}`,transcript:stripMarkdown(sampleLine).slice(0,300),tag:scriptTitle}).catch(()=>{}),rehState.cast[sp].voice=saved.voice_id,rehState.cast[sp].instruct=rehState.cast[sp].instruct||desc,rehState.cast[sp].soul=rehState.cast[sp].soul||[info.age?`Age: ${info.age}`:"",desc].filter(Boolean).join(" \xB7 "),rehState.cast[sp].voiceData=null,rehState.voices.includes(saved.voice_id)||rehState.voices.push(saved.voice_id),rehMarkCastDesigning(sp,"done")}catch(e){rehMarkCastDesigning(sp,"err",e.message)}done++,setProg(done,designOnly.length)}typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{}),rehDesignCancelled||toast(`Designed ${done} voice${done!==1?"s":""} \u2014 tagged "${tag}" + Rehearser`,"success")}catch(e){toast("Design all failed: "+((e==null?void 0:e.message)||e),"error")}finally{renderCastList(),populateNarratorSelect(),prog&&(prog.hidden=!0),btn.disabled=!1}});function rehWriteCharacterNote(sp,info){const c=rehState.cast[sp];if(!c||!info)return;info.gender&&!c.gender&&(c.gender=String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/,"N"));const bits=[];info.age&&bits.push(`Age: ${info.age}`),info.description&&bits.push(info.description);const note=bits.join(" \xB7 ");note&&!c.soul&&(c.soul=note),info.description&&!c.instruct&&(c.instruct=info.description)}async function rehResearchCast(speakers){var _a2,_b2,_c2,_d2;const names=speakers.filter(sp=>sp!==REH_NARRATOR_KEY);if(!names.length)return{};let chars=[];try{const r=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script:((_a2=$("reh-script-text"))==null?void 0:_a2.value.trim())||linesToScriptText(),names,llm_url:((_b2=$("reh-llm-url"))==null?void 0:_b2.value.trim())||rehDefaultLlmUrl(),model:((_c2=$("reh-llm-model"))==null?void 0:_c2.value)||"",language:((_d2=$("reh-design-lang"))==null?void 0:_d2.value)||"English"})});r.ok&&(chars=(await r.json()).characters||[])}catch{}const byName={};return chars.forEach(c=>{c.name&&(byName[String(c.name).toUpperCase().trim()]=c)}),speakers.forEach(sp=>rehWriteCharacterNote(sp,byName[sp.toUpperCase().trim()])),byName}(_xa=$("reh-matchlib-btn"))==null||_xa.addEventListener("click",async()=>{var _a2,_b2,_c2,_d2;const btn=$("reh-matchlib-btn"),speakers=Object.keys(rehState.cast).filter(sp=>rehState.cast[sp].voice!=="me");if(!speakers.length){toast("No characters to match","error");return}let lib=(window._voices||[]).filter(v=>v.enabled!==!1);if(!lib.length)try{lib=(await fetch("/api/voices").then(r=>r.json())).filter(v=>v.enabled!==!1)}catch{}if(!lib.length){toast("Your voice library is empty \u2014 clone, design or import some voices first","error");return}const candidates=lib.map(v=>({id:v.id,gender:v.gender||"",language:v.lang||"",tags:v.tag||"",description:(v.note||v.name||"").slice(0,140)})),nameFor=sp=>sp===REH_NARRATOR_KEY?"Narrator":sp,byDisplay={};speakers.forEach(sp=>{byDisplay[nameFor(sp).toUpperCase().trim()]=sp});const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Matching\u2026';try{const r=await fetch("/api/match-characters-voices",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script:((_a2=$("reh-script-text"))==null?void 0:_a2.value.trim())||linesToScriptText(),names:speakers.map(nameFor),voices:candidates,llm_url:((_b2=$("reh-llm-url"))==null?void 0:_b2.value.trim())||rehDefaultLlmUrl(),model:((_c2=$("reh-llm-model"))==null?void 0:_c2.value)||"",language:((_d2=$("reh-design-lang"))==null?void 0:_d2.value)||"English"})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const assignments=(await r.json()).assignments||[],validIds=new Set(candidates.map(c=>c.id));let n=0;assignments.forEach(a=>{const sp=byDisplay[String(a.name||"").toUpperCase().trim()];sp&&a.voice_id&&validIds.has(a.voice_id)&&(rehState.cast[sp].voice=a.voice_id,rehState.cast[sp].voiceData=getVoiceData(a.voice_id),rehState.voices.includes(a.voice_id)||rehState.voices.push(a.voice_id),sp===REH_NARRATOR_KEY&&(rehState.narratorVoice=a.voice_id),n++)}),btn.innerHTML=' Researching characters\u2026',await rehResearchCast(speakers),renderCastList(),populateNarratorSelect(),toast(n?`Matched ${n} character${n!==1?"s":""} & added notes`:"No good matches \u2014 try \u201CDesign all voices\u201D instead",n?"success":"error")}catch(e){toast("Match failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig}});const REH_FISH_LANG={English:"en",German:"de",French:"fr",Spanish:"es",Italian:"it",Portuguese:"pt",Dutch:"nl",Auto:""};(_ya=$("reh-matchonline-btn"))==null||_ya.addEventListener("click",async()=>{var _a2,_b2,_c2,_d2,_e2,_f2;const btn=$("reh-matchonline-btn"),speakers=Object.keys(rehState.cast).filter(sp=>rehState.cast[sp].voice!=="me"&&sp!==REH_NARRATOR_KEY);if(!speakers.length){toast("No characters to match","error");return}const lang=(_b2=REH_FISH_LANG[((_a2=$("reh-design-lang"))==null?void 0:_a2.value)||"English"])!=null?_b2:"en",prog=$("reh-autodesign-progress"),fill=$("reh-autodesign-fill"),label=$("reh-autodesign-label"),setProg=(d,t,msg)=>{fill&&(fill.style.width=(t?d/t*100:0)+"%"),label&&(label.textContent=msg||`${d} / ${t}`)},orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Analyzing\u2026',prog&&(prog.hidden=!1),rehDesignCancelled=!1;try{let chars=[];try{const ar=await fetch("/api/analyze-characters",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({script:((_c2=$("reh-script-text"))==null?void 0:_c2.value.trim())||linesToScriptText(),names:speakers,llm_url:((_d2=$("reh-llm-url"))==null?void 0:_d2.value.trim())||rehDefaultLlmUrl(),model:((_e2=$("reh-llm-model"))==null?void 0:_e2.value)||"",language:((_f2=$("reh-design-lang"))==null?void 0:_f2.value)||"English"})});ar.ok&&(chars=(await ar.json()).characters||[])}catch{}const byName={};chars.forEach(c=>{c.name&&(byName[String(c.name).toUpperCase().trim()]=c)});const G={M:"male",F:"female",N:"neutral"};let done=0,n=0;for(let i=0;ir.json())).items||[]}catch{}let pick=items.find(v=>v.sample_audio),nameHit=!!pick;if(!pick){const fb=[`language=${lang}`,gender?`gender=${gender}`:"","page_size=12","sort_by=score",`page=${i%4+1}`].filter(Boolean);try{items=(await fetch("/api/fishaudio/voices?"+fb.join("&")).then(r=>r.json())).items||[]}catch{}pick=items.find(v=>v.sample_audio)}let cands=items.filter(v=>v.sample_audio).slice(0,6).map(v=>({title:v.title,sample_audio:v.sample_audio,image:v.image||"",gender:v.gender||"",language:v.language||lang||"",description:v.description||"",sample_text:v.sample_text||v.default_text||""}));if(!nameHit&&cands.length>1){const taken=new Set(Object.values(rehState.cast).map(c=>{var _a3,_b3,_c3;return(_c3=(_b3=(_a3=c.online)==null?void 0:_a3.candidates)==null?void 0:_b3[c.online.picked])==null?void 0:_c3.sample_audio}).filter(Boolean));cands=[...cands.slice(i%cands.length),...cands.slice(0,i%cands.length)].sort((a,b)=>(taken.has(a.sample_audio)?1:0)-(taken.has(b.sample_audio)?1:0))}if(cands.length)try{const vid=await _rehImportFishCandidate(sp,cands[0]);cands[0].voice_id=vid,rehState.cast[sp].voice=vid,rehState.cast[sp].voiceData=getVoiceData(vid),rehState.cast[sp].online={candidates:cands,picked:0},n++}catch(e){logErr("match-online import "+sp,e)}done++,setProg(done,speakers.length)}typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary().catch(()=>{}),renderCastList(),populateNarratorSelect(),toast(n?`Imported & matched ${n} online voice${n!==1?"s":""}`:"No online matches found",n?"success":"error")}catch(e){toast("Online match failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig,prog&&(prog.hidden=!0)}});function rehMarkCastDesigning(sp,state,msg,info){var _a2;const row=document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`);if(!row)return;let badge=row.querySelector(".reh-cast-design-badge");badge||(badge=document.createElement("span"),badge.className="reh-cast-design-badge",(_a2=row.querySelector("strong"))==null||_a2.after(badge)),badge.className="reh-cast-design-badge"+(state==="done"?" done":state==="err"?" err":""),badge.textContent=state==="designing"?"\u2728 designing\u2026":state==="done"?"\u2713 designed":"\u2717 failed",msg&&(badge.title=msg),row.classList.toggle("reh-cast-designing",state==="designing");const wrap=row.closest("div");let panel=wrap==null?void 0:wrap.querySelector(".reh-cast-design-panel");if(state==="designing"&&info){panel||(panel=document.createElement("div"),panel.className="reh-cast-design-panel",wrap.appendChild(panel));const genderIcon=info.gender==="M"?"\u2642":info.gender==="F"?"\u2640":"\u26A7",genderLabel=info.gender==="M"?"Male":info.gender==="F"?"Female":"Neutral";panel.innerHTML=`
${genderIcon} ${escHtml(genderLabel)} ${escHtml(info.language||"EN")} @@ -999,12 +1006,12 @@ This warms each voice so the engine caches its .pt and first playback is instant ${info.age?`${escHtml(info.age)}`:""}
${escHtml(info.desc||"")}
-
${escHtml(info.step||"Designing voice\u2026")}
`}else if(state==="done"&&panel){const descEl=panel.querySelector(".reh-cdp-desc"),metaEl=panel.querySelector(".reh-cdp-meta"),stepEl=panel.querySelector(".reh-cdp-step");if(stepEl&&stepEl.remove(),descEl){const t=descEl.textContent;t.length>160&&(descEl.textContent=t.slice(0,157)+"\u2026")}panel.classList.add("done")}else state==="err"&&panel&&panel.remove()}(_za=$("reh-start-btn"))==null||_za.addEventListener("click",()=>{var _a2,_b2;const backend=(_a2=$("reh-backend-select"))==null?void 0:_a2.value;if(!backend){toast("Select a backend first","error");return}rehState.backend=backend,rehState.narratorVoice=((_b2=$("reh-narrator-voice"))==null?void 0:_b2.value)||"",rehState.lineIndex=0,rehState.clips=[],rehState.playing=!1,rehState.synthCache.clear(),rehDecodedBuffers.clear(),rehState.practiceStart=null,rehState.practiceEnd=null,buildScriptPage(),showPhase(3),highlightCurrentLine()});const REH_STAGE_FONT_KEY="ttsvc_reh_stage_scale",REH_CAST_COLLAPSED_KEY="ttsvc_reh_cast_collapsed";function rehStageScale(){const v=parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY)||"1");return isNaN(v)?1:Math.max(.7,Math.min(2,v))}function rehApplyStageFont(){const scale=String(rehStageScale()),wrap=document.querySelector(".reh-page-wrap");wrap&&wrap.style.setProperty("--reh-stage-scale",scale);const page=$("reh-a4-page");page&&page.style.setProperty("--reh-stage-scale",scale)}function rehStageFontStep(delta){const next=Math.max(.7,Math.min(2,Math.round((rehStageScale()+delta)*100)/100));localStorage.setItem(REH_STAGE_FONT_KEY,String(next)),rehApplyStageFont()}function rehApplyCastCollapsed(){const row=$("reh-cast-row"),stage=$("reh-stage-area");if(!row)return;const collapsed=localStorage.getItem(REH_CAST_COLLAPSED_KEY)==="1";row.classList.toggle("is-collapsed",collapsed),stage&&stage.classList.toggle("side-collapsed",collapsed);const btn=$("reh-cast-toggle");if(btn){btn.setAttribute("aria-expanded",String(!collapsed)),btn.title=collapsed?"Expand character list":"Collapse to avatars";const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi "+(collapsed?"mdi-chevron-right":"mdi-chevron-left"))}}function rehToggleCast(){const collapsed=localStorage.getItem(REH_CAST_COLLAPSED_KEY)==="1";localStorage.setItem(REH_CAST_COLLAPSED_KEY,collapsed?"0":"1"),rehApplyCastCollapsed()}(_Aa=$("reh-font-inc"))==null||_Aa.addEventListener("click",()=>rehStageFontStep(.1)),(_Ba=$("reh-font-dec"))==null||_Ba.addEventListener("click",()=>rehStageFontStep(-.1)),(_Ca=$("reh-cast-toggle"))==null||_Ca.addEventListener("click",rehToggleCast);function buildScriptPage(){var _a2,_b2;const titleEl=$("reh-page-title");titleEl&&(titleEl.textContent=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Script");const castStrip=$("reh-cast-strip");if(castStrip){const names=Object.keys(rehState.cast),lineCountFor=sp=>rehState.lines.filter(l=>l.speaker===sp&&l.type==="dialog").length;castStrip.innerHTML=names.map(sp=>{const c=rehState.cast[sp],isMe=c.voice==="me",displayName=sp===REH_NARRATOR_KEY?"Narrator":sp;return`
- ${escHtml((displayName||"?")[0].toUpperCase())} - ${escHtml(displayName)} - ${isMe?'':""} - ${lineCountFor(sp)} -
`}).join(""),castStrip.querySelectorAll(".ab-char-item").forEach(item=>{item.addEventListener("click",()=>{const line=document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`);line&&line.scrollIntoView({behavior:"smooth",block:"center"})})});const lbl=$("reh-cast-toggle-label");lbl&&(lbl.textContent=`Characters (${names.length})`)}rehApplyStageFont(),rehApplyCastCollapsed();const linesEl=$("reh-script-lines");linesEl&&(linesEl.innerHTML=rehState.lines.map((line,i)=>{const isCached=rehState.synthCache.has(i),isStale=rehState.staleLines.has(i),synthDot=``,reSynthBtn=``,editBtn=``,note=rehState.lines[i].note||"",noteArea=`
+
${escHtml(info.step||"Designing voice\u2026")}
`}else if(state==="done"&&panel){const descEl=panel.querySelector(".reh-cdp-desc"),metaEl=panel.querySelector(".reh-cdp-meta"),stepEl=panel.querySelector(".reh-cdp-step");if(stepEl&&stepEl.remove(),descEl){const t=descEl.textContent;t.length>160&&(descEl.textContent=t.slice(0,157)+"\u2026")}panel.classList.add("done")}else state==="err"&&panel&&panel.remove()}(_za=$("reh-start-btn"))==null||_za.addEventListener("click",()=>{var _a2,_b2;const backend=(_a2=$("reh-backend-select"))==null?void 0:_a2.value;if(!backend){toast("Select a backend first","error");return}rehState.backend=backend,rehState.narratorVoice=((_b2=$("reh-narrator-voice"))==null?void 0:_b2.value)||"",rehState.lineIndex=0,rehState.clips=[],rehState.playing=!1,rehState.synthCache.clear(),rehDecodedBuffers.clear(),rehState.practiceStart=null,rehState.practiceEnd=null,buildScriptPage(),showPhase(3),highlightCurrentLine()});const REH_STAGE_FONT_KEY="ttsvc_reh_stage_scale",REH_CAST_COLLAPSED_KEY="ttsvc_reh_cast_collapsed";function rehStageScale(){const v=parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY)||"1");return isNaN(v)?1:Math.max(.7,Math.min(2,v))}function rehApplyStageFont(){const scale=String(rehStageScale()),wrap=document.querySelector(".reh-page-wrap");wrap&&wrap.style.setProperty("--reh-stage-scale",scale);const page=$("reh-a4-page");page&&page.style.setProperty("--reh-stage-scale",scale)}function rehStageFontStep(delta){const next=Math.max(.7,Math.min(2,Math.round((rehStageScale()+delta)*100)/100));localStorage.setItem(REH_STAGE_FONT_KEY,String(next)),rehApplyStageFont()}function rehApplyCastCollapsed(){const row=$("reh-cast-row"),stage=$("reh-stage-area");if(!row)return;const collapsed=localStorage.getItem(REH_CAST_COLLAPSED_KEY)==="1";row.classList.toggle("is-collapsed",collapsed),stage&&stage.classList.toggle("side-collapsed",collapsed);const btn=$("reh-cast-toggle");if(btn){btn.setAttribute("aria-expanded",String(!collapsed)),btn.title=collapsed?"Expand character list":"Collapse to avatars";const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi "+(collapsed?"mdi-chevron-right":"mdi-chevron-left"))}}function rehToggleCast(){const collapsed=localStorage.getItem(REH_CAST_COLLAPSED_KEY)==="1";localStorage.setItem(REH_CAST_COLLAPSED_KEY,collapsed?"0":"1"),rehApplyCastCollapsed()}(_Aa=$("reh-font-inc"))==null||_Aa.addEventListener("click",()=>rehStageFontStep(.1)),(_Ba=$("reh-font-dec"))==null||_Ba.addEventListener("click",()=>rehStageFontStep(-.1)),(_Ca=$("reh-cast-toggle"))==null||_Ca.addEventListener("click",rehToggleCast);function renderCastStrip(){var _a2,_b2,_c2;const castStrip=$("reh-cast-strip");if(!castStrip)return;const scriptTitle=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"";_rehEnsureLibCharsCache(scriptTitle);const names=Object.keys(rehState.cast),lineCountFor=sp=>rehState.lines.filter(l=>l.speaker===sp&&l.type==="dialog").length,libRecByNameStage=new Map((_rehLibCharsCache||[]).map(r=>[String(r.name||"").trim().toLowerCase(),r])),sortMode=((_b2=$("reh-cast-side-sort"))==null?void 0:_b2.value)||localStorage.getItem("reh_cast_side_sort")||"lines",query=(((_c2=$("reh-cast-side-search"))==null?void 0:_c2.value)||"").trim().toLowerCase();let rows=names.map(sp=>{const displayName=sp===REH_NARRATOR_KEY?"Narrator":sp,libRec=sp===REH_NARRATOR_KEY?null:libRecByNameStage.get(String(sp).trim().toLowerCase()),fallbackName=displayName==="Narrator"?displayName:displayName.replace(/\w\S*/g,w=>w[0].toUpperCase()+w.slice(1).toLowerCase());return{sp,displayName:(libRec==null?void 0:libRec.name)||fallbackName,n:lineCountFor(sp),libRec}});query&&(rows=rows.filter(r=>r.displayName.toLowerCase().includes(query))),rows.sort((a,b)=>sortMode==="name"?a.displayName.localeCompare(b.displayName):b.n-a.n||a.displayName.localeCompare(b.displayName)),castStrip.innerHTML=rows.map(({sp,displayName,n,libRec})=>{const c=rehState.cast[sp],isMe=c.voice==="me",avatarHtml=libRec!=null&&libRec.image?``:`${escHtml((displayName||"?")[0].toUpperCase())}`;return`
+ ${avatarHtml} + ${escHtml(displayName)} + ${isMe?'':""} + ${n} +
`}).join(""),castStrip.querySelectorAll(".ab-char-item").forEach(item=>{item.addEventListener("click",()=>{const line=document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`);line&&line.scrollIntoView({behavior:"smooth",block:"center"})})});const lbl=$("reh-cast-toggle-label");lbl&&(lbl.textContent=`Characters (${names.length})`);const searchInp=$("reh-cast-side-search"),sortSel=$("reh-cast-side-sort");searchInp&&!searchInp.dataset.wired&&(searchInp.dataset.wired="1",searchInp.addEventListener("input",()=>renderCastStrip())),sortSel&&!sortSel.dataset.wired&&(sortSel.dataset.wired="1",sortSel.value=sortMode,sortSel.addEventListener("change",()=>{localStorage.setItem("reh_cast_side_sort",sortSel.value),renderCastStrip()}))}function buildScriptPage(){var _a2,_b2;const titleEl=$("reh-page-title");titleEl&&(titleEl.textContent=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Script"),renderCastStrip(),rehApplyStageFont(),rehApplyCastCollapsed(),typeof _lineAudioSyncDots=="function"&&_lineAudioSyncDots().catch(()=>{});const linesEl=$("reh-script-lines");linesEl&&(linesEl.innerHTML=rehState.lines.map((line,i)=>{const isCached=rehState.synthCache.has(i),isStale=rehState.staleLines.has(i),synthDot=``,reSynthBtn=``,editBtn=``,note=rehState.lines[i].note||"",noteArea=`
${editBtn} @@ -1012,14 +1019,14 @@ This warms each voice so the engine caches its .pt and first playback is instant ${bulkCheck} ${escHtml(line.text)} ${editBtn} ${synthDot} -
`;case"action":return`
- ${bulkCheck}${renderMarkdownInline(line.text)} +
`;case"action":{const narrPlayBtn=``;return`
+ ${bulkCheck}${narrPlayBtn}${renderMarkdownInline(line.text)} ${editBtn} ${synthDot} -
`;case"pagebreak":{const pbLabel=line.page?`\u2014 Page ${line.page} \u2014`:"\u2014 Page break \u2014";return`
${bulkCheck}${pbLabel}
`}case"transition":return`
${bulkCheck}${escHtml(line.text)}
`;case"direction":{const indent=line.speaker?"text-align:center;":"";return`
${bulkCheck}${escHtml(line.text)}
`}case"dialog":{const c=rehState.cast[line.speaker]||{voice:"",color:"#89b4fa"},isMe=c.voice==="me",emoInfo=getEmotionInfo(line.emotion||"");return`
+
`}case"pagebreak":{const pbLabel=line.page?`\u2014 Page ${line.page} \u2014`:"\u2014 Page break \u2014";return`
${bulkCheck}${pbLabel}
`}case"transition":return`
${bulkCheck}${escHtml(line.text)}
`;case"direction":{const indent=line.speaker?"text-align:center;":"";return`
${bulkCheck}${escHtml(line.text)}
`}case"dialog":{const c=rehState.cast[line.speaker]||{voice:"",color:"#89b4fa"},isMe=c.voice==="me",emoInfo=getEmotionInfo(line.emotion||"");return`
${bulkCheck}
- + ${escHtml(line.speaker)} ${isMe?' Me':' TTS'} @@ -1033,7 +1040,7 @@ This warms each voice so the engine caches its .pt and first playback is instant
${renderMarkdownInline(line.text)}
${noteArea} -
`}default:return""}}).join(""),linesEl.querySelectorAll(".reh-emo-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),openEmoPicker(parseInt(btn.dataset.index),btn)})}),linesEl.querySelectorAll(".reh-edit-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),startInlineEdit(parseInt(btn.dataset.index))})}),linesEl.querySelectorAll(".reh-block-dialog").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-action-text").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-line-play-avatar").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);if(rehState.playing&&rehState.lineIndex===idx){pausePlay();return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()})}),linesEl.querySelectorAll(".reh-gutter-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);e.shiftKey?rehState.practiceEnd=rehState.practiceEnd===idx?null:idx:(stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()),updatePracticeRange()})}),linesEl.querySelectorAll("[data-index]").forEach(el=>{el.addEventListener("click",e=>{if(e.target.closest(".reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root"))return;const idx=parseInt(el.dataset.index);if(rehState.bulkMode){_toggleBulkSel(idx,e.shiftKey);return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine()})}),linesEl.querySelectorAll(".reh-resynth-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),synthOneLine(parseInt(btn.id.replace("reh-rsb-","")))})}),linesEl.querySelectorAll(".reh-note-area").forEach(area=>{const idx=parseInt(area.dataset.index),btn=area.querySelector(".reh-note-btn"),ta=area.querySelector(".reh-note-ta");!btn||!ta||(ta.value.trim()&&ta.classList.add("open"),btn.addEventListener("click",e=>{e.stopPropagation(),ta.classList.toggle("open"),ta.classList.contains("open")&&ta.focus()}),ta.addEventListener("input",()=>{rehState.lines[idx].note=ta.value,btn.classList.toggle("has-note",!!ta.value.trim())}),ta.addEventListener("click",e=>e.stopPropagation()))}),linesEl.classList.toggle("reh-bulk-mode",rehState.bulkMode),(_b2=document.querySelector(".reh-page-wrap"))==null||_b2.classList.toggle("reh-bulk-mode",rehState.bulkMode),rehState.bulkMode&&linesEl.querySelectorAll(".reh-bulk-check").forEach(cb=>{cb.addEventListener("click",e=>{e.stopPropagation(),_toggleBulkSel(parseInt(cb.dataset.bulk),e.shiftKey)})}),applyPageMode(),_checkToneStyleSupport())}function _toggleBulkSel(i,range=!1){if(Number.isFinite(i)){if(range&&rehState.bulkAnchor!==null&&Number.isFinite(rehState.bulkAnchor)){const a=Math.min(rehState.bulkAnchor,i),b=Math.max(rehState.bulkAnchor,i),indices=[];for(let n=a;n<=b;n++)rehState.lines[n]&&!rehState.lines[n].hidden&&indices.push(n);const deselect=indices.length&&indices.every(n=>rehState.bulkSel.has(n));indices.forEach(n=>{deselect?rehState.bulkSel.delete(n):rehState.bulkSel.add(n),_refreshBulkLine(n)}),rehState.bulkAnchor=i}else rehState.bulkSel.has(i)?rehState.bulkSel.delete(i):rehState.bulkSel.add(i),rehState.bulkAnchor=i,_refreshBulkLine(i);_updateBulkCount()}}function _refreshBulkLine(i){const sel=rehState.bulkSel.has(i);document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb=>{cb.classList.toggle("checked",sel);const icon=cb.querySelector(".mdi");icon&&(icon.className="mdi "+(sel?"mdi-checkbox-marked":"mdi-checkbox-blank-outline"))}),document.querySelectorAll(`[data-index="${i}"]`).forEach(el=>el.classList.toggle("reh-selected",sel))}function _updateBulkCount(){const el=$("reh-bulk-count");el&&(el.textContent=`${rehState.bulkSel.size} selected`)}function setBulkMode(on){rehState.bulkMode=on,on||(rehState.bulkSel.clear(),rehState.bulkAnchor=null);const bar=$("reh-bulk-bar");bar&&(bar.hidden=!on);const btn=$("reh-bulk-toggle");btn&&btn.classList.toggle("active",on),_updateBulkCount(),rehState.lines.length&&buildScriptPage()}function _bulkApply(fn,{keepSelection=!1}={}){if(!rehState.bulkSel.size){toast("No lines selected","error");return}[...rehState.bulkSel].forEach(i=>{const l=rehState.lines[i];l&&fn(l,i)}),keepSelection||rehState.bulkSel.clear(),_updateBulkCount(),buildScriptPage()}function _bulkDelete(){if(!rehState.bulkSel.size){toast("No lines selected","error");return}const victims=[...rehState.bulkSel].sort((a,b)=>b-a);victims.forEach(i=>{rehState.lines.splice(i,1),_reindexLineState(i)}),rehState.bulkSel.clear(),rehState.lineIndex>=rehState.lines.length&&(rehState.lineIndex=Math.max(0,rehState.lines.length-1)),_updateBulkCount(),buildScriptPage(),highlightCurrentLine(),toast(`Deleted ${victims.length} line${victims.length!==1?"s":""}`,"success")}function _reindexLineState(d){const shift=(collection,isMap)=>{const out=isMap?new Map:new Set;for(const entry of collection){const k=isMap?entry[0]:entry;if(k===d)continue;const nk=k>d?k-1:k;isMap?out.set(nk,entry[1]):out.add(nk)}return out};rehState.synthCache=shift(rehState.synthCache,!0),rehState.staleLines=shift(rehState.staleLines,!1);const sel=shift(rehState.bulkSel,!1);rehState.bulkSel.clear(),sel.forEach(v=>rehState.bulkSel.add(v)),rehState.practiceStart!=null&&rehState.practiceStart>d&&rehState.practiceStart--,rehState.practiceEnd!=null&&rehState.practiceEnd>d&&rehState.practiceEnd--,rehState.lineIndex>d&&rehState.lineIndex--}const PAGE_MODES=["auto","scroll","pdf"];let _pageMode=localStorage.getItem("reh-page-mode")||"auto";function applyPageMode(){const wrap=document.querySelector(".reh-page-wrap"),a4=$("reh-a4-page");wrap&&(_pageMode==="scroll"?(wrap.querySelectorAll(".reh-paper").forEach(p=>{[...p.children].forEach(c=>{c.classList.contains("reh-paper-num")||a4==null||a4.appendChild(c)}),p.remove()}),wrap.classList.remove("paginated"),a4&&(a4.style.display=""),document.querySelectorAll(".reh-block-pagebreak").forEach(el=>el.style.display="none")):paginateScript(_pageMode==="pdf"?{respectBreaks:!0}:{respectBreaks:!1}),_syncPageModeBtn())}function _syncPageModeBtn(){const btn=$("reh-page-mode-btn");if(!btn)return;const icons={auto:"mdi-file-document-outline",scroll:"mdi-format-align-justify",pdf:"mdi-book-open-page-variant"},labels={auto:"A4 pages",scroll:"Scroll",pdf:"PDF pages"};btn.innerHTML=` ${labels[_pageMode]||labels.auto}`,btn.title="Switch view: "+labels[_pageMode]}function cyclePageMode(){const idx=PAGE_MODES.indexOf(_pageMode);_pageMode=PAGE_MODES[(idx+1)%PAGE_MODES.length],localStorage.setItem("reh-page-mode",_pageMode),applyPageMode(),rehState.lines.length&&buildScriptPage()}function paginateScript({respectBreaks=!1}={}){const wrap=document.querySelector(".reh-page-wrap"),linesEl=$("reh-script-lines"),titleEl=$("reh-page-title");if(!wrap||!linesEl)return;const blocks=[...linesEl.children];if(!blocks.length)return;wrap.classList.add("paginated");const a4=$("reh-a4-page");a4&&(a4.style.display="none"),wrap.querySelectorAll(".reh-paper").forEach(p=>p.remove());const PAGE_CONTENT=1027,TITLE_SPACE=64;let pageNum=0,page=null,used=0;const newPage=()=>{pageNum++,page=document.createElement("div"),page.className="reh-paper";const num=document.createElement("div");if(num.className="reh-paper-num",num.textContent=pageNum+".",page.appendChild(num),used=0,pageNum===1&&titleEl){const t=titleEl.cloneNode(!0);t.style.display="",page.appendChild(t),used+=TITLE_SPACE}wrap.appendChild(page)};newPage();for(const block of blocks){if(respectBreaks&&block.classList.contains("reh-block-pagebreak")){newPage();continue}page.appendChild(block);const cs=getComputedStyle(block),h=block.offsetHeight+(parseFloat(cs.marginTop)||0)+(parseFloat(cs.marginBottom)||0);!respectBreaks&&used+h>PAGE_CONTENT&&used>(pageNum===1?TITLE_SPACE:0)&&(newPage(),page.appendChild(block)),used+=h}}function isPracticeRange(idx){const s=rehState.practiceStart,e=rehState.practiceEnd;return s===null?!1:idx>=s&&(e===null||idx<=e)}function startInlineEdit(idx){const line=rehState.lines[idx];if(!line)return;const dialogEl=document.getElementById("reh-diag-"+idx),actionEl=document.querySelector(`[data-index="${idx}"] .reh-action-text`),sceneEl=document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`),targetEl=dialogEl||actionEl||sceneEl;if(!targetEl||targetEl.tagName==="TEXTAREA")return;const orig=line.text,ta=document.createElement("textarea");ta.value=orig,ta.style.cssText="width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;";const toolbar=document.createElement("div");toolbar.className="reh-fmt-toolbar",toolbar.innerHTML=` +
`}default:return""}}).join(""),linesEl.querySelectorAll(".reh-emo-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),openEmoPicker(parseInt(btn.dataset.index),btn)})}),linesEl.querySelectorAll(".reh-edit-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),startInlineEdit(parseInt(btn.dataset.index))})}),linesEl.querySelectorAll(".reh-block-dialog").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-action-text").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-line-play-avatar").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);if(rehState.playing&&rehState.lineIndex===idx){pausePlay();return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()})}),linesEl.querySelectorAll(".reh-gutter-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);e.shiftKey?rehState.practiceEnd=rehState.practiceEnd===idx?null:idx:(stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()),updatePracticeRange()})}),linesEl.querySelectorAll("[data-index]").forEach(el=>{el.addEventListener("click",e=>{if(e.target.closest(".reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root"))return;const idx=parseInt(el.dataset.index);if(rehState.bulkMode){_toggleBulkSel(idx,e.shiftKey);return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine()})}),linesEl.querySelectorAll(".reh-resynth-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),synthOneLine(parseInt(btn.id.replace("reh-rsb-","")))})}),linesEl.querySelectorAll(".reh-note-area").forEach(area=>{const idx=parseInt(area.dataset.index),btn=area.querySelector(".reh-note-btn"),ta=area.querySelector(".reh-note-ta");!btn||!ta||(ta.value.trim()&&ta.classList.add("open"),btn.addEventListener("click",e=>{e.stopPropagation(),ta.classList.toggle("open"),ta.classList.contains("open")&&ta.focus()}),ta.addEventListener("input",()=>{rehState.lines[idx].note=ta.value,btn.classList.toggle("has-note",!!ta.value.trim())}),ta.addEventListener("click",e=>e.stopPropagation()))}),linesEl.classList.toggle("reh-bulk-mode",rehState.bulkMode),(_b2=document.querySelector(".reh-page-wrap"))==null||_b2.classList.toggle("reh-bulk-mode",rehState.bulkMode),rehState.bulkMode&&linesEl.querySelectorAll(".reh-bulk-check").forEach(cb=>{cb.addEventListener("click",e=>{e.stopPropagation(),_toggleBulkSel(parseInt(cb.dataset.bulk),e.shiftKey)})}),applyPageMode(),_checkToneStyleSupport())}function _toggleBulkSel(i,range=!1){if(Number.isFinite(i)){if(range&&rehState.bulkAnchor!==null&&Number.isFinite(rehState.bulkAnchor)){const a=Math.min(rehState.bulkAnchor,i),b=Math.max(rehState.bulkAnchor,i),indices=[];for(let n=a;n<=b;n++)rehState.lines[n]&&!rehState.lines[n].hidden&&indices.push(n);const deselect=indices.length&&indices.every(n=>rehState.bulkSel.has(n));indices.forEach(n=>{deselect?rehState.bulkSel.delete(n):rehState.bulkSel.add(n),_refreshBulkLine(n)}),rehState.bulkAnchor=i}else rehState.bulkSel.has(i)?rehState.bulkSel.delete(i):rehState.bulkSel.add(i),rehState.bulkAnchor=i,_refreshBulkLine(i);_updateBulkCount()}}function _refreshBulkLine(i){const sel=rehState.bulkSel.has(i);document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb=>{cb.classList.toggle("checked",sel);const icon=cb.querySelector(".mdi");icon&&(icon.className="mdi "+(sel?"mdi-checkbox-marked":"mdi-checkbox-blank-outline"))}),document.querySelectorAll(`[data-index="${i}"]`).forEach(el=>el.classList.toggle("reh-selected",sel))}function _updateBulkCount(){const el=$("reh-bulk-count");el&&(el.textContent=`${rehState.bulkSel.size} selected`)}function setBulkMode(on){rehState.bulkMode=on,on||(rehState.bulkSel.clear(),rehState.bulkAnchor=null);const bar=$("reh-bulk-bar");bar&&(bar.hidden=!on);const btn=$("reh-bulk-toggle");btn&&btn.classList.toggle("active",on),_updateBulkCount(),rehState.lines.length&&buildScriptPage()}function _bulkApply(fn,{keepSelection=!1}={}){if(!rehState.bulkSel.size){toast("No lines selected","error");return}[...rehState.bulkSel].forEach(i=>{const l=rehState.lines[i];l&&fn(l,i)}),keepSelection||rehState.bulkSel.clear(),_updateBulkCount(),buildScriptPage()}function _bulkDelete(){if(!rehState.bulkSel.size){toast("No lines selected","error");return}const victims=[...rehState.bulkSel].sort((a,b)=>b-a);victims.forEach(i=>{rehState.lines.splice(i,1),_reindexLineState(i)}),rehState.bulkSel.clear(),rehState.lineIndex>=rehState.lines.length&&(rehState.lineIndex=Math.max(0,rehState.lines.length-1)),_updateBulkCount(),buildScriptPage(),highlightCurrentLine(),toast(`Deleted ${victims.length} line${victims.length!==1?"s":""}`,"success")}function _reindexLineState(d){const shift=(collection,isMap)=>{const out=isMap?new Map:new Set;for(const entry of collection){const k=isMap?entry[0]:entry;if(k===d)continue;const nk=k>d?k-1:k;isMap?out.set(nk,entry[1]):out.add(nk)}return out};rehState.synthCache=shift(rehState.synthCache,!0),rehState.staleLines=shift(rehState.staleLines,!1);const sel=shift(rehState.bulkSel,!1);rehState.bulkSel.clear(),sel.forEach(v=>rehState.bulkSel.add(v)),rehState.practiceStart!=null&&rehState.practiceStart>d&&rehState.practiceStart--,rehState.practiceEnd!=null&&rehState.practiceEnd>d&&rehState.practiceEnd--,rehState.lineIndex>d&&rehState.lineIndex--}const PAGE_MODES=["auto","scroll","pdf"];let _pageMode=localStorage.getItem("reh-page-mode")||"pdf";function applyPageMode(){const wrap=document.querySelector(".reh-page-wrap"),a4=$("reh-a4-page");wrap&&(_pageMode==="scroll"?(wrap.querySelectorAll(".reh-paper").forEach(p=>{[...p.children].forEach(c=>{c.classList.contains("reh-paper-num")||a4==null||a4.appendChild(c)}),p.remove()}),wrap.classList.remove("paginated"),a4&&(a4.style.display=""),document.querySelectorAll(".reh-block-pagebreak").forEach(el=>el.style.display="none")):paginateScript(_pageMode==="pdf"?{respectBreaks:!0}:{respectBreaks:!1}),_syncPageModeBtn())}function _syncPageModeBtn(){const btn=$("reh-page-mode-btn");if(!btn)return;const icons={auto:"mdi-file-document-outline",scroll:"mdi-format-align-justify",pdf:"mdi-book-open-page-variant"},labels={auto:"A4 pages",scroll:"Scroll",pdf:"PDF pages"},nextMode=PAGE_MODES[(PAGE_MODES.indexOf(_pageMode)+1)%PAGE_MODES.length];btn.innerHTML=` ${labels[nextMode]||labels.auto}`,btn.title=`Currently: ${labels[_pageMode]} \u2014 click to switch to ${labels[nextMode]}`}function cyclePageMode(){const idx=PAGE_MODES.indexOf(_pageMode);_pageMode=PAGE_MODES[(idx+1)%PAGE_MODES.length],localStorage.setItem("reh-page-mode",_pageMode),applyPageMode(),rehState.lines.length&&buildScriptPage()}function paginateScript({respectBreaks=!1}={}){const wrap=document.querySelector(".reh-page-wrap"),linesEl=$("reh-script-lines"),titleEl=$("reh-page-title");if(!wrap||!linesEl)return;const blocks=[...linesEl.children];if(!blocks.length)return;wrap.classList.add("paginated");const a4=$("reh-a4-page");a4&&(a4.style.display="none"),wrap.querySelectorAll(".reh-paper").forEach(p=>p.remove());const PAGE_CONTENT=1027,TITLE_SPACE=64;let pageNum=0,page=null,used=0;const newPage=()=>{pageNum++,page=document.createElement("div"),page.className="reh-paper";const num=document.createElement("div");if(num.className="reh-paper-num",num.textContent=pageNum+".",page.appendChild(num),used=0,pageNum===1&&titleEl){const t=titleEl.cloneNode(!0);t.style.display="",page.appendChild(t),used+=TITLE_SPACE}wrap.appendChild(page)};newPage();for(const block of blocks){if(respectBreaks&&block.classList.contains("reh-block-pagebreak")){newPage();continue}page.appendChild(block);const cs=getComputedStyle(block),h=block.offsetHeight+(parseFloat(cs.marginTop)||0)+(parseFloat(cs.marginBottom)||0);!respectBreaks&&used+h>PAGE_CONTENT&&used>(pageNum===1?TITLE_SPACE:0)&&(newPage(),page.appendChild(block)),used+=h}}function isPracticeRange(idx){const s=rehState.practiceStart,e=rehState.practiceEnd;return s===null?!1:idx>=s&&(e===null||idx<=e)}function startInlineEdit(idx){const line=rehState.lines[idx];if(!line)return;const dialogEl=document.getElementById("reh-diag-"+idx),actionEl=document.querySelector(`[data-index="${idx}"] .reh-action-text`),sceneEl=document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`),targetEl=dialogEl||actionEl||sceneEl;if(!targetEl||targetEl.tagName==="TEXTAREA")return;const orig=line.text,ta=document.createElement("textarea");ta.value=orig,ta.style.cssText="width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;";const toolbar=document.createElement("div");toolbar.className="reh-fmt-toolbar",toolbar.innerHTML=` @@ -1068,7 +1075,7 @@ This warms each voice so the engine caches its .pt and first playback is instant ${e.emoji} ${escHtml(e.label)} ${line.emotion===e.value?'':""} -
`).join(""),listEl.querySelectorAll(".reh-emo-item").forEach(item=>{item.addEventListener("mousedown",e=>{e.preventDefault(),selectEmotion(idx,item.dataset.value,anchorBtn),closeEmoPicker()})})}searchEl.addEventListener("input",()=>renderList(searchEl.value)),searchEl.addEventListener("keydown",e=>{if(e.key==="Escape"&&closeEmoPicker(),e.key==="Enter"){const val=searchEl.value.trim();val&&(selectEmotion(idx,val,anchorBtn),closeEmoPicker())}}),applyBtn.addEventListener("mousedown",e=>{e.preventDefault();const val=searchEl.value.trim();if(!val)return;if(![...REH_EMOTIONS,...rehCustomEmotions].find(e2=>e2.value===val)){rehCustomEmotions.push({emoji:"\u2728",label:val,value:val,custom:!0});try{localStorage.setItem("reh-custom-emotions",JSON.stringify(rehCustomEmotions))}catch{}}selectEmotion(idx,val,anchorBtn),closeEmoPicker()}),renderList(),searchEl.focus(),setTimeout(()=>document.addEventListener("mousedown",_closePickerOnOutside),50)}function _closePickerOnOutside(e){rehEmoPicker&&!rehEmoPicker.contains(e.target)&&closeEmoPicker()}function closeEmoPicker(){rehEmoPicker&&(rehEmoPicker.remove(),rehEmoPicker=null),document.removeEventListener("mousedown",_closePickerOnOutside)}function _markSynthDot(idx,state){const dot=document.getElementById("reh-syd-"+idx);if(!dot)return;dot.className="reh-synth-dot"+(state==="stale"?" stale":"")+(state==="synthesizing"?" synthesizing":""),dot.style.display=state?"":"none",dot.title=state==="stale"?"Tone changed \u2014 needs re-synthesis":state==="synthesizing"?"Synthesizing\u2026":"Pre-synthesized";const block=dot.closest("[data-index]");block&&block.classList.toggle("reh-line-synthesizing",state==="synthesizing")}function _showReSynthBtn(idx,show){const btn=document.getElementById("reh-rsb-"+idx);btn&&(btn.hidden=!show)}async function synthOneLine(idx){const line=rehState.lines[idx];if(!line||line.type!=="dialog")return;const c=rehState.cast[line.speaker];if(!c||!c.voice||c.voice==="me")return;const instruct=_buildInstruct(c.instruct,line.emotion);_showReSynthBtn(idx,!1),_markSynthDot(idx,"synthesizing");try{const blob=await fetchTtsPreviewBlob(c.voice,_rehInlineTone(stripMarkdown(line.text),line.emotion),"wav",instruct,rehState.backend);rehState.synthCache.set(idx,blob),rehState.staleLines.delete(idx),preDecodeBlob(idx,blob),_markSynthDot(idx,"ok"),toast("Re-synthesized line "+(idx+1),"success")}catch(e){_markSynthDot(idx,null),_showReSynthBtn(idx,!0),toast("Synthesis failed: "+e.message,"error")}}function selectEmotion(idx,value,anchorBtn){rehState.lines[idx].emotion=value,rehState.synthCache.has(idx)&&(rehState.synthCache.delete(idx),rehState.staleLines.add(idx),_markSynthDot(idx,"stale"),_updateStaleBatchBtn()),_showReSynthBtn(idx,!0);const info=getEmotionInfo(value);anchorBtn.className="reh-emo-btn"+(value?" has-emotion":""),anchorBtn.innerHTML=`${info.emoji?info.emoji+" ":""}${escHtml(info.label)} `,value&&_checkToneStyleSupport()}function _checkToneStyleSupport(){const warn=$("reh-tone-warn");if(!warn)return;const txtEl=$("reh-tone-warn-txt"),b=typeof backendById=="function"?backendById(rehState.backend):null;if(!b){warn.hidden=!0;return}const all=typeof availableTtsBackends=="function"?availableTtsBackends():[],hasTone=rehState.lines.some(l=>l.type==="dialog"&&l.emotion);if(_rehBackendIsFish())txtEl&&(txtEl.innerHTML=`${escHtml(b.label)} keeps each character\u2019s voice consistent and applies tone. Per-line tones are sent as inline [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())(khi)&&rehDecodedBuffers.delete(k)}async function preDecodeBlob(lineIdx,blob){if(!rehDecodedBuffers.has(lineIdx))try{const ab=await blob.arrayBuffer(),buf=await rehPlayCtx().decodeAudioData(ab);rehDecodedBuffers.set(lineIdx,buf),_rehEvictDecoded(lineIdx)}catch{}}function computeWordTimings(text,durationSec){const words=stripMarkdown(text).split(/\s+/).filter(Boolean);if(words.length<2)return[];const totalChars=words.reduce((s,w)=>s+w.length,0)||1;let t=0;return words.map(w=>{const start=t;return t+=w.length/totalChars*durationSec,{word:w,start,end:t}})}function stopAudioSource(){if(rehCurrentSource){try{rehCurrentSource.stop(0)}catch{}rehCurrentSource=null}rehWordHighlightRaf&&(cancelAnimationFrame(rehWordHighlightRaf),rehWordHighlightRaf=null)}async function playPreDecoded(lineIdx,blob,text){rehDecodedBuffers.has(lineIdx)||await preDecodeBlob(lineIdx,blob),_rehEvictDecoded(lineIdx);const buf=rehDecodedBuffers.get(lineIdx);if(!buf){await playAudioBlobFallback(blob);return}const timings=computeWordTimings(text,buf.duration),dialogEl=document.getElementById("reh-diag-"+lineIdx);return dialogEl&&timings.length>=2&&(dialogEl.innerHTML=timings.map((t,i)=>`${escHtml(t.word)}`).join(" ")),new Promise(resolve=>{stopAudioSource();const ctx=rehPlayCtx(),src=ctx.createBufferSource();src.buffer=buf,src.connect(ctx.destination),rehCurrentSource=src;const t0=ctx.currentTime;src.onended=()=>{rehCurrentSource=null,rehWordHighlightRaf&&(cancelAnimationFrame(rehWordHighlightRaf),rehWordHighlightRaf=null),dialogEl&&timings.length>=2&&(dialogEl.innerHTML=renderMarkdownInline(text)),resolve()},src.start(0);const nextI=findNextCachedLine(lineIdx+1);if(nextI>=0&&preDecodeBlob(nextI,rehState.synthCache.get(nextI)),dialogEl&&timings.length>=2){const tick=()=>{if(rehCurrentSource!==src)return;const elapsed=ctx.currentTime-t0;let active=0;for(let i=timings.length-1;i>=0;i--)if(elapsed>=timings[i].start){active=i;break}dialogEl.querySelectorAll(".reh-word").forEach((span,i)=>{span.classList.toggle("reh-word-active",i===active)}),rehWordHighlightRaf=requestAnimationFrame(tick)};rehWordHighlightRaf=requestAnimationFrame(tick)}})}function findNextCachedLine(fromIdx){for(let i=fromIdx;i{audio.addEventListener("canplay",r,{once:!0}),setTimeout(r,3e3)}),await audio.play().catch(()=>{}),await waitForAudioEnd(audio))}function startPlay(){rehPlayCtx(),_ensureNarrator(),rehState.playing=!0,updatePlayBtn(),playNextLine()}function pausePlay(){rehState.playing=!1,updatePlayBtn(),stopAudioSource();const audio=$("reh-tts-audio");audio&&!audio.paused&&audio.pause(),hideStatusBar()}function stopPlay(){rehState.playing=!1,updatePlayBtn(),stopAudioSource();const audio=$("reh-tts-audio");audio&&(audio.pause(),audio.src=""),hideStatusBar()}function hideStatusBar(){const bar=$("reh-tts-status-bar");bar&&(bar.hidden=!0)}async function playNextLine(){var _a2,_b2,_c2;if(!rehState.playing)return;if(rehState.practiceEnd!==null&&rehState.lineIndex>rehState.practiceEnd){rehState.playing=!1,updatePlayBtn(),rehState.repeat?(rehState.lineIndex=(_a2=rehState.practiceStart)!=null?_a2:0,startPlay()):(rehState.lineIndex=(_b2=rehState.practiceStart)!=null?_b2:0,highlightCurrentLine());return}if(((_c2=rehState.lines[rehState.lineIndex])==null?void 0:_c2.type)==="pagebreak")return rehState.lineIndex++,playNextLine();const _cur=rehState.lines[rehState.lineIndex];if(_cur&&(_cur.ignored||_cur.hidden))return rehState.lineIndex++,playNextLine();if(rehState.skipDescriptions&&!rehState.narratorVoice)for(;rehState.lineIndexrehState.practiceEnd);)rehState.lineIndex++;if(rehState.lineIndex>=rehState.lines.length){if(rehState.playing=!1,updatePlayBtn(),rehState.repeat){rehState.lineIndex=0,startPlay();return}toast("Script finished","success");return}const line=rehState.lines[rehState.lineIndex];if(highlightCurrentLine(),line.type!=="dialog"){const hasText=!!(line.text||"").trim();if(rehState.narratorVoice&&hasText){showStatusBar("Narrator: "+line.text.slice(0,50)+(line.text.length>50?"\u2026":""));const cached2=rehState.synthCache.get(rehState.lineIndex);if(cached2)await playPreDecoded(rehState.lineIndex,cached2,line.text);else try{const blob=await fetchTtsPreviewBlob(rehState.narratorVoice,stripMarkdown(line.text),"wav","",rehState.backend);if(!rehState.playing)return;rehState.synthCache.set(rehState.lineIndex,blob),_markSynthDot(rehState.lineIndex,"ok"),await playPreDecoded(rehState.lineIndex,blob,line.text)}catch{await new Promise(r=>setTimeout(r,200))}}else rehState.skipDescriptions||await new Promise(r=>setTimeout(r,line.type==="direction"?150:250));if(!rehState.playing)return;rehState.lineIndex++,playNextLine();return}const cast=rehState.cast[line.speaker]||{voice:""};if(cast.voice==="me"){rehState.playing=!1,updatePlayBtn(),showRecOverlay(line);return}if(!cast.voice){if(showStatusBar(line.speaker+" has no voice \u2014 skipping\u2026"),await new Promise(r=>setTimeout(r,350)),!rehState.playing)return;rehState.lineIndex++,playNextLine();return}const profile=(cast.instruct||"").trim(),instruct=_buildInstruct(profile,line.emotion),cleanTxt=stripMarkdown(line.text),cached=rehState.synthCache.get(rehState.lineIndex);if(cached)showStatusBar(line.speaker+" is speaking\u2026"),await playPreDecoded(rehState.lineIndex,cached,line.text),rehState.clips.push({lineIndex:rehState.lineIndex,speaker:line.speaker,type:"tts",blob:cached});else{showStatusBar("Synthesizing\u2026");try{const blob=await fetchTtsPreviewBlob(cast.voice,_rehInlineTone(cleanTxt,line.emotion),"wav",instruct,rehState.backend);if(!rehState.playing)return;rehState.synthCache.set(rehState.lineIndex,blob),showStatusBar(line.speaker+" is speaking\u2026"),await playPreDecoded(rehState.lineIndex,blob,line.text),rehState.clips.push({lineIndex:rehState.lineIndex,speaker:line.speaker,type:"tts",blob})}catch(e){showStatusBar("TTS failed: "+e.message),await new Promise(r=>setTimeout(r,1e3))}}rehState.playing&&(rehState.lineIndex++,playNextLine())}function waitForAudioEnd(audio){return new Promise(resolve=>{if(!audio||audio.paused||audio.ended){resolve();return}audio.addEventListener("ended",resolve,{once:!0}),audio.addEventListener("pause",resolve,{once:!0}),audio.addEventListener("error",resolve,{once:!0})})}function showStatusBar(msg){const bar=$("reh-tts-status-bar");if(!bar)return;bar.hidden=!1;const txt=$("reh-tts-status-txt");txt&&(txt.textContent=msg)}function highlightCurrentLine(){const i=rehState.lineIndex,total=rehState.lines.length,prog=$("reh-tb-progress");prog&&(prog.style.width=(total?i/total*100:0)+"%");const lbl=$("reh-tb-label");lbl&&(lbl.textContent=`${i+1} / ${total}`),document.querySelectorAll("[data-index]").forEach(el=>{el.classList.toggle("reh-line-active",parseInt(el.dataset.index)===i)});const active=document.querySelector(`[data-index="${i}"]`);active&&active.scrollIntoView({behavior:"smooth",block:"center"}),updatePlayBtn()}function updatePlayBtn(){const btn=$("reh-tb-play");btn&&(btn.innerHTML=rehState.playing?'':'',btn.title=rehState.playing?"Pause":"Play all")}async function synthAll(){var _a2;if(rehState.synthRunning)return;if(!rehState.backend){toast("Select a TTS backend first","error");return}_ensureNarrator();const ttsLines=rehState.lines.map((l,i)=>({line:l,idx:i})).filter(({line})=>{if(line.ignored||line.hidden)return!1;if(line.type==="dialog"){const c=rehState.cast[line.speaker];return c&&c.voice&&c.voice!=="me"}return!!(rehState.narratorVoice&&(line.text||"").trim())});if(!ttsLines.length){toast("No TTS lines to synthesize","error");return}rehState.synthRunning=!0,rehState.synthCancelled=!1;const synthBar=$("reh-synth-bar"),fill=$("reh-synth-fill"),label=$("reh-synth-label");synthBar&&(synthBar.hidden=!1);const prog=(d,t)=>{fill&&(fill.style.width=(t?d/t*100:0)+"%"),label&&(label.textContent=`${d} / ${t} synthesized`)};prog(0,ttsLines.length);let done=0;for(const{line,idx}of ttsLines){if(rehState.synthCancelled)break;let voice,instruct;if(line.type==="dialog"){const c=rehState.cast[line.speaker];voice=c.voice,instruct=_buildInstruct(c.instruct,line.emotion)}else voice=rehState.narratorVoice,instruct="";_markSynthDot(idx,"synthesizing"),(_a2=document.getElementById("reh-syd-"+idx))==null||_a2.scrollIntoView({behavior:"smooth",block:"nearest"});try{const blob=await fetchTtsPreviewBlob(voice,_rehInlineTone(stripMarkdown(line.text),line.emotion),"wav",instruct,rehState.backend);rehState.synthCache.set(idx,blob),rehState.staleLines.delete(idx),_markSynthDot(idx,"ok"),_showReSynthBtn(idx,!1),preDecodeBlob(idx,blob)}catch{_markSynthDot(idx,null)}prog(++done,ttsLines.length)}synthBar&&(synthBar.hidden=!0),rehState.synthRunning=!1,rehState.synthCancelled||toast(`Pre-synthesized ${done} of ${ttsLines.length} lines \u2014 ready for instant playback`,"success")}function _updateStaleBatchBtn(){const btn=$("reh-tb-resynth-stale");btn&&(btn.hidden=rehState.staleLines.size===0)}(_Va=$("reh-tb-synth-all"))==null||_Va.addEventListener("click",()=>synthAll()),(_Wa=$("reh-tb-resynth-stale"))==null||_Wa.addEventListener("click",async()=>{if(rehState.synthRunning)return;const stale=[...rehState.staleLines];if(!stale.length)return;rehState.synthRunning=!0,rehState.synthCancelled=!1;const synthBar=$("reh-synth-bar"),fill=$("reh-synth-fill"),label=$("reh-synth-label");synthBar&&(synthBar.hidden=!1);let done=0;for(const idx of stale){if(rehState.synthCancelled)break;const line=rehState.lines[idx];if(!line||line.type!=="dialog"){rehState.staleLines.delete(idx);continue}const c=rehState.cast[line.speaker];if(!c||!c.voice||c.voice==="me"){rehState.staleLines.delete(idx);continue}const instruct=[c.instruct||"",line.emotion||""].filter(Boolean).join(". ");_markSynthDot(idx,"synthesizing");try{const blob=await fetchTtsPreviewBlob(c.voice,_rehInlineTone(stripMarkdown(line.text),line.emotion),"wav",instruct,rehState.backend);rehState.synthCache.set(idx,blob),rehState.staleLines.delete(idx),preDecodeBlob(idx,blob),_markSynthDot(idx,"ok"),_showReSynthBtn(idx,!1)}catch{_markSynthDot(idx,"stale")}fill&&(fill.style.width=++done/stale.length*100+"%"),label&&(label.textContent=`${done} / ${stale.length} synthesized`)}synthBar&&(synthBar.hidden=!0),rehState.synthRunning=!1,_updateStaleBatchBtn(),rehState.synthCancelled||toast(`Re-synthesized ${done} stale line${done!==1?"s":""}`,"success")}),(_Xa=$("reh-synth-cancel"))==null||_Xa.addEventListener("click",()=>{rehState.synthCancelled=!0,rehState.synthRunning=!1});function showRecOverlay(line){const overlay=$("reh-rec-overlay");if(!overlay)return;overlay.hidden=!1;const cue=$("reh-rec-cue"),c=rehState.cast[line.speaker]||{color:"#89b4fa"};cue&&(cue.innerHTML=`${escHtml(line.speaker)} \u2014 your line:
${escHtml(stripMarkdown(line.text))}
`),$("reh-rec-preview")&&($("reh-rec-preview").style.display="none",$("reh-rec-preview").src=""),$("reh-rec-confirm-row")&&($("reh-rec-confirm-row").hidden=!0),$("reh-rec-start")&&($("reh-rec-start").disabled=!1),$("reh-rec-stop")&&($("reh-rec-stop").disabled=!0),$("reh-rec-time")&&($("reh-rec-time").textContent="0:00"),rehState.lastRecBlob=null}function hideRecOverlay(){const overlay=$("reh-rec-overlay");overlay&&(overlay.hidden=!0),stopRehMic()}function rehRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("reh-mic-meter");if(!meter)return;if(!meter.children.length)for(let i=0;i<18;i++){const b=document.createElement("div");b.className="bar",meter.appendChild(b)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))});const el=$("reh-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function rehStartMeter(){if(!rehState.recAnalyser)return;rehState.recMeterRaf&&cancelAnimationFrame(rehState.recMeterRaf);const data=new Float32Array(rehState.recAnalyser.fftSize),canvas=$("reh-live-wave"),RING=300,ADD=10;rehState.recWaveRing=new Float32Array(RING);const tick=()=>{rehState.recAnalyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const s of data)sum+=s*s,peak=Math.max(peak,Math.abs(s));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0;if(rehRenderMeter((db+60)/60,db,peak>.98),canvas&&rehState.recWaveRing){const ring=rehState.recWaveRing;ring.copyWithin(0,ADD);for(let i=0;i.98?"#f38ba8":db>-12?"#f9e2af":"#a6e3a1",ctx.lineWidth=1.5;const mid=h/2;for(let i=0;i{try{n&&n.disconnect()}catch{}}),rehState.recStream&&rehState.recStream.getTracks().forEach(t=>t.stop()),rehState.recDestStream&&rehState.recDestStream.getTracks().forEach(t=>t.stop()),rehState.recAudioCtx&&rehState.recAudioCtx.close().catch(()=>{}),Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null}),rehRenderMeter();const wc=$("reh-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height)}(_Ya=$("reh-rec-start"))==null||_Ya.addEventListener("click",async()=>{try{await startRehMic(),rehState.recChunks=[],rehState.recSecs=0,$("reh-rec-time")&&($("reh-rec-time").textContent="0:00"),$("reh-rec-start")&&($("reh-rec-start").disabled=!0),$("reh-rec-stop")&&($("reh-rec-stop").disabled=!1),$("reh-rec-confirm-row")&&($("reh-rec-confirm-row").hidden=!0),rehState.recTimer=setInterval(()=>{rehState.recSecs++,$("reh-rec-time")&&($("reh-rec-time").textContent=Math.floor(rehState.recSecs/60)+":"+String(rehState.recSecs%60).padStart(2,"0"))},1e3),rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256e3}),rehState.mediaRec.ondataavailable=e=>{e.data.size&&rehState.recChunks.push(e.data)},rehState.mediaRec.onstop=()=>{clearInterval(rehState.recTimer),$("reh-rec-start")&&($("reh-rec-start").disabled=!1),$("reh-rec-stop")&&($("reh-rec-stop").disabled=!0);const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||"audio/webm"}),url=URL.createObjectURL(blob),p=$("reh-rec-preview");p&&(p.src=url,p.style.display=""),$("reh-rec-confirm-row")&&($("reh-rec-confirm-row").hidden=!1),rehState.lastRecBlob=blob},rehState.mediaRec.start(100)}catch(e){stopRehMic(),toast(await microphoneErrorMessage(e),"error")}}),(_Za=$("reh-rec-stop"))==null||_Za.addEventListener("click",()=>{var _a2;((_a2=rehState.mediaRec)==null?void 0:_a2.state)!=="inactive"&&rehState.mediaRec.stop()}),(__a=$("reh-rec-keep"))==null||__a.addEventListener("click",()=>{var _a2;rehState.lastRecBlob&&rehState.clips.push({lineIndex:rehState.lineIndex,speaker:(_a2=rehState.lines[rehState.lineIndex])==null?void 0:_a2.speaker,type:"me",blob:rehState.lastRecBlob}),stopRehMic(),hideRecOverlay(),rehState.lineIndex++,startPlay()}),(_$a=$("reh-rec-redo"))==null||_$a.addEventListener("click",()=>{stopRehMic();const l=rehState.lines[rehState.lineIndex];l&&showRecOverlay(l)}),(_ab=$("reh-skip-line"))==null||_ab.addEventListener("click",()=>{var _a2;rehState.clips.push({lineIndex:rehState.lineIndex,speaker:(_a2=rehState.lines[rehState.lineIndex])==null?void 0:_a2.speaker,type:"skip"}),stopRehMic(),hideRecOverlay(),rehState.lineIndex++,startPlay()});function renderSummary(){const list=$("reh-summary-list");if(list){if(!rehState.clips.length){list.innerHTML='

No 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`
+
`).join(""),listEl.querySelectorAll(".reh-emo-item").forEach(item=>{item.addEventListener("mousedown",e=>{e.preventDefault(),selectEmotion(idx,item.dataset.value,anchorBtn),closeEmoPicker()})})}searchEl.addEventListener("input",()=>renderList(searchEl.value)),searchEl.addEventListener("keydown",e=>{if(e.key==="Escape"&&closeEmoPicker(),e.key==="Enter"){const val=searchEl.value.trim();val&&(selectEmotion(idx,val,anchorBtn),closeEmoPicker())}}),applyBtn.addEventListener("mousedown",e=>{e.preventDefault();const val=searchEl.value.trim();if(!val)return;if(![...REH_EMOTIONS,...rehCustomEmotions].find(e2=>e2.value===val)){rehCustomEmotions.push({emoji:"\u2728",label:val,value:val,custom:!0});try{localStorage.setItem("reh-custom-emotions",JSON.stringify(rehCustomEmotions))}catch{}}selectEmotion(idx,val,anchorBtn),closeEmoPicker()}),renderList(),searchEl.focus(),setTimeout(()=>document.addEventListener("mousedown",_closePickerOnOutside),50)}function _closePickerOnOutside(e){rehEmoPicker&&!rehEmoPicker.contains(e.target)&&closeEmoPicker()}function closeEmoPicker(){rehEmoPicker&&(rehEmoPicker.remove(),rehEmoPicker=null),document.removeEventListener("mousedown",_closePickerOnOutside)}function _markSynthDot(idx,state){const dot=document.getElementById("reh-syd-"+idx);if(!dot)return;dot.className="reh-synth-dot"+(state==="stale"?" stale":"")+(state==="synthesizing"?" synthesizing":""),dot.style.display=state?"":"none",dot.title=state==="stale"?"Tone changed \u2014 needs re-synthesis":state==="synthesizing"?"Synthesizing\u2026":"Pre-synthesized";const block=dot.closest("[data-index]");block&&block.classList.toggle("reh-line-synthesizing",state==="synthesizing")}function _showReSynthBtn(idx,show){const btn=document.getElementById("reh-rsb-"+idx);btn&&(btn.hidden=!show)}function _rehSyncCastVoiceFromLibrary(rec){if(!window.rehState||!rehState.lines||!rehState.lines.length||!rec||!rec.name||!rec.voice)return;const openBook=typeof _lineAudioBookName=="function"?_lineAudioBookName():"";if(!openBook||String(rec.book||"").trim().toLowerCase()!==openBook.trim().toLowerCase())return;const target=String(rec.name).toUpperCase().trim(),sp=Object.keys(rehState.cast).find(k=>String(k).toUpperCase().trim()===target);if(!sp)return;const c=rehState.cast[sp];!c||c.voice===rec.voice||(c.voice=rec.voice,c.voiceData=getVoiceData(rec.voice),rehState.lines.forEach((line,idx)=>{line.type==="dialog"&&line.speaker===sp&&rehState.synthCache.has(idx)&&(rehState.synthCache.delete(idx),rehState.staleLines.add(idx),typeof _markSynthDot=="function"&&_markSynthDot(idx,"stale"))}),typeof _updateStaleBatchBtn=="function"&&_updateStaleBatchBtn(),typeof renderCastList=="function"&&renderCastList())}window._rehSyncCastVoiceFromLibrary=_rehSyncCastVoiceFromLibrary;async function synthOneLine(idx){const line=rehState.lines[idx];if(!line||line.type!=="dialog")return;const c=rehState.cast[line.speaker];if(!c||!c.voice||c.voice==="me")return;const instruct=_buildInstruct(c.instruct,line.emotion);_showReSynthBtn(idx,!1),_markSynthDot(idx,"synthesizing");try{const blob=await fetchTtsPreviewBlob(c.voice,_rehInlineTone(stripMarkdown(line.text),line.emotion),"wav",instruct,rehState.backend);rehState.synthCache.set(idx,blob),rehState.staleLines.delete(idx),preDecodeBlob(idx,blob),_markSynthDot(idx,"ok"),toast("Re-synthesized line "+(idx+1),"success")}catch(e){_markSynthDot(idx,null),_showReSynthBtn(idx,!0),toast("Synthesis failed: "+e.message,"error")}}function selectEmotion(idx,value,anchorBtn){rehState.lines[idx].emotion=value,rehState.synthCache.has(idx)&&(rehState.synthCache.delete(idx),rehState.staleLines.add(idx),_markSynthDot(idx,"stale"),_updateStaleBatchBtn()),_showReSynthBtn(idx,!0);const info=getEmotionInfo(value);anchorBtn.className="reh-emo-btn"+(value?" has-emotion":""),anchorBtn.innerHTML=`${info.emoji?info.emoji+" ":""}${escHtml(info.label)} `,value&&_checkToneStyleSupport()}function _checkToneStyleSupport(){const warn=$("reh-tone-warn");if(!warn)return;const txtEl=$("reh-tone-warn-txt"),b=typeof backendById=="function"?backendById(rehState.backend):null;if(!b){warn.hidden=!0;return}const all=typeof availableTtsBackends=="function"?availableTtsBackends():[],hasTone=rehState.lines.some(l=>l.type==="dialog"&&l.emotion);if(_rehBackendIsFish())txtEl&&(txtEl.innerHTML=`${escHtml(b.label)} keeps each character\u2019s voice consistent and applies tone. Per-line tones are sent as inline [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())(khi)&&rehDecodedBuffers.delete(k)}async function preDecodeBlob(lineIdx,blob){if(!rehDecodedBuffers.has(lineIdx))try{const ab=await blob.arrayBuffer(),buf=await rehPlayCtx().decodeAudioData(ab);rehDecodedBuffers.set(lineIdx,buf),_rehEvictDecoded(lineIdx)}catch{}}function computeWordTimings(text,durationSec){const words=stripMarkdown(text).split(/\s+/).filter(Boolean);if(words.length<2)return[];const totalChars=words.reduce((s,w)=>s+w.length,0)||1;let t=0;return words.map(w=>{const start=t;return t+=w.length/totalChars*durationSec,{word:w,start,end:t}})}function stopAudioSource(){if(rehCurrentSource){try{rehCurrentSource.stop(0)}catch{}rehCurrentSource=null}rehWordHighlightRaf&&(cancelAnimationFrame(rehWordHighlightRaf),rehWordHighlightRaf=null)}async function playPreDecoded(lineIdx,blob,text){rehDecodedBuffers.has(lineIdx)||await preDecodeBlob(lineIdx,blob),_rehEvictDecoded(lineIdx);const buf=rehDecodedBuffers.get(lineIdx);if(!buf){await playAudioBlobFallback(blob);return}const timings=computeWordTimings(text,buf.duration),dialogEl=document.getElementById("reh-diag-"+lineIdx);return dialogEl&&timings.length>=2&&(dialogEl.innerHTML=timings.map((t,i)=>`${escHtml(t.word)}`).join(" ")),new Promise(resolve=>{stopAudioSource();const ctx=rehPlayCtx(),src=ctx.createBufferSource();src.buffer=buf,src.connect(ctx.destination),rehCurrentSource=src;const t0=ctx.currentTime;src.onended=()=>{rehCurrentSource=null,rehWordHighlightRaf&&(cancelAnimationFrame(rehWordHighlightRaf),rehWordHighlightRaf=null),dialogEl&&timings.length>=2&&(dialogEl.innerHTML=renderMarkdownInline(text)),resolve()},src.start(0);const nextI=findNextCachedLine(lineIdx+1);if(nextI>=0&&preDecodeBlob(nextI,rehState.synthCache.get(nextI)),dialogEl&&timings.length>=2){const tick=()=>{if(rehCurrentSource!==src)return;const elapsed=ctx.currentTime-t0;let active=0;for(let i=timings.length-1;i>=0;i--)if(elapsed>=timings[i].start){active=i;break}dialogEl.querySelectorAll(".reh-word").forEach((span,i)=>{span.classList.toggle("reh-word-active",i===active)}),rehWordHighlightRaf=requestAnimationFrame(tick)};rehWordHighlightRaf=requestAnimationFrame(tick)}})}function findNextCachedLine(fromIdx){for(let i=fromIdx;i{audio.addEventListener("canplay",r,{once:!0}),setTimeout(r,3e3)}),await audio.play().catch(()=>{}),await waitForAudioEnd(audio))}function startPlay(){rehPlayCtx(),_ensureNarrator(),rehState.playing=!0,updatePlayBtn(),playNextLine()}function _rehResetActionPlayIcons(){document.querySelectorAll(".reh-action-play-btn").forEach(btn=>{const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-play"),btn.title="Play or pause this line"})}function pausePlay(){rehState.playing=!1,updatePlayBtn(),stopAudioSource();const audio=$("reh-tts-audio");audio&&!audio.paused&&audio.pause(),hideStatusBar(),_rehResetActionPlayIcons()}function stopPlay(){rehState.playing=!1,updatePlayBtn(),stopAudioSource();const audio=$("reh-tts-audio");audio&&(audio.pause(),audio.src=""),hideStatusBar(),_rehResetActionPlayIcons()}function hideStatusBar(){const bar=$("reh-tts-status-bar");bar&&(bar.hidden=!0)}async function playNextLine(){var _a2,_b2,_c2;if(!rehState.playing)return;if(rehState.practiceEnd!==null&&rehState.lineIndex>rehState.practiceEnd){rehState.playing=!1,updatePlayBtn(),rehState.repeat?(rehState.lineIndex=(_a2=rehState.practiceStart)!=null?_a2:0,startPlay()):(rehState.lineIndex=(_b2=rehState.practiceStart)!=null?_b2:0,highlightCurrentLine());return}if(((_c2=rehState.lines[rehState.lineIndex])==null?void 0:_c2.type)==="pagebreak")return rehState.lineIndex++,playNextLine();const _cur=rehState.lines[rehState.lineIndex];if(_cur&&(_cur.ignored||_cur.hidden))return rehState.lineIndex++,playNextLine();if(rehState.skipDescriptions&&!rehState.narratorVoice)for(;rehState.lineIndexrehState.practiceEnd);)rehState.lineIndex++;if(rehState.lineIndex>=rehState.lines.length){if(rehState.playing=!1,updatePlayBtn(),rehState.repeat){rehState.lineIndex=0,startPlay();return}toast("Script finished","success");return}const myLineIndex=rehState.lineIndex,stillCurrent=()=>rehState.playing&&rehState.lineIndex===myLineIndex,line=rehState.lines[rehState.lineIndex];if(highlightCurrentLine(),line.type!=="dialog"){const hasText=!!(line.text||"").trim();if(rehState.narratorVoice&&hasText){showStatusBar("Narrator: "+line.text.slice(0,50)+(line.text.length>50?"\u2026":""));const cached2=rehState.synthCache.get(rehState.lineIndex);if(cached2)await playPreDecoded(rehState.lineIndex,cached2,line.text);else try{const book=_lineAudioBookName(),cleanNarr=stripMarkdown(line.text),cacheKey=await _lineAudioCacheKey(cleanNarr,rehState.narratorVoice,"");if(!stillCurrent())return;let blob=await _lineAudioCacheGet(book,cacheKey);if(!stillCurrent())return;if(!blob){if(blob=await fetchTtsPreviewBlob(rehState.narratorVoice,cleanNarr,"wav","",rehState.backend),!stillCurrent())return;_lineAudioCachePut(book,cacheKey,blob)}rehState.synthCache.set(myLineIndex,blob),_markSynthDot(myLineIndex,"ok"),await playPreDecoded(myLineIndex,blob,line.text)}catch{await new Promise(r=>setTimeout(r,200))}}else rehState.skipDescriptions||await new Promise(r=>setTimeout(r,line.type==="direction"?150:250));if(!stillCurrent())return;rehState.lineIndex++,playNextLine();return}const cast=rehState.cast[line.speaker]||{voice:""};if(cast.voice==="me"){rehState.playing=!1,updatePlayBtn(),showRecOverlay(line);return}if(!cast.voice){if(showStatusBar(line.speaker+" has no voice \u2014 skipping\u2026"),await new Promise(r=>setTimeout(r,350)),!stillCurrent())return;rehState.lineIndex++,playNextLine();return}const profile=(cast.instruct||"").trim(),instruct=_buildInstruct(profile,line.emotion),cleanTxt=stripMarkdown(line.text),cached=rehState.synthCache.get(rehState.lineIndex);if(cached)showStatusBar(line.speaker+" is speaking\u2026"),await playPreDecoded(rehState.lineIndex,cached,line.text),rehState.clips.push({lineIndex:rehState.lineIndex,speaker:line.speaker,type:"tts",blob:cached});else{showStatusBar("Synthesizing\u2026");try{const toneText=_rehInlineTone(cleanTxt,line.emotion),book=_lineAudioBookName(),cacheKey=await _lineAudioCacheKey(toneText,cast.voice,instruct);if(!stillCurrent())return;let blob=await _lineAudioCacheGet(book,cacheKey);if(!stillCurrent())return;if(!blob){if(blob=await fetchTtsPreviewBlob(cast.voice,toneText,"wav",instruct,rehState.backend),!stillCurrent())return;_lineAudioCachePut(book,cacheKey,blob)}rehState.synthCache.set(myLineIndex,blob),showStatusBar(line.speaker+" is speaking\u2026"),await playPreDecoded(myLineIndex,blob,line.text),rehState.clips.push({lineIndex:myLineIndex,speaker:line.speaker,type:"tts",blob})}catch(e){showStatusBar("TTS failed: "+e.message),await new Promise(r=>setTimeout(r,1e3))}}stillCurrent()&&(rehState.lineIndex++,playNextLine())}function waitForAudioEnd(audio){return new Promise(resolve=>{if(!audio||audio.paused||audio.ended){resolve();return}audio.addEventListener("ended",resolve,{once:!0}),audio.addEventListener("pause",resolve,{once:!0}),audio.addEventListener("error",resolve,{once:!0})})}function showStatusBar(msg){const bar=$("reh-tts-status-bar");if(!bar)return;bar.hidden=!1;const txt=$("reh-tts-status-txt");txt&&(txt.textContent=msg)}function highlightCurrentLine(){const i=rehState.lineIndex,total=rehState.lines.length,prog=$("reh-tb-progress");prog&&(prog.style.width=(total?i/total*100:0)+"%");const lbl=$("reh-tb-label");lbl&&(lbl.textContent=`${i+1} / ${total}`),document.querySelectorAll("[data-index]").forEach(el=>{el.classList.toggle("reh-line-active",parseInt(el.dataset.index)===i)}),document.querySelectorAll(".reh-action-play-btn").forEach(btn=>{const icon=btn.querySelector(".mdi");if(!icon)return;const isActive=rehState.playing&&parseInt(btn.dataset.index)===i;icon.className=isActive?"mdi mdi-stop":"mdi mdi-play",btn.title=isActive?"Stop":"Play or pause this line"});const active=document.querySelector(`[data-index="${i}"]`);active&&active.scrollIntoView({behavior:"smooth",block:"center"}),updatePlayBtn()}function updatePlayBtn(){const btn=$("reh-tb-play");btn&&(btn.innerHTML=rehState.playing?'':'',btn.title=rehState.playing?"Pause":"Play all")}async function _lineAudioCacheKey(text,voice,instruct){const enc=new TextEncoder().encode(`${text}\0${voice}\0${instruct||""}`),digest=await crypto.subtle.digest("SHA-256",enc);return[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,"0")).join("").slice(0,32)}function _lineAudioBookName(){var _a2;return((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Untitled"}async function _lineAudioCacheGet(book,key){try{const r=await fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`);return r.ok?await r.blob():null}catch{return null}}function _lineAudioCachePut(book,key,blob){fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`,{method:"POST",body:blob}).catch(()=>{})}let _lineAudioSyncedFor=null;async function _lineAudioSyncDots(){const book=_lineAudioBookName(),scanId=`${book}::${rehState.lines.length}`;if(_lineAudioSyncedFor===scanId)return;_lineAudioSyncedFor=scanId,_ensureNarrator();const idxToKey=new Map;for(let i=0;i[k,i]));try{const r=await fetch(`/api/line-audio/${encodeURIComponent(book)}/check`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({keys:[...idxToKey.values()]})});if(!r.ok)return;const{existing}=await r.json();(existing||[]).forEach(key=>{const idx=keyToIdx.get(key);idx!=null&&(rehState.staleLines.delete(idx),_markSynthDot(idx,"ok"))})}catch{}}async function synthAll(){var _a2;if(rehState.synthRunning)return;if(!rehState.backend){toast("Select a TTS backend first","error");return}_ensureNarrator();const ttsLines=rehState.lines.map((l,i)=>({line:l,idx:i})).filter(({line})=>{if(line.ignored||line.hidden)return!1;if(line.type==="dialog"){const c=rehState.cast[line.speaker];return c&&c.voice&&c.voice!=="me"}return!!(rehState.narratorVoice&&(line.text||"").trim())});if(!ttsLines.length){toast("No TTS lines to synthesize","error");return}rehState.synthRunning=!0,rehState.synthCancelled=!1;const synthBar=$("reh-synth-bar"),fill=$("reh-synth-fill"),label=$("reh-synth-label");synthBar&&(synthBar.hidden=!1);const prog=(d,t)=>{fill&&(fill.style.width=(t?d/t*100:0)+"%"),label&&(label.textContent=`${d} / ${t} synthesized`)};prog(0,ttsLines.length);let done=0;const book=_lineAudioBookName();try{for(const{line,idx}of ttsLines){if(rehState.synthCancelled)break;let voice,instruct;if(line.type==="dialog"){const c=rehState.cast[line.speaker];if(!c||!c.voice||c.voice==="me"){_markSynthDot(idx,null),prog(++done,ttsLines.length);continue}voice=c.voice,instruct=_buildInstruct(c.instruct,line.emotion)}else voice=rehState.narratorVoice,instruct="";_markSynthDot(idx,"synthesizing"),(_a2=document.getElementById("reh-syd-"+idx))==null||_a2.scrollIntoView({behavior:"smooth",block:"nearest"});const toneText=_rehInlineTone(stripMarkdown(line.text),line.emotion);try{const cacheKey=await _lineAudioCacheKey(toneText,voice,instruct);let blob=await _lineAudioCacheGet(book,cacheKey);blob||(blob=await fetchTtsPreviewBlob(voice,toneText,"wav",instruct,rehState.backend),_lineAudioCachePut(book,cacheKey,blob)),rehState.synthCache.set(idx,blob),rehState.staleLines.delete(idx),_markSynthDot(idx,"ok"),_showReSynthBtn(idx,!1),preDecodeBlob(idx,blob)}catch{_markSynthDot(idx,null)}prog(++done,ttsLines.length)}}finally{synthBar&&(synthBar.hidden=!0),rehState.synthRunning=!1}rehState.synthCancelled||toast(`Pre-synthesized ${done} of ${ttsLines.length} lines \u2014 ready for instant playback`,"success")}function _updateStaleBatchBtn(){const btn=$("reh-tb-resynth-stale");btn&&(btn.hidden=rehState.staleLines.size===0)}(_Va=$("reh-tb-synth-all"))==null||_Va.addEventListener("click",()=>synthAll()),(_Wa=$("reh-tb-resynth-stale"))==null||_Wa.addEventListener("click",async()=>{if(rehState.synthRunning)return;const stale=[...rehState.staleLines];if(!stale.length)return;rehState.synthRunning=!0,rehState.synthCancelled=!1;const synthBar=$("reh-synth-bar"),fill=$("reh-synth-fill"),label=$("reh-synth-label");synthBar&&(synthBar.hidden=!1);let done=0;const book=_lineAudioBookName();try{for(const idx of stale){if(rehState.synthCancelled)break;const line=rehState.lines[idx];if(!line||line.type!=="dialog"){rehState.staleLines.delete(idx);continue}const c=rehState.cast[line.speaker];if(!c||!c.voice||c.voice==="me"){rehState.staleLines.delete(idx);continue}const instruct=[c.instruct||"",line.emotion||""].filter(Boolean).join(". ");_markSynthDot(idx,"synthesizing");const toneText=_rehInlineTone(stripMarkdown(line.text),line.emotion);try{const cacheKey=await _lineAudioCacheKey(toneText,c.voice,instruct);let blob=await _lineAudioCacheGet(book,cacheKey);blob||(blob=await fetchTtsPreviewBlob(c.voice,toneText,"wav",instruct,rehState.backend),_lineAudioCachePut(book,cacheKey,blob)),rehState.synthCache.set(idx,blob),rehState.staleLines.delete(idx),preDecodeBlob(idx,blob),_markSynthDot(idx,"ok"),_showReSynthBtn(idx,!1)}catch{_markSynthDot(idx,"stale")}fill&&(fill.style.width=++done/stale.length*100+"%"),label&&(label.textContent=`${done} / ${stale.length} synthesized`)}}finally{synthBar&&(synthBar.hidden=!0),rehState.synthRunning=!1,_updateStaleBatchBtn()}rehState.synthCancelled||toast(`Re-synthesized ${done} stale line${done!==1?"s":""}`,"success")}),(_Xa=$("reh-synth-cancel"))==null||_Xa.addEventListener("click",()=>{rehState.synthCancelled=!0,rehState.synthRunning=!1}),(_Ya=$("reh-tb-clean-cache"))==null||_Ya.addEventListener("click",async()=>{const btn=$("reh-tb-clean-cache");btn&&(btn.disabled=!0,btn.innerHTML=' Scanning\u2026');try{_ensureNarrator();const keep=[];for(const line of rehState.lines){if(line.ignored||line.hidden)continue;let voice,instruct,text;if(line.type==="dialog"){const c=rehState.cast[line.speaker];if(!c||!c.voice||c.voice==="me")continue;voice=c.voice,instruct=_buildInstruct(c.instruct,line.emotion),text=_rehInlineTone(stripMarkdown(line.text),line.emotion)}else{if(!rehState.narratorVoice||!(line.text||"").trim())continue;voice=rehState.narratorVoice,instruct="",text=stripMarkdown(line.text)}keep.push(await _lineAudioCacheKey(text,voice,instruct))}const book=_lineAudioBookName(),r=await fetch(`/api/line-audio/${encodeURIComponent(book)}/prune`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({keep})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const d=await r.json();toast(d.deleted?`Cleaned up ${d.deleted} unused cached audio file${d.deleted!==1?"s":""}`:"Nothing to clean up \u2014 every cached file is still in use","success")}catch(e){toast("Cache cleanup failed: "+(e.message||e),"error")}finally{btn&&(btn.disabled=!1,btn.innerHTML=' Clean cache')}});function showRecOverlay(line){const overlay=$("reh-rec-overlay");if(!overlay)return;overlay.hidden=!1;const cue=$("reh-rec-cue"),c=rehState.cast[line.speaker]||{color:"#89b4fa"};cue&&(cue.innerHTML=`${escHtml(line.speaker)} \u2014 your line:
${escHtml(stripMarkdown(line.text))}
`),$("reh-rec-preview")&&($("reh-rec-preview").style.display="none",$("reh-rec-preview").src=""),$("reh-rec-confirm-row")&&($("reh-rec-confirm-row").hidden=!0),$("reh-rec-start")&&($("reh-rec-start").disabled=!1),$("reh-rec-stop")&&($("reh-rec-stop").disabled=!0),$("reh-rec-time")&&($("reh-rec-time").textContent="0:00"),rehState.lastRecBlob=null}function hideRecOverlay(){const overlay=$("reh-rec-overlay");overlay&&(overlay.hidden=!0),stopRehMic()}function rehRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("reh-mic-meter");if(!meter)return;if(!meter.children.length)for(let i=0;i<18;i++){const b=document.createElement("div");b.className="bar",meter.appendChild(b)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))});const el=$("reh-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function rehStartMeter(){if(!rehState.recAnalyser)return;rehState.recMeterRaf&&cancelAnimationFrame(rehState.recMeterRaf);const data=new Float32Array(rehState.recAnalyser.fftSize),canvas=$("reh-live-wave"),RING=300,ADD=10;rehState.recWaveRing=new Float32Array(RING);const tick=()=>{rehState.recAnalyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const s of data)sum+=s*s,peak=Math.max(peak,Math.abs(s));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0;if(rehRenderMeter((db+60)/60,db,peak>.98),canvas&&rehState.recWaveRing){const ring=rehState.recWaveRing;ring.copyWithin(0,ADD);for(let i=0;i.98?"#f38ba8":db>-12?"#f9e2af":"#a6e3a1",ctx.lineWidth=1.5;const mid=h/2;for(let i=0;i{try{n&&n.disconnect()}catch{}}),rehState.recStream&&rehState.recStream.getTracks().forEach(t=>t.stop()),rehState.recDestStream&&rehState.recDestStream.getTracks().forEach(t=>t.stop()),rehState.recAudioCtx&&rehState.recAudioCtx.close().catch(()=>{}),Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null}),rehRenderMeter();const wc=$("reh-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height)}(_Za=$("reh-rec-start"))==null||_Za.addEventListener("click",async()=>{try{await startRehMic(),rehState.recChunks=[],rehState.recSecs=0,$("reh-rec-time")&&($("reh-rec-time").textContent="0:00"),$("reh-rec-start")&&($("reh-rec-start").disabled=!0),$("reh-rec-stop")&&($("reh-rec-stop").disabled=!1),$("reh-rec-confirm-row")&&($("reh-rec-confirm-row").hidden=!0),rehState.recTimer=setInterval(()=>{rehState.recSecs++,$("reh-rec-time")&&($("reh-rec-time").textContent=Math.floor(rehState.recSecs/60)+":"+String(rehState.recSecs%60).padStart(2,"0"))},1e3),rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256e3}),rehState.mediaRec.ondataavailable=e=>{e.data.size&&rehState.recChunks.push(e.data)},rehState.mediaRec.onstop=()=>{clearInterval(rehState.recTimer),$("reh-rec-start")&&($("reh-rec-start").disabled=!1),$("reh-rec-stop")&&($("reh-rec-stop").disabled=!0);const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||"audio/webm"}),url=URL.createObjectURL(blob),p=$("reh-rec-preview");p&&(p.src=url,p.style.display=""),$("reh-rec-confirm-row")&&($("reh-rec-confirm-row").hidden=!1),rehState.lastRecBlob=blob},rehState.mediaRec.start(100)}catch(e){stopRehMic(),toast(await microphoneErrorMessage(e),"error")}}),(__a=$("reh-rec-stop"))==null||__a.addEventListener("click",()=>{var _a2;((_a2=rehState.mediaRec)==null?void 0:_a2.state)!=="inactive"&&rehState.mediaRec.stop()}),(_$a=$("reh-rec-keep"))==null||_$a.addEventListener("click",()=>{var _a2;rehState.lastRecBlob&&rehState.clips.push({lineIndex:rehState.lineIndex,speaker:(_a2=rehState.lines[rehState.lineIndex])==null?void 0:_a2.speaker,type:"me",blob:rehState.lastRecBlob}),stopRehMic(),hideRecOverlay(),rehState.lineIndex++,startPlay()}),(_ab=$("reh-rec-redo"))==null||_ab.addEventListener("click",()=>{stopRehMic();const l=rehState.lines[rehState.lineIndex];l&&showRecOverlay(l)}),(_bb=$("reh-skip-line"))==null||_bb.addEventListener("click",()=>{var _a2;rehState.clips.push({lineIndex:rehState.lineIndex,speaker:(_a2=rehState.lines[rehState.lineIndex])==null?void 0:_a2.speaker,type:"skip"}),stopRehMic(),hideRecOverlay(),rehState.lineIndex++,startPlay()});function renderSummary(){const list=$("reh-summary-list");if(list){if(!rehState.clips.length){list.innerHTML='

No 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`
${escHtml(clip.speaker||"\u2014")} ${typeLabel}
@@ -1076,7 +1083,7 @@ This warms each voice so the engine caches its .pt and first playback is instant ${audioHtml}
${dlHtml} -
`}).join("")}}(_bb=$("reh-new-session-btn"))==null||_bb.addEventListener("click",()=>{stopPlay(),stopRehMic(),rehState.lines=[],rehState.cast={},rehState.clips=[],rehState.lineIndex=0,rehState.savedId=null,rehState.synthCache.clear(),rehDecodedBuffers.clear(),rehState.narratorVoice="",rehState.practiceStart=null,rehState.practiceEnd=null,$("reh-script-text")&&($("reh-script-text").value=""),$("reh-script-title")&&($("reh-script-title").value=""),renderLibraryList(),showPhase(1)}),(_cb=$("reh-resume-btn"))==null||_cb.addEventListener("click",()=>{showPhase(3),highlightCurrentLine()}),(_db=$("reh-save-session-btn"))==null||_db.addEventListener("click",saveToLibrary),(_eb=$("reh-cast-save-btn"))==null||_eb.addEventListener("click",saveToLibrary),(_fb=$("reh-export-session-btn"))==null||_fb.addEventListener("click",exportToFile),(_gb=$("reh-tb-save"))==null||_gb.addEventListener("click",saveToLibrary),(_hb=$("reh-tb-export"))==null||_hb.addEventListener("click",exportToFile),(_ib=$("reh-lib-import-file"))==null||_ib.addEventListener("change",async function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];f&&(await importFromFile(f),this.value="")}),document.querySelectorAll(".reh-impex-file").forEach(inp=>{inp.addEventListener("change",async function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];f&&(await importFromFile(f),this.value="",navRehearserPhase(1))})}),(_jb=$("reh-impex-reh-btn"))==null||_jb.addEventListener("click",exportToFile),(_kb=$("reh-impex-fountain-btn"))==null||_kb.addEventListener("click",()=>{var _a2;(_a2=$("reh-fountain-btn"))==null||_a2.click()}),(_lb=$("reh-impex-fdx-btn"))==null||_lb.addEventListener("click",()=>{var _a2;(_a2=$("reh-fdx-export-btn"))==null||_a2.click()}),(_mb=$("reh-impex-osf-btn"))==null||_mb.addEventListener("click",()=>{var _a2;(_a2=$("reh-osf-export-btn"))==null||_a2.click()});function _downloadText(content,filename){const a=document.createElement("a");a.href=URL.createObjectURL(new Blob([content],{type:"text/plain;charset=utf-8"})),a.download=filename,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),3e3)}function exportAsTxt(){var _a2,_b2;const title=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||((_b2=$("reh-page-title"))==null?void 0:_b2.textContent)||"script";_downloadText(linesToScriptText(),title.replace(/[^a-z0-9]/gi,"_")+".txt")}function exportAsMd(){var _a2;const title=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Script",lines=rehState.lines.map(l=>{switch(l.type){case"act":return` +
`}).join("")}}(_cb=$("reh-new-session-btn"))==null||_cb.addEventListener("click",()=>{stopPlay(),stopRehMic(),rehState.lines=[],rehState.cast={},rehState.clips=[],rehState.lineIndex=0,rehState.savedId=null,rehState.synthCache.clear(),rehDecodedBuffers.clear(),rehState.narratorVoice="",rehState.practiceStart=null,rehState.practiceEnd=null,$("reh-script-text")&&($("reh-script-text").value=""),$("reh-script-title")&&($("reh-script-title").value=""),renderLibraryList(),showPhase(1)}),(_db=$("reh-resume-btn"))==null||_db.addEventListener("click",()=>{showPhase(3),highlightCurrentLine()}),(_eb=$("reh-save-session-btn"))==null||_eb.addEventListener("click",saveToLibrary),(_fb=$("reh-cast-save-btn"))==null||_fb.addEventListener("click",saveToLibrary),(_gb=$("reh-export-session-btn"))==null||_gb.addEventListener("click",exportToFile),(_hb=$("reh-tb-save"))==null||_hb.addEventListener("click",saveToLibrary),(_ib=$("reh-tb-export"))==null||_ib.addEventListener("click",exportToFile),(_jb=$("reh-lib-import-file"))==null||_jb.addEventListener("change",async function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];f&&(await importFromFile(f),this.value="")}),document.querySelectorAll(".reh-impex-file").forEach(inp=>{inp.addEventListener("change",async function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];f&&(await importFromFile(f),this.value="",navRehearserPhase(1))})}),(_kb=$("reh-impex-reh-btn"))==null||_kb.addEventListener("click",exportToFile),(_lb=$("reh-impex-fountain-btn"))==null||_lb.addEventListener("click",()=>{var _a2;(_a2=$("reh-fountain-btn"))==null||_a2.click()}),(_mb=$("reh-impex-fdx-btn"))==null||_mb.addEventListener("click",()=>{var _a2;(_a2=$("reh-fdx-export-btn"))==null||_a2.click()}),(_nb=$("reh-impex-osf-btn"))==null||_nb.addEventListener("click",()=>{var _a2;(_a2=$("reh-osf-export-btn"))==null||_a2.click()});function _downloadText(content,filename){const a=document.createElement("a");a.href=URL.createObjectURL(new Blob([content],{type:"text/plain;charset=utf-8"})),a.download=filename,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),3e3)}function exportAsTxt(){var _a2,_b2;const title=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||((_b2=$("reh-page-title"))==null?void 0:_b2.textContent)||"script";_downloadText(linesToScriptText(),title.replace(/[^a-z0-9]/gi,"_")+".txt")}function exportAsMd(){var _a2;const title=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Script",lines=rehState.lines.map(l=>{switch(l.type){case"act":return` # ${l.text} `;case"scene":return` ## ${l.text} @@ -1091,7 +1098,7 @@ ${l.text} `);_downloadText(`# ${title} ${lines.trim()} -`,title.replace(/[^a-z0-9]/gi,"_")+".md")}(_nb=$("reh-impex-txt-btn"))==null||_nb.addEventListener("click",exportAsTxt),(_ob=$("reh-impex-md-btn"))==null||_ob.addEventListener("click",exportAsMd),function(){const inp=$("reh-impex-url"),btn=$("reh-impex-url-btn"),status2=$("reh-impex-url-status");if(!inp||!btn)return;async function fetchUrl(){const url=inp.value.trim();if(!url){toast("Paste a URL first","error");return}btn.disabled=!0,status2&&(status2.textContent="Fetching\u2026",status2.style.color="");try{const r=await fetch("/api/fetch-web-script",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json(),ta=$("reh-script-text");ta&&(ta.value=d.text);const titleInp=$("reh-script-title");titleInp&&d.title&&(titleInp.value=d.title.replace(/-/g," ").replace(/\b\w/g,c=>c.toUpperCase())),status2&&(status2.textContent=`\u2713 Fetched ${(d.chars/1e3).toFixed(0)} K chars \u2014 scroll down to Parse & cast`,status2.style.color="var(--green)"),navRehearserPhase(1),setTimeout(()=>ta==null?void 0:ta.scrollIntoView({behavior:"smooth",block:"nearest"}),300),toast(`Fetched "${d.title}" \u2014 click "Parse & cast" to continue`,"success")}catch(e){status2&&(status2.textContent="\u2717 "+e.message,status2.style.color="var(--red)"),toast("Fetch failed: "+e.message,"error")}finally{btn.disabled=!1}}btn.addEventListener("click",fetchUrl),inp.addEventListener("keydown",e=>{e.key==="Enter"&&fetchUrl()})}(),function(){const modal=$("reh-imsdb-modal"),grid=$("reh-imsdb-grid"),search=$("reh-imsdb-search"),countEl=$("reh-imsdb-count"),closeBtn=$("reh-imsdb-close"),coverBtn=$("reh-imsdb-cover-btn"),listBtn=$("reh-imsdb-list-btn"),urlInp=$("reh-imsdb-url"),urlBtn=$("reh-imsdb-url-fetch");if(!modal||!grid)return;const CACHE_KEY="reh-imsdb-cat-v1",CACHE_TTL=6*3600*1e3;let _catalogue=null,_posterObserver=null,_view=localStorage.getItem("reh-imsdb-view")||"list";function _applyView(){grid.classList.toggle("list-view",_view==="list"),coverBtn&&coverBtn.classList.toggle("active",_view==="cover"),listBtn&&listBtn.classList.toggle("active",_view==="list")}function _ensurePosterObserver(){return _posterObserver||(_posterObserver=new IntersectionObserver(entries=>{entries.forEach(async ent=>{if(!ent.isIntersecting)return;const card=ent.target;_posterObserver.unobserve(card);const title=card.dataset.title;try{const d=await fetch("/api/movie-poster?title="+encodeURIComponent(title)).then(r=>r.json());if(d.poster){const img=document.createElement("img");img.className="reh-imsdb-poster",img.loading="lazy",img.src=d.poster,img.alt=title,img.onload=()=>{var _a2;const wrap=card.querySelector(".reh-imsdb-poster-wrap");wrap&&((_a2=wrap.querySelector(".reh-imsdb-fallback"))==null||_a2.remove(),wrap.appendChild(img))}}d.year&&card.querySelectorAll(".reh-imsdb-year").forEach(y=>{y.textContent=d.year})}catch{}})},{root:grid,rootMargin:"300px"}),_posterObserver)}function _bookColor(title){let h=0;for(let i=0;i>>0;return`hsl(${h%360}, 45%, 42%)`}function renderGrid(items){const obs=_ensurePosterObserver();if(grid.innerHTML="",!items.length){grid.innerHTML='
No matches.
';return}const frag=document.createDocumentFragment();items.slice(0,400).forEach(it=>{const card=document.createElement("div");card.className="reh-imsdb-card",card.dataset.title=it.title,card.dataset.url=it.fetch_url;const color1=_bookColor(it.title),color2=_bookColor(it.title+"_");card.innerHTML=` +`,title.replace(/[^a-z0-9]/gi,"_")+".md")}(_ob=$("reh-impex-txt-btn"))==null||_ob.addEventListener("click",exportAsTxt),(_pb=$("reh-impex-md-btn"))==null||_pb.addEventListener("click",exportAsMd),function(){const inp=$("reh-impex-url"),btn=$("reh-impex-url-btn"),status2=$("reh-impex-url-status");if(!inp||!btn)return;async function fetchUrl(){const url=inp.value.trim();if(!url){toast("Paste a URL first","error");return}btn.disabled=!0,status2&&(status2.textContent="Fetching\u2026",status2.style.color="");try{const r=await fetch("/api/fetch-web-script",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json(),ta=$("reh-script-text");ta&&(ta.value=d.text);const titleInp=$("reh-script-title");titleInp&&d.title&&(titleInp.value=d.title.replace(/-/g," ").replace(/\b\w/g,c=>c.toUpperCase())),status2&&(status2.textContent=`\u2713 Fetched ${(d.chars/1e3).toFixed(0)} K chars \u2014 scroll down to Parse & cast`,status2.style.color="var(--green)"),navRehearserPhase(1),setTimeout(()=>ta==null?void 0:ta.scrollIntoView({behavior:"smooth",block:"nearest"}),300),toast(`Fetched "${d.title}" \u2014 click "Parse & cast" to continue`,"success")}catch(e){status2&&(status2.textContent="\u2717 "+e.message,status2.style.color="var(--red)"),toast("Fetch failed: "+e.message,"error")}finally{btn.disabled=!1}}btn.addEventListener("click",fetchUrl),inp.addEventListener("keydown",e=>{e.key==="Enter"&&fetchUrl()})}(),function(){const modal=$("reh-imsdb-modal"),grid=$("reh-imsdb-grid"),search=$("reh-imsdb-search"),countEl=$("reh-imsdb-count"),closeBtn=$("reh-imsdb-close"),coverBtn=$("reh-imsdb-cover-btn"),listBtn=$("reh-imsdb-list-btn"),urlInp=$("reh-imsdb-url"),urlBtn=$("reh-imsdb-url-fetch");if(!modal||!grid)return;const CACHE_KEY="reh-imsdb-cat-v1",CACHE_TTL=6*3600*1e3;let _catalogue=null,_posterObserver=null,_view=localStorage.getItem("reh-imsdb-view")||"list";function _applyView(){grid.classList.toggle("list-view",_view==="list"),coverBtn&&coverBtn.classList.toggle("active",_view==="cover"),listBtn&&listBtn.classList.toggle("active",_view==="list")}function _ensurePosterObserver(){return _posterObserver||(_posterObserver=new IntersectionObserver(entries=>{entries.forEach(async ent=>{if(!ent.isIntersecting)return;const card=ent.target;_posterObserver.unobserve(card);const title=card.dataset.title;try{const d=await fetch("/api/movie-poster?title="+encodeURIComponent(title)).then(r=>r.json());if(d.poster){const img=document.createElement("img");img.className="reh-imsdb-poster",img.loading="lazy",img.src=d.poster,img.alt=title,img.onload=()=>{var _a2;const wrap=card.querySelector(".reh-imsdb-poster-wrap");wrap&&((_a2=wrap.querySelector(".reh-imsdb-fallback"))==null||_a2.remove(),wrap.appendChild(img))}}d.year&&card.querySelectorAll(".reh-imsdb-year").forEach(y=>{y.textContent=d.year})}catch{}})},{root:grid,rootMargin:"300px"}),_posterObserver)}function _bookColor(title){let h=0;for(let i=0;i>>0;return`hsl(${h%360}, 45%, 42%)`}function renderGrid(items){const obs=_ensurePosterObserver();if(grid.innerHTML="",!items.length){grid.innerHTML='
No matches.
';return}const frag=document.createDocumentFragment();items.slice(0,400).forEach(it=>{const card=document.createElement("div");card.className="reh-imsdb-card",card.dataset.title=it.title,card.dataset.url=it.fetch_url;const color1=_bookColor(it.title),color2=_bookColor(it.title+"_");card.innerHTML=`
@@ -1104,11 +1111,11 @@ ${lines.trim()}
`,card.addEventListener("click",()=>importImsdb(it)),frag.appendChild(card),obs.observe(card)}),grid.appendChild(frag),countEl&&(countEl.textContent=`${items.length} script${items.length!==1?"s":""}${items.length>400?" (showing 400)":""}`),_applyView()}async function importAnyWebUrl(url,label="script"){if(url=String(url||"").trim(),!url){toast("Paste a script URL first","error");return}if(!/^https?:\/\//i.test(url)){toast("URL must start with http:// or https://","error");return}urlBtn&&(urlBtn.disabled=!0),grid.innerHTML=`
Fetching ${escHtml(label)}\u2026
`;try{const r=await fetch("/api/fetch-web-script",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json(),ta=$("reh-script-text");ta&&(ta.value=d.text);const ti=$("reh-script-title");ti&&(ti.value=d.title||label),closeModal(),navRehearserPhase(1),setTimeout(()=>ta==null?void 0:ta.scrollIntoView({behavior:"smooth",block:"center"}),250),toast(`Loaded "${d.title||label}" (${(d.chars/1e3).toFixed(0)} K) \u2014 click "Parse & cast"`,"success")}catch(e){grid.innerHTML=`
Fetch failed: ${escHtml(e.message)}
`,_catalogue&&renderGrid(_searchFilter(_catalogue)),toast("Script fetch failed: "+e.message,"error")}finally{urlBtn&&(urlBtn.disabled=!1)}}async function importImsdb(it){await importAnyWebUrl(it.fetch_url,it.title||"IMSDb script")}async function _loadCatalogue(){try{const raw=localStorage.getItem(CACHE_KEY);if(raw){const cached=JSON.parse(raw);if(Date.now()-cached.tsr.json())).items||[];try{localStorage.setItem(CACHE_KEY,JSON.stringify({ts:Date.now(),items:_catalogue}))}catch{}}async function openModal(){if(modal.hidden=!1,urlInp&&(urlInp.value=""),_applyView(),!_catalogue){grid.innerHTML='
Loading catalogue\u2026
';try{await _loadCatalogue()}catch(e){grid.innerHTML=`
Failed to load: ${escHtml(e.message)}
`;return}}renderGrid(_searchFilter(_catalogue)),search==null||search.focus()}function closeModal(){modal.hidden=!0}function _searchFilter(items){const q=(search==null?void 0:search.value.trim().toLowerCase())||"";return q?items.filter(it=>it.title.toLowerCase().includes(q)):items}let _searchTimer=null;search==null||search.addEventListener("input",()=>{clearTimeout(_searchTimer),_searchTimer=setTimeout(()=>{_catalogue&&renderGrid(_searchFilter(_catalogue))},150)}),search==null||search.addEventListener("keydown",e=>{e.key==="Enter"&&/^https?:\/\//i.test(search.value.trim())&&(e.preventDefault(),importAnyWebUrl(search.value.trim(),"pasted URL"))}),urlBtn==null||urlBtn.addEventListener("click",()=>importAnyWebUrl((urlInp==null?void 0:urlInp.value)||"","pasted URL")),urlInp==null||urlInp.addEventListener("keydown",e=>{e.key==="Enter"&&(e.preventDefault(),importAnyWebUrl(urlInp.value,"pasted URL"))}),coverBtn==null||coverBtn.addEventListener("click",()=>{_view="cover",localStorage.setItem("reh-imsdb-view",_view),_applyView()}),listBtn==null||listBtn.addEventListener("click",()=>{_view="list",localStorage.setItem("reh-imsdb-view",_view),_applyView()}),[$("reh-browse-imsdb-btn"),$("reh-browse-imsdb-btn-impex")].forEach(btn=>{btn==null||btn.addEventListener("click",openModal)}),closeBtn==null||closeBtn.addEventListener("click",closeModal),modal.addEventListener("click",e=>{e.target===modal&&closeModal()})}();const trainState={seq:[],turn:0,phase:"idle",cueSource:null,mediaRec:null,recChunks:[],recRaf:null,recStream:null,recCtx:null,recAnalyser:null,recTimer:null,recSecs:0};function _trainNormWords(str){return(str||"").toLowerCase().replace(/[^a-zäöüàáâãèéêëìíîïòóôõùúûüýÿæœßа-яёА-ЯЁ0-9\s]/g,"").trim().split(/\s+/).filter(Boolean)}function _trainLcs(a,b){const R=a.length,C=b.length,dp=Array.from({length:R+1},()=>new Int16Array(C+1));for(let i2=1;i2<=R;i2++)for(let j2=1;j2<=C;j2++)dp[i2][j2]=a[i2-1]===b[j2-1]?dp[i2-1][j2-1]+1:Math.max(dp[i2-1][j2],dp[i2][j2-1]);let i=R,j=C;const seq=[];for(;i>0&&j>0;)a[i-1]===b[j-1]?(seq.unshift([i-1,j-1]),i--,j--):dp[i-1][j]>=dp[i][j-1]?i--:j--;return seq}function _trainCompare(expected,actual){const ew=_trainNormWords(expected),aw=_trainNormWords(actual);if(!ew.length)return{html_exp:"",html_act:"",score:1};const pairs=_trainLcs(ew,aw),matchedE=new Set(pairs.map(p=>p[0])),matchedA=new Set(pairs.map(p=>p[1])),expHtml=ew.map((w,i)=>matchedE.has(i)?`${escHtml(w)}`:`${escHtml(w)}`).join(" "),actHtml=aw.map((w,i)=>matchedA.has(i)?`${escHtml(w)}`:`${escHtml(w)}`).join(" "),score=ew.length?Math.round(matchedE.size/ew.length*100):100;return{html_exp:expHtml,html_act:actHtml,score}}function buildTrainSeq(){const seq=[],lines=rehState.lines;for(let i=0;i=0&&cueLines.length<3;j--){const prev=lines[j];if(prev.type==="scene"||prev.type==="act")break;if(prev.type!=="dialog")continue;const pc=rehState.cast[prev.speaker];if(!pc||pc.voice==="me")break;cueLines.unshift({index:j,speaker:prev.speaker,text:prev.text,emotion:prev.emotion||"",voice:pc.voice,color:pc.color,instruct:_buildInstruct(pc.instruct,prev.emotion),voiceData:pc.voiceData})}seq.push({cueLines,myLine:{index:i,speaker:line.speaker,text:line.text,color:cast.color}})}return seq}function _trainSetState(phase){trainState.phase=phase;const stateEl=$("reh-train-rec-state"),playBtn=$("reh-train-play"),recBtn=$("reh-train-record"),stopBtn=$("reh-train-stop-rec"),cueState=$("reh-train-cue-state"),map={idle:{state:"Read the cue above \xB7 press \u25B6 to hear it",play:!0,rec:!1,stop:!1},playing_cue:{state:"Playing cue\u2026",play:!1,rec:!1,stop:!1},ready:{state:"Press \u{1F3A4} to record your line",play:!1,rec:!0,stop:!1},recording:{state:"Recording\u2026",play:!1,rec:!1,stop:!0},transcribing:{state:"Transcribing\u2026",play:!1,rec:!1,stop:!1},done:{state:"Done \u2014 press \u25B6 to replay or \u23ED for next",play:!0,rec:!0,stop:!1}},m=map[phase]||map.idle;stateEl&&(stateEl.textContent=m.state),cueState&&phase==="playing_cue"?cueState.innerHTML='':cueState&&(cueState.innerHTML=""),playBtn&&(playBtn.disabled=!m.play),recBtn&&(recBtn.disabled=!m.rec,recBtn.hidden=!!m.stop),stopBtn&&(stopBtn.disabled=!m.stop,stopBtn.hidden=!m.stop);const timerEl=$("reh-train-rec-timer"),metersEl=$("reh-train-meters");phase==="recording"?(timerEl&&(timerEl.hidden=!1),metersEl&&(metersEl.hidden=!1)):phase!=="done"&&(timerEl&&(timerEl.hidden=!0),metersEl&&(metersEl.hidden=!0))}async function _trainPlayCueLines(cueLines){_trainSetState("playing_cue");for(const cue of cueLines){if(trainState.phase!=="playing_cue")break;let blob=rehState.synthCache.get(cue.index);if(!blob)try{blob=await fetchTtsPreviewBlob(cue.voice,_rehInlineTone(stripMarkdown(cue.text),cue.emotion),"wav",cue.instruct,rehState.backend)}catch(e){toast("Cue TTS failed: "+e.message,"error");break}if(trainState.phase!=="playing_cue")break;await new Promise(resolve=>{const url=URL.createObjectURL(blob),audio=new Audio(url);audio.onended=()=>{URL.revokeObjectURL(url),resolve()},audio.onerror=()=>{URL.revokeObjectURL(url),resolve()},trainState.cueAudio=audio,audio.play().catch(resolve)})}trainState.phase==="playing_cue"&&_trainSetState("ready")}async function _trainTranscribe(blob){_trainSetState("transcribing");try{const fd=new FormData;fd.append("file",blob,"train_rec.webm"),fd.append("backend",(_appSettings==null?void 0:_appSettings.stt_preferred_backend)||"configured");const r=await fetch("/api/transcribe-bytes",{method:"POST",body:fd});if(!r.ok)throw new Error(r.statusText);return((await r.json()).text||"").trim()}catch(e){return toast("Transcription failed: "+e.message,"error"),""}}async function trainGoTo(idx){trainState.cueAudio&&(trainState.cueAudio.pause(),trainState.cueAudio=null),_trainStopMic(),trainState.turn=Math.max(0,Math.min(idx,trainState.seq.length-1));const turn=trainState.seq[trainState.turn];if(!turn)return;const progEl=$("reh-train-progress");progEl&&(progEl.textContent=`Turn ${trainState.turn+1} / ${trainState.seq.length}`);const cueCard=$("reh-train-cue"),cueAvEl=$("reh-train-cue-avatar"),cueSpeaker=$("reh-train-cue-speaker"),cueText=$("reh-train-cue-text");if(turn.cueLines.length){const lastCue=turn.cueLines[turn.cueLines.length-1];cueAvEl&&(cueAvEl.innerHTML=voiceAvatarHtml(lastCue.voice,lastCue.color,32)),cueSpeaker&&(cueSpeaker.textContent=lastCue.speaker),cueText&&(cueText.innerHTML=turn.cueLines.map(c=>`
${escHtml(c.speaker)} ${escHtml(c.text)} -
`).join("")),cueCard==null||cueCard.classList.remove("no-cue")}else cueAvEl&&(cueAvEl.innerHTML=''),cueSpeaker&&(cueSpeaker.textContent="No preceding cue"),cueText&&(cueText.textContent="This is the first line \u2014 speak when ready."),cueCard==null||cueCard.classList.add("no-cue");const mySpeaker=$("reh-train-my-speaker"),myText=$("reh-train-my-text");mySpeaker&&(mySpeaker.textContent=turn.myLine.speaker),myText&&(myText.textContent=turn.myLine.text);const resultEl=$("reh-train-result");resultEl&&(resultEl.hidden=!0);const prevAudio=$("reh-train-audio-preview");prevAudio&&(prevAudio.src="",prevAudio.style.display="none"),_trainSetState(turn.cueLines.length?"idle":"ready")}function _trainStartMeter(){const meterEl=$("reh-train-meter"),dbEl=$("reh-train-db"),waveEl=$("reh-train-wave");if(!trainState.recAnalyser||!meterEl)return;const analyser=trainState.recAnalyser,fft=new Uint8Array(analyser.frequencyBinCount),wCtx=waveEl==null?void 0:waveEl.getContext("2d"),W=(waveEl==null?void 0:waveEl.width)||300,H=(waveEl==null?void 0:waveEl.height)||36,BARS=18;function tick(){analyser.getByteFrequencyData(fft);const rms=fft.reduce((s,v)=>s+v*v,0)/fft.length,db=rms>0?20*Math.log10(Math.sqrt(rms)/128):-1/0;dbEl&&(dbEl.textContent=isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB");const slots=Array.from({length:BARS},(_,i)=>{const s=Math.floor(i/BARS*fft.length),e=Math.floor((i+1)/BARS*fft.length);return fft.slice(s,e).reduce((a,b)=>a+b,0)/(e-s)/255});meterEl.innerHTML=slots.map(v=>{const h=Math.max(2,Math.round(v*28)),col=v>.85?"var(--red)":v>.6?"#f59e0b":"var(--green)";return``}).join(""),wCtx&&(analyser.getByteTimeDomainData(fft),wCtx.clearRect(0,0,W,H),wCtx.beginPath(),wCtx.strokeStyle="var(--accent)",wCtx.lineWidth=1.5,fft.forEach((v,i)=>{const x=i/fft.length*W,y=v/255*H;i?wCtx.lineTo(x,y):wCtx.moveTo(x,y)}),wCtx.stroke()),trainState.recRaf=requestAnimationFrame(tick)}trainState.recRaf=requestAnimationFrame(tick)}function _trainStopMic(){if(trainState.recRaf&&(cancelAnimationFrame(trainState.recRaf),trainState.recRaf=null),trainState.recTimer&&(clearInterval(trainState.recTimer),trainState.recTimer=null),trainState.mediaRec&&trainState.mediaRec.state!=="inactive")try{trainState.mediaRec.stop()}catch{}if(trainState.mediaRec=null,trainState.recCtx){try{trainState.recCtx.close()}catch{}trainState.recCtx=null}trainState.recStream&&(trainState.recStream.getTracks().forEach(t=>t.stop()),trainState.recStream=null),trainState.recAnalyser=null,trainState.recChunks=[];const timerEl=$("reh-train-rec-timer");timerEl&&(timerEl.hidden=!0,timerEl.textContent="0:00");const metersEl=$("reh-train-meters");metersEl&&(metersEl.hidden=!0)}async function _trainStartRecording(){if(trainState.phase==="ready")try{const stream=await requestMicrophoneStream({raw:!0});trainState.recStream=stream;const ctx=new AudioContext;trainState.recCtx=ctx;const src=ctx.createMediaStreamSource(stream),analyser=ctx.createAnalyser();analyser.fftSize=256,trainState.recAnalyser=analyser;const dst=ctx.createMediaStreamDestination();src.connect(analyser),analyser.connect(dst),_trainSetState("recording"),_trainStartMeter();const timerEl=$("reh-train-rec-timer");timerEl&&(timerEl.hidden=!1),trainState.recSecs=0,trainState.recTimer=setInterval(()=>{trainState.recSecs++;const m=Math.floor(trainState.recSecs/60),s=trainState.recSecs%60;timerEl&&(timerEl.textContent=`${m}:${String(s).padStart(2,"0")}`)},1e3),trainState.recChunks=[];const mr=new MediaRecorder(dst.stream,{audioBitsPerSecond:128e3});trainState.mediaRec=mr,mr.ondataavailable=e=>{e.data.size>0&&trainState.recChunks.push(e.data)},mr.onstop=async()=>{var _a2;_trainStopMic();const blob=new Blob(trainState.recChunks,{type:"audio/webm"});trainState.recChunks=[];const url=URL.createObjectURL(blob),prevAudio=$("reh-train-audio-preview");prevAudio&&(prevAudio.src=url,prevAudio.style.display="");const transcript=await _trainTranscribe(blob),turn=trainState.seq[trainState.turn],{html_exp,html_act,score}=_trainCompare(((_a2=turn==null?void 0:turn.myLine)==null?void 0:_a2.text)||"",transcript),resultEl=$("reh-train-result"),expEl=$("reh-train-expected-text"),actEl=$("reh-train-actual-text"),scoreEl=$("reh-train-score");if(expEl&&(expEl.innerHTML=html_exp),actEl&&(actEl.innerHTML=html_act||'Nothing detected'),scoreEl){const col=score>=90?"var(--green)":score>=60?"#f59e0b":"var(--red)";scoreEl.innerHTML=`${score}%`}resultEl&&(resultEl.hidden=!1),_trainSetState("done")},mr.start()}catch(e){toast("Microphone error: "+e.message,"error"),_trainSetState("ready")}}function _trainStopRecording(){trainState.mediaRec&&trainState.mediaRec.state==="recording"&&trainState.mediaRec.stop()}function _trainHideStage(hide){[".reh-stage-area","#reh-tts-status-bar","#reh-rec-overlay","#reh-synth-bar"].forEach(sel=>{const el=document.querySelector(sel)||document.getElementById(sel.replace("#",""));el&&(el.style.display=hide?"none":"")})}function enterTrainMode(){const seq=buildTrainSeq();if(!seq.length){toast('No "I play this" lines found \u2014 check "I play this" on at least one character in the Cast tab.',"error");return}trainState.seq=seq,trainState.turn=0,trainState.phase="idle",_trainHideStage(!0);const panel=$("reh-train-panel");panel&&(panel.hidden=!1),trainGoTo(0)}function exitTrainMode(){trainState.cueAudio&&(trainState.cueAudio.pause(),trainState.cueAudio=null),_trainStopMic(),trainState.phase="idle",trainState.seq=[],_trainHideStage(!1);const panel=$("reh-train-panel");panel&&(panel.hidden=!0)}if((_pb=$("reh-bulk-toggle"))==null||_pb.addEventListener("click",()=>setBulkMode(!rehState.bulkMode)),(_qb=$("reh-bulk-done"))==null||_qb.addEventListener("click",()=>setBulkMode(!1)),(_rb=$("reh-bulk-all"))==null||_rb.addEventListener("click",()=>{document.querySelectorAll("#reh-script-lines .reh-bulk-check").forEach(cb=>rehState.bulkSel.add(parseInt(cb.dataset.bulk))),rehState.bulkAnchor=rehState.bulkSel.size?Math.min(...rehState.bulkSel):null,document.querySelectorAll("#reh-script-lines .reh-bulk-check").forEach(cb=>_refreshBulkLine(parseInt(cb.dataset.bulk))),_updateBulkCount()}),(_sb=$("reh-bulk-none"))==null||_sb.addEventListener("click",()=>{const had=[...rehState.bulkSel];rehState.bulkSel.clear(),rehState.bulkAnchor=null,had.forEach(_refreshBulkLine),_updateBulkCount()}),(_tb=$("reh-bulk-ignore"))==null||_tb.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!0})),(_ub=$("reh-bulk-unignore"))==null||_ub.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!1})),(_vb=$("reh-bulk-hide"))==null||_vb.addEventListener("click",()=>_bulkApply(l=>{l.hidden=!0})),(_wb=$("reh-bulk-delete"))==null||_wb.addEventListener("click",_bulkDelete),(_xb=$("reh-bulk-show-hidden"))==null||_xb.addEventListener("change",e=>{rehState.showHidden=e.target.checked,buildScriptPage()}),(_yb=$("reh-page-mode-btn"))==null||_yb.addEventListener("click",cyclePageMode),(_zb=$("reh-train-open-btn"))==null||_zb.addEventListener("click",enterTrainMode),(_Ab=$("reh-train-exit-btn"))==null||_Ab.addEventListener("click",exitTrainMode),(_Bb=$("reh-train-play"))==null||_Bb.addEventListener("click",()=>{const turn=trainState.seq[trainState.turn];turn&&trainState.phase!=="playing_cue"&&(trainState.cueAudio&&(trainState.cueAudio.pause(),trainState.cueAudio=null),turn.cueLines.length?_trainPlayCueLines(turn.cueLines):_trainSetState("ready"))}),(_Cb=$("reh-train-record"))==null||_Cb.addEventListener("click",()=>{(trainState.phase==="ready"||trainState.phase==="done")&&_trainStartRecording()}),(_Db=$("reh-train-stop-rec"))==null||_Db.addEventListener("click",_trainStopRecording),(_Eb=$("reh-train-prev"))==null||_Eb.addEventListener("click",()=>{trainState.turn>0&&trainGoTo(trainState.turn-1)}),(_Fb=$("reh-train-next"))==null||_Fb.addEventListener("click",()=>{trainState.turn{trainState.cueAudio&&(trainState.cueAudio.pause(),trainState.cueAudio=null),_trainStopMic();const turn=trainState.seq[trainState.turn];if(!turn)return;const resultEl=$("reh-train-result");resultEl&&(resultEl.hidden=!0),turn.cueLines.length?_trainPlayCueLines(turn.cueLines):_trainSetState("ready")}),rehRenderMeter(),_syncPageModeBtn(),renderLibraryList().catch(()=>{}),$("reh-skip-desc-toggle")&&($("reh-skip-desc-toggle").checked=rehState.skipDescriptions),window._rehearserStartImpEx)delete window._rehearserStartImpEx,showRehImpEx();else{const _initPhase=window._rehearserStartPhase||1;delete window._rehearserStartPhase,showPhase(_initPhase)}const readerState={mode:null,title:"",pdfDoc:null,pages:[],sentences:[],idx:0,readingIdx:-1,playing:!1,speed:1,scale:1,zoomMode:"fit-width",audioCtx:null,currentSource:null,raf:null,blobCache:new Map,bufCache:new Map,gainCache:new Map,unitsByPage:new Map,baseSentences:[],chunkMode:"sentence",normalize:!0,io:null,_seq:0,synthRunning:!1,synthCancel:!1,selecting:!1,selStart:null,selEnd:null,selAnchored:!1,fileBlob:null,docText:"",savedId:null,savedAudioIdx:new Set,sourceUploaded:!1,ocrWorker:null,synthStarted:!1};window.readerState=readerState;const READER_RESUME_KEY="reader-resume",READER_FMT="mp3",READER_BUF_WINDOW=3,READER_CANVAS_MARGIN=2200,READER_IMPORT_YIELD_EVERY=350,READER_PDF_INITIAL_PAGES=16,READER_PDF_INITIAL_MAX_WITHOUT_TEXT=64,READER_OCR_LANGS="deu+eng",READER_OCR_MIN_GAP_RATIO=.08,READER_OCR_MIN_GAP_PX=40,READER_OCR_RENDER_SCALE=2.5,READER_OCR_MIN_CONFIDENCE=40;function readerCtx(){return readerState.audioCtx||(readerState.audioCtx=new(window.AudioContext||window.webkitAudioContext)),readerState.audioCtx.state==="suspended"&&readerState.audioCtx.resume().catch(()=>{}),readerState.audioCtx}function readerYield(){return new Promise(resolve=>setTimeout(resolve,0))}window.readerOnShow=async function(){const sel=$("reader-backend-select");if(sel&&(!sel.value||sel.options.length<=1)){if(typeof availableTtsBackends=="function"&&!availableTtsBackends().length&&typeof refreshTtsBackendAvailability=="function")try{await refreshTtsBackendAvailability()}catch{}typeof availableTtsBackends=="function"&&availableTtsBackends().length&&(sel.innerHTML=ttsBackendOptions(sel.value),sel.disabled=!1)}readerUpdateBackendHint(),window.VoicePicker&&VoicePicker.upgrade("reader-voice-select"),readerRenderLibrary()},window.readerStop=function(){readerSaveResume(),readerPersistProgress(),readerStopPlayback()};async function readerFetchVoices(){var _a2;const btn=$("reader-fetch-voices-btn"),backend=(_a2=$("reader-backend-select"))==null?void 0:_a2.value;if(!backend){toast("No available TTS backend","error");return}btn&&(btn.disabled=!0);try{const raw=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json()),ids=(Array.isArray(raw)?raw:[]).map(v=>typeof v=="string"?v:v.id||v.name||v.voice_id).filter(Boolean),sel=$("reader-voice-select");if(!sel)return;const prev=sel.value;window.VoicePicker?(VoicePicker.upgrade("reader-voice-select"),VoicePicker.populate("reader-voice-select",ids),prev&&ids.includes(prev)?VoicePicker.setValue("reader-voice-select",prev):ids.length&&VoicePicker.setValue("reader-voice-select",ids[0])):(sel.innerHTML='',ids.forEach(id=>{const o=document.createElement("option");o.value=o.textContent=id,sel.appendChild(o)}),prev&&ids.includes(prev)&&(sel.value=prev)),toast("Fetched "+ids.length+" voices","success")}catch(e){toast("Fetch failed: "+(e.message||e),"error")}finally{btn&&(btn.disabled=!1)}}function readerUpdateBackendHint(){var _a2;const el=$("reader-backend-hint");if(!el)return;const id=((_a2=$("reader-backend-select"))==null?void 0:_a2.value)||"",b=typeof backendById=="function"?backendById(id):null,steady=id&&(b&&b.style_aware||/design|custom|kokoro|magpie/i.test(id));el.innerHTML=steady?' Good for long-form narration. For maximum steadiness, increase \u201CVoice consistency\u201D.':' Cloned / zero-shot voices re-sample each chunk, so the voice can drift between sections. To steady it: raise \u201CVoice consistency\u201D to paragraph/page, set a fixed Seed with a low Temperature (e.g. 0.3), keep \u201CNormalise loudness\u201D on, or pick a style-aware backend.',el.classList.toggle("reader-hint-warn",!steady)}function readerTitleFromText(text){return(String(text||"").split(/\r?\n/).map(x=>x.trim()).find(Boolean)||"Pasted text").replace(/\s+/g," ").slice(0,80)||"Pasted text"}function readerUpdateToolbarVisibility(loaded=!!readerState.sentences.length){const toolbar=$("reader-toolbar");toolbar&&(toolbar.hidden=!loaded);const pdfOnly=readerState.mode==="pdf",zoom=$("reader-zoom");zoom&&(zoom.hidden=!pdfOnly);const legend=document.querySelector(".reader-legend");legend&&(legend.hidden=!pdfOnly)}function readerFinishLoadedDocument({resume=!0,successPrefix="Loaded"}={}){if(!readerState.sentences.length)return toast("No readable text found in this document","error"),!1;readerState.mode==="text"&&readerState.sentences.forEach((s,i)=>readerSetStatus(i,"pending")),$("reader-transport").hidden=!1,$("reader-dropzone").hidden=!0;const importArea=$("reader-import-area");importArea&&(importArea.hidden=!0),readerUpdateToolbarVisibility(!0),$("reader-synthbar").hidden=!1;const pr=$("reader-page-range");if(pr&&(pr.hidden=readerState.mode!=="pdf",readerState.mode==="pdf")){const n=readerState.pages.length,pf=$("reader-page-from"),pt=$("reader-page-to");pf&&(pf.max=n,pf.value=1),pt&&(pt.max=n,pt.value=n)}readerUpdateScopeLabel();const resumed=resume&&readerLoadResume();return readerUpdateProgress(),readerHighlightSentence(readerState.sentences[readerState.idx]),toast(resumed?"Resumed at sentence "+(readerState.idx+1)+" / "+readerState.sentences.length:successPrefix+" \xB7 "+readerState.sentences.length+" sentences \u2014 press play","success"),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("source"),!0}async function readerImportFile(file){if(!file)return;const name=(file.name||"").toLowerCase();readerResetDoc(),readerState.title=file.name.replace(/\.[^.]+$/,""),readerState.fileBlob=file;const titleEl=$("reader-doc-title");titleEl&&(titleEl.textContent=readerState.title);try{if(name.endsWith(".pdf")){toast("Loading PDF\u2026","success");const loaded=await readerLoadPdfSkeleton(file);if(!loaded)return;readerShowExtractPrompt(loaded)}else{const text=await file.text();readerState.docText=text,await readerLoadText(text),readerFinishLoadedDocument({successPrefix:"Loaded"})}}catch(e){toast("Import failed: "+(e.message||e),"error")}}function readerShowExtractPrompt(loaded){const dz=$("reader-dropzone");dz&&(dz.hidden=!0);const importArea=$("reader-import-area");importArea&&(importArea.hidden=!0),readerUpdateToolbarVisibility(!0);const banner=$("reader-extract-banner");if(banner){banner.hidden=!1;const pc=$("reader-extract-pagecount");pc&&(pc.textContent=String(loaded.nPages))}const btn=$("reader-extract-btn");btn&&(btn.onclick=()=>readerRunExtraction(loaded)),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("source"),toast("PDF loaded \xB7 "+loaded.nPages+' pages \u2014 click "Extract Text" when ready',"success")}async function readerRunExtraction(loaded){const btn=$("reader-extract-btn"),original=btn?btn.innerHTML:"";btn&&(btn.disabled=!0,btn.innerHTML=' Extracting\u2026');try{await readerExtractPdfText(loaded);const banner=$("reader-extract-banner");banner&&(banner.hidden=!0),readerFinishLoadedDocument({resume:!1,successPrefix:"Extracted"})}catch(e){toast("Text extraction failed: "+(e.message||e),"error")}finally{btn&&(btn.disabled=!1,btn.innerHTML=original)}}async function readerImportPastedText(){const ta=$("reader-paste-text"),text=((ta==null?void 0:ta.value)||"").trim();if(!text){toast("Paste some text first","error"),ta==null||ta.focus();return}const btn=$("reader-paste-load");btn&&(btn.disabled=!0),readerResetDoc(),readerState.title=readerTitleFromText(text),readerState.docText=text,readerState.fileBlob=null,readerState.sourceUploaded=!1;const titleEl=$("reader-doc-title");titleEl&&(titleEl.textContent=readerState.title);try{await readerLoadText(text),readerFinishLoadedDocument({resume:!1,successPrefix:"Loaded pasted text"})}catch(e){toast("Import failed: "+(e.message||e),"error")}finally{btn&&(btn.disabled=!1)}}function readerResetDoc(){readerStopPlayback(),readerState._seq++,readerState.mode=null,readerState.pdfDoc=null,readerState.pages=[],readerState.sentences=[],readerState.idx=0,readerState.readingIdx=-1,readerState.scale=1,readerState.zoomMode="fit-width",readerState.blobCache.clear(),readerState.bufCache.clear(),readerState.gainCache.clear(),readerState.unitsByPage=new Map,readerState.baseSentences=[],readerState.fileBlob=null,readerState.docText="",readerState.savedId=null,readerState.savedAudioIdx=new Set,readerState.sourceUploaded=!1;const extractBanner=$("reader-extract-banner");extractBanner&&(extractBanner.hidden=!0),readerState.io&&(readerState.io.disconnect(),readerState.io=null),readerState.synthRunning=!1,readerState.synthCancel=!1,readerState.synthStarted=!1,readerSetSelecting(!1),readerState.selStart=readerState.selEnd=null,readerState.selAnchored=!1;const sb=$("reader-synthbar");sb&&(sb.hidden=!0);const tr=$("reader-transport");tr&&(tr.hidden=!0),readerUpdateToolbarVisibility(!1);const sp=$("reader-synth-prog");sp&&(sp.hidden=!0);const si=$("reader-sel-info");si&&(si.hidden=!0);const doc=$("reader-doc");doc&&(doc.innerHTML="",doc.classList.remove("two-page","reader-selecting","reader-synth-started")),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("source");const importArea=$("reader-import-area");importArea&&(importArea.hidden=!1),readerClearSearch({clearInput:!0})}function readerBuildSentences(words){const sentences=[];let cur=null;const endRe=/[.!?]["'”’)\]]?$/,abbrev=/^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;for(const w of words)w.para&&cur&&cur.words.length&&(sentences.push(cur),cur=null),cur||(cur={text:"",words:[],status:"pending",_stat:null,paraStart:!!w.para}),cur.words.push(w),cur.text+=(cur.text?" ":"")+w.text,(endRe.test(w.text)&&!abbrev.test(w.text.replace(/[^a-z.]/gi,""))&&cur.words.length>=2||cur.words.length>=45)&&(sentences.push(cur),cur=null);return cur&&cur.words.length&&sentences.push(cur),sentences}function readerGroupUnits(base,mode){if(mode==="sentence"||!base.length)return base.map(s=>({text:s.text,words:s.words,status:"pending",_stat:null,paraStart:!!s.paraStart}));const maxChars=mode==="page"?2e3:500,isPdf=readerState.mode==="pdf",keyOf=s=>{var _a2,_b2,_c2,_d2;return isPdf?(_b2=(_a2=s.words[0])==null?void 0:_a2.page)!=null?_b2:0:(_d2=(_c2=s.words[0])==null?void 0:_c2.para)!=null?_d2:0},units=[];let cur=null;for(const s of base){const k=keyOf(s),boundary=cur&&(mode==="paragraph"&&k!==cur._key||mode==="page"&&isPdf&&k!==cur._key),tooLong=cur&&cur.words.length&&cur.text.length+s.text.length+1>maxChars;(boundary||tooLong)&&(units.push(cur),cur=null),cur||(cur={text:"",words:[],status:"pending",_stat:null,_key:k,paraStart:!!s.paraStart}),cur.text+=cur.text?(s.paraStart?` +
`).join("")),cueCard==null||cueCard.classList.remove("no-cue")}else cueAvEl&&(cueAvEl.innerHTML=''),cueSpeaker&&(cueSpeaker.textContent="No preceding cue"),cueText&&(cueText.textContent="This is the first line \u2014 speak when ready."),cueCard==null||cueCard.classList.add("no-cue");const mySpeaker=$("reh-train-my-speaker"),myText=$("reh-train-my-text");mySpeaker&&(mySpeaker.textContent=turn.myLine.speaker),myText&&(myText.textContent=turn.myLine.text);const resultEl=$("reh-train-result");resultEl&&(resultEl.hidden=!0);const prevAudio=$("reh-train-audio-preview");prevAudio&&(prevAudio.src="",prevAudio.style.display="none"),_trainSetState(turn.cueLines.length?"idle":"ready")}function _trainStartMeter(){const meterEl=$("reh-train-meter"),dbEl=$("reh-train-db"),waveEl=$("reh-train-wave");if(!trainState.recAnalyser||!meterEl)return;const analyser=trainState.recAnalyser,fft=new Uint8Array(analyser.frequencyBinCount),wCtx=waveEl==null?void 0:waveEl.getContext("2d"),W=(waveEl==null?void 0:waveEl.width)||300,H=(waveEl==null?void 0:waveEl.height)||36,BARS=18;function tick(){analyser.getByteFrequencyData(fft);const rms=fft.reduce((s,v)=>s+v*v,0)/fft.length,db=rms>0?20*Math.log10(Math.sqrt(rms)/128):-1/0;dbEl&&(dbEl.textContent=isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB");const slots=Array.from({length:BARS},(_,i)=>{const s=Math.floor(i/BARS*fft.length),e=Math.floor((i+1)/BARS*fft.length);return fft.slice(s,e).reduce((a,b)=>a+b,0)/(e-s)/255});meterEl.innerHTML=slots.map(v=>{const h=Math.max(2,Math.round(v*28)),col=v>.85?"var(--red)":v>.6?"#f59e0b":"var(--green)";return``}).join(""),wCtx&&(analyser.getByteTimeDomainData(fft),wCtx.clearRect(0,0,W,H),wCtx.beginPath(),wCtx.strokeStyle="var(--accent)",wCtx.lineWidth=1.5,fft.forEach((v,i)=>{const x=i/fft.length*W,y=v/255*H;i?wCtx.lineTo(x,y):wCtx.moveTo(x,y)}),wCtx.stroke()),trainState.recRaf=requestAnimationFrame(tick)}trainState.recRaf=requestAnimationFrame(tick)}function _trainStopMic(){if(trainState.recRaf&&(cancelAnimationFrame(trainState.recRaf),trainState.recRaf=null),trainState.recTimer&&(clearInterval(trainState.recTimer),trainState.recTimer=null),trainState.mediaRec&&trainState.mediaRec.state!=="inactive")try{trainState.mediaRec.stop()}catch{}if(trainState.mediaRec=null,trainState.recCtx){try{trainState.recCtx.close()}catch{}trainState.recCtx=null}trainState.recStream&&(trainState.recStream.getTracks().forEach(t=>t.stop()),trainState.recStream=null),trainState.recAnalyser=null,trainState.recChunks=[];const timerEl=$("reh-train-rec-timer");timerEl&&(timerEl.hidden=!0,timerEl.textContent="0:00");const metersEl=$("reh-train-meters");metersEl&&(metersEl.hidden=!0)}async function _trainStartRecording(){if(trainState.phase==="ready")try{const stream=await requestMicrophoneStream({raw:!0});trainState.recStream=stream;const ctx=new AudioContext;trainState.recCtx=ctx;const src=ctx.createMediaStreamSource(stream),analyser=ctx.createAnalyser();analyser.fftSize=256,trainState.recAnalyser=analyser;const dst=ctx.createMediaStreamDestination();src.connect(analyser),analyser.connect(dst),_trainSetState("recording"),_trainStartMeter();const timerEl=$("reh-train-rec-timer");timerEl&&(timerEl.hidden=!1),trainState.recSecs=0,trainState.recTimer=setInterval(()=>{trainState.recSecs++;const m=Math.floor(trainState.recSecs/60),s=trainState.recSecs%60;timerEl&&(timerEl.textContent=`${m}:${String(s).padStart(2,"0")}`)},1e3),trainState.recChunks=[];const mr=new MediaRecorder(dst.stream,{audioBitsPerSecond:128e3});trainState.mediaRec=mr,mr.ondataavailable=e=>{e.data.size>0&&trainState.recChunks.push(e.data)},mr.onstop=async()=>{var _a2;_trainStopMic();const blob=new Blob(trainState.recChunks,{type:"audio/webm"});trainState.recChunks=[];const url=URL.createObjectURL(blob),prevAudio=$("reh-train-audio-preview");prevAudio&&(prevAudio.src=url,prevAudio.style.display="");const transcript=await _trainTranscribe(blob),turn=trainState.seq[trainState.turn],{html_exp,html_act,score}=_trainCompare(((_a2=turn==null?void 0:turn.myLine)==null?void 0:_a2.text)||"",transcript),resultEl=$("reh-train-result"),expEl=$("reh-train-expected-text"),actEl=$("reh-train-actual-text"),scoreEl=$("reh-train-score");if(expEl&&(expEl.innerHTML=html_exp),actEl&&(actEl.innerHTML=html_act||'Nothing detected'),scoreEl){const col=score>=90?"var(--green)":score>=60?"#f59e0b":"var(--red)";scoreEl.innerHTML=`${score}%`}resultEl&&(resultEl.hidden=!1),_trainSetState("done")},mr.start()}catch(e){toast("Microphone error: "+e.message,"error"),_trainSetState("ready")}}function _trainStopRecording(){trainState.mediaRec&&trainState.mediaRec.state==="recording"&&trainState.mediaRec.stop()}function _trainHideStage(hide){[".reh-stage-area","#reh-tts-status-bar","#reh-rec-overlay","#reh-synth-bar"].forEach(sel=>{const el=document.querySelector(sel)||document.getElementById(sel.replace("#",""));el&&(el.style.display=hide?"none":"")})}function enterTrainMode(){const seq=buildTrainSeq();if(!seq.length){toast('No "I play this" lines found \u2014 check "I play this" on at least one character in the Cast tab.',"error");return}trainState.seq=seq,trainState.turn=0,trainState.phase="idle",_trainHideStage(!0);const panel=$("reh-train-panel");panel&&(panel.hidden=!1),trainGoTo(0)}function exitTrainMode(){trainState.cueAudio&&(trainState.cueAudio.pause(),trainState.cueAudio=null),_trainStopMic(),trainState.phase="idle",trainState.seq=[],_trainHideStage(!1);const panel=$("reh-train-panel");panel&&(panel.hidden=!0)}if((_qb=$("reh-bulk-toggle"))==null||_qb.addEventListener("click",()=>setBulkMode(!rehState.bulkMode)),(_rb=$("reh-bulk-done"))==null||_rb.addEventListener("click",()=>setBulkMode(!1)),(_sb=$("reh-bulk-all"))==null||_sb.addEventListener("click",()=>{document.querySelectorAll("#reh-script-lines .reh-bulk-check").forEach(cb=>rehState.bulkSel.add(parseInt(cb.dataset.bulk))),rehState.bulkAnchor=rehState.bulkSel.size?Math.min(...rehState.bulkSel):null,document.querySelectorAll("#reh-script-lines .reh-bulk-check").forEach(cb=>_refreshBulkLine(parseInt(cb.dataset.bulk))),_updateBulkCount()}),(_tb=$("reh-bulk-none"))==null||_tb.addEventListener("click",()=>{const had=[...rehState.bulkSel];rehState.bulkSel.clear(),rehState.bulkAnchor=null,had.forEach(_refreshBulkLine),_updateBulkCount()}),(_ub=$("reh-bulk-ignore"))==null||_ub.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!0})),(_vb=$("reh-bulk-unignore"))==null||_vb.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!1})),(_wb=$("reh-bulk-hide"))==null||_wb.addEventListener("click",()=>_bulkApply(l=>{l.hidden=!0})),(_xb=$("reh-bulk-delete"))==null||_xb.addEventListener("click",_bulkDelete),(_yb=$("reh-bulk-show-hidden"))==null||_yb.addEventListener("change",e=>{rehState.showHidden=e.target.checked,buildScriptPage()}),(_zb=$("reh-page-mode-btn"))==null||_zb.addEventListener("click",cyclePageMode),(_Ab=$("reh-train-open-btn"))==null||_Ab.addEventListener("click",enterTrainMode),(_Bb=$("reh-train-exit-btn"))==null||_Bb.addEventListener("click",exitTrainMode),(_Cb=$("reh-train-play"))==null||_Cb.addEventListener("click",()=>{const turn=trainState.seq[trainState.turn];turn&&trainState.phase!=="playing_cue"&&(trainState.cueAudio&&(trainState.cueAudio.pause(),trainState.cueAudio=null),turn.cueLines.length?_trainPlayCueLines(turn.cueLines):_trainSetState("ready"))}),(_Db=$("reh-train-record"))==null||_Db.addEventListener("click",()=>{(trainState.phase==="ready"||trainState.phase==="done")&&_trainStartRecording()}),(_Eb=$("reh-train-stop-rec"))==null||_Eb.addEventListener("click",_trainStopRecording),(_Fb=$("reh-train-prev"))==null||_Fb.addEventListener("click",()=>{trainState.turn>0&&trainGoTo(trainState.turn-1)}),(_Gb=$("reh-train-next"))==null||_Gb.addEventListener("click",()=>{trainState.turn{trainState.cueAudio&&(trainState.cueAudio.pause(),trainState.cueAudio=null),_trainStopMic();const turn=trainState.seq[trainState.turn];if(!turn)return;const resultEl=$("reh-train-result");resultEl&&(resultEl.hidden=!0),turn.cueLines.length?_trainPlayCueLines(turn.cueLines):_trainSetState("ready")}),rehRenderMeter(),_syncPageModeBtn(),renderLibraryList().catch(()=>{}),$("reh-skip-desc-toggle")&&($("reh-skip-desc-toggle").checked=rehState.skipDescriptions),window._rehearserStartImpEx)delete window._rehearserStartImpEx,showRehImpEx();else{const _initPhase=window._rehearserStartPhase||1;delete window._rehearserStartPhase,showPhase(_initPhase)}const readerState={mode:null,title:"",pdfDoc:null,pages:[],sentences:[],idx:0,readingIdx:-1,playing:!1,speed:1,scale:1,zoomMode:"fit-width",audioCtx:null,currentSource:null,raf:null,blobCache:new Map,bufCache:new Map,gainCache:new Map,unitsByPage:new Map,baseSentences:[],chunkMode:"sentence",normalize:!0,io:null,_seq:0,synthRunning:!1,synthCancel:!1,selecting:!1,selStart:null,selEnd:null,selAnchored:!1,fileBlob:null,docText:"",savedId:null,savedAudioIdx:new Set,sourceUploaded:!1,ocrWorker:null,synthStarted:!1};window.readerState=readerState;const READER_RESUME_KEY="reader-resume",READER_FMT="mp3",READER_BUF_WINDOW=3,READER_CANVAS_MARGIN=2200,READER_IMPORT_YIELD_EVERY=350,READER_PDF_INITIAL_PAGES=16,READER_PDF_INITIAL_MAX_WITHOUT_TEXT=64,READER_OCR_LANGS="deu+eng",READER_OCR_MIN_GAP_RATIO=.08,READER_OCR_MIN_GAP_PX=40,READER_OCR_RENDER_SCALE=2.5,READER_OCR_MIN_CONFIDENCE=40;function readerCtx(){return readerState.audioCtx||(readerState.audioCtx=new(window.AudioContext||window.webkitAudioContext)),readerState.audioCtx.state==="suspended"&&readerState.audioCtx.resume().catch(()=>{}),readerState.audioCtx}function readerYield(){return new Promise(resolve=>setTimeout(resolve,0))}window.readerOnShow=async function(){const sel=$("reader-backend-select");if(sel&&(!sel.value||sel.options.length<=1)){if(typeof availableTtsBackends=="function"&&!availableTtsBackends().length&&typeof refreshTtsBackendAvailability=="function")try{await refreshTtsBackendAvailability()}catch{}typeof availableTtsBackends=="function"&&availableTtsBackends().length&&(sel.innerHTML=ttsBackendOptions(sel.value),sel.disabled=!1)}readerUpdateBackendHint(),window.VoicePicker&&VoicePicker.upgrade("reader-voice-select"),readerRenderLibrary()},window.readerStop=function(){readerSaveResume(),readerPersistProgress(),readerStopPlayback()};async function readerFetchVoices(){var _a2;const btn=$("reader-fetch-voices-btn"),backend=(_a2=$("reader-backend-select"))==null?void 0:_a2.value;if(!backend){toast("No available TTS backend","error");return}btn&&(btn.disabled=!0);try{const raw=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json()),ids=(Array.isArray(raw)?raw:[]).map(v=>typeof v=="string"?v:v.id||v.name||v.voice_id).filter(Boolean),sel=$("reader-voice-select");if(!sel)return;const prev=sel.value;window.VoicePicker?(VoicePicker.upgrade("reader-voice-select"),VoicePicker.populate("reader-voice-select",ids),prev&&ids.includes(prev)?VoicePicker.setValue("reader-voice-select",prev):ids.length&&VoicePicker.setValue("reader-voice-select",ids[0])):(sel.innerHTML='',ids.forEach(id=>{const o=document.createElement("option");o.value=o.textContent=id,sel.appendChild(o)}),prev&&ids.includes(prev)&&(sel.value=prev)),toast("Fetched "+ids.length+" voices","success")}catch(e){toast("Fetch failed: "+(e.message||e),"error")}finally{btn&&(btn.disabled=!1)}}function readerUpdateBackendHint(){var _a2;const el=$("reader-backend-hint");if(!el)return;const id=((_a2=$("reader-backend-select"))==null?void 0:_a2.value)||"",b=typeof backendById=="function"?backendById(id):null,steady=id&&(b&&b.style_aware||/design|custom|kokoro|magpie/i.test(id));el.innerHTML=steady?' Good for long-form narration. For maximum steadiness, increase \u201CVoice consistency\u201D.':' Cloned / zero-shot voices re-sample each chunk, so the voice can drift between sections. To steady it: raise \u201CVoice consistency\u201D to paragraph/page, set a fixed Seed with a low Temperature (e.g. 0.3), keep \u201CNormalise loudness\u201D on, or pick a style-aware backend.',el.classList.toggle("reader-hint-warn",!steady)}function readerTitleFromText(text){return(String(text||"").split(/\r?\n/).map(x=>x.trim()).find(Boolean)||"Pasted text").replace(/\s+/g," ").slice(0,80)||"Pasted text"}function readerUpdateToolbarVisibility(loaded=!!readerState.sentences.length){const toolbar=$("reader-toolbar");toolbar&&(toolbar.hidden=!loaded);const pdfOnly=readerState.mode==="pdf",zoom=$("reader-zoom");zoom&&(zoom.hidden=!pdfOnly);const legend=document.querySelector(".reader-legend");legend&&(legend.hidden=!pdfOnly)}function readerFinishLoadedDocument({resume=!0,successPrefix="Loaded"}={}){if(!readerState.sentences.length)return toast("No readable text found in this document","error"),!1;readerState.mode==="text"&&readerState.sentences.forEach((s,i)=>readerSetStatus(i,"pending")),$("reader-transport").hidden=!1,$("reader-dropzone").hidden=!0;const importArea=$("reader-import-area");importArea&&(importArea.hidden=!0),readerUpdateToolbarVisibility(!0),$("reader-synthbar").hidden=!1;const pr=$("reader-page-range");if(pr&&(pr.hidden=readerState.mode!=="pdf",readerState.mode==="pdf")){const n=readerState.pages.length,pf=$("reader-page-from"),pt=$("reader-page-to");pf&&(pf.max=n,pf.value=1),pt&&(pt.max=n,pt.value=n)}readerUpdateScopeLabel();const resumed=resume&&readerLoadResume();return readerUpdateProgress(),readerHighlightSentence(readerState.sentences[readerState.idx]),toast(resumed?"Resumed at sentence "+(readerState.idx+1)+" / "+readerState.sentences.length:successPrefix+" \xB7 "+readerState.sentences.length+" sentences \u2014 press play","success"),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("source"),!0}async function readerImportFile(file){if(!file)return;const name=(file.name||"").toLowerCase();readerResetDoc(),readerState.title=file.name.replace(/\.[^.]+$/,""),readerState.fileBlob=file;const titleEl=$("reader-doc-title");titleEl&&(titleEl.textContent=readerState.title);try{if(name.endsWith(".pdf")){toast("Loading PDF\u2026","success");const loaded=await readerLoadPdfSkeleton(file);if(!loaded)return;readerShowExtractPrompt(loaded)}else{const text=await file.text();readerState.docText=text,await readerLoadText(text),readerFinishLoadedDocument({successPrefix:"Loaded"})}}catch(e){toast("Import failed: "+(e.message||e),"error")}}function readerShowExtractPrompt(loaded){const dz=$("reader-dropzone");dz&&(dz.hidden=!0);const importArea=$("reader-import-area");importArea&&(importArea.hidden=!0),readerUpdateToolbarVisibility(!0);const banner=$("reader-extract-banner");if(banner){banner.hidden=!1;const pc=$("reader-extract-pagecount");pc&&(pc.textContent=String(loaded.nPages))}const btn=$("reader-extract-btn");btn&&(btn.onclick=()=>readerRunExtraction(loaded)),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("source"),toast("PDF loaded \xB7 "+loaded.nPages+' pages \u2014 click "Extract Text" when ready',"success")}async function readerRunExtraction(loaded){const btn=$("reader-extract-btn"),original=btn?btn.innerHTML:"";btn&&(btn.disabled=!0,btn.innerHTML=' Extracting\u2026');try{await readerExtractPdfText(loaded);const banner=$("reader-extract-banner");banner&&(banner.hidden=!0),readerFinishLoadedDocument({resume:!1,successPrefix:"Extracted"})}catch(e){toast("Text extraction failed: "+(e.message||e),"error")}finally{btn&&(btn.disabled=!1,btn.innerHTML=original)}}async function readerImportPastedText(){const ta=$("reader-paste-text"),text=((ta==null?void 0:ta.value)||"").trim();if(!text){toast("Paste some text first","error"),ta==null||ta.focus();return}const btn=$("reader-paste-load");btn&&(btn.disabled=!0),readerResetDoc(),readerState.title=readerTitleFromText(text),readerState.docText=text,readerState.fileBlob=null,readerState.sourceUploaded=!1;const titleEl=$("reader-doc-title");titleEl&&(titleEl.textContent=readerState.title);try{await readerLoadText(text),readerFinishLoadedDocument({resume:!1,successPrefix:"Loaded pasted text"})}catch(e){toast("Import failed: "+(e.message||e),"error")}finally{btn&&(btn.disabled=!1)}}function readerResetDoc(){readerStopPlayback(),readerState._seq++,readerState.mode=null,readerState.pdfDoc=null,readerState.pages=[],readerState.sentences=[],readerState.idx=0,readerState.readingIdx=-1,readerState.scale=1,readerState.zoomMode="fit-width",readerState.blobCache.clear(),readerState.bufCache.clear(),readerState.gainCache.clear(),readerState.unitsByPage=new Map,readerState.baseSentences=[],readerState.fileBlob=null,readerState.docText="",readerState.savedId=null,readerState.savedAudioIdx=new Set,readerState.sourceUploaded=!1;const extractBanner=$("reader-extract-banner");extractBanner&&(extractBanner.hidden=!0),readerState.io&&(readerState.io.disconnect(),readerState.io=null),readerState.synthRunning=!1,readerState.synthCancel=!1,readerState.synthStarted=!1,readerSetSelecting(!1),readerState.selStart=readerState.selEnd=null,readerState.selAnchored=!1;const sb=$("reader-synthbar");sb&&(sb.hidden=!0);const tr=$("reader-transport");tr&&(tr.hidden=!0),readerUpdateToolbarVisibility(!1);const sp=$("reader-synth-prog");sp&&(sp.hidden=!0);const si=$("reader-sel-info");si&&(si.hidden=!0);const doc=$("reader-doc");doc&&(doc.innerHTML="",doc.classList.remove("two-page","reader-selecting","reader-synth-started")),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("source");const importArea=$("reader-import-area");importArea&&(importArea.hidden=!1),readerClearSearch({clearInput:!0})}const READER_NO_LEADING_SPACE_RE=/^[«"'’”)\]]+$/;function readerBuildSentences(words){const sentences=[];let cur=null;const endRe=/[.!?]["'”’)\]]?$/,abbrev=/^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;for(const w of words)w.para&&cur&&cur.words.length&&(sentences.push(cur),cur=null),cur||(cur={text:"",words:[],status:"pending",_stat:null,paraStart:!!w.para}),cur.words.push(w),cur.text+=(cur.text&&!READER_NO_LEADING_SPACE_RE.test(w.text)?" ":"")+w.text,(endRe.test(w.text)&&!abbrev.test(w.text.replace(/[^a-z.]/gi,""))&&cur.words.length>=2||cur.words.length>=45)&&(sentences.push(cur),cur=null);return cur&&cur.words.length&&sentences.push(cur),sentences}function readerGroupUnits(base,mode){if(mode==="sentence"||!base.length)return base.map(s=>({text:s.text,words:s.words,status:"pending",_stat:null,paraStart:!!s.paraStart}));const maxChars=mode==="page"?2e3:500,isPdf=readerState.mode==="pdf",keyOf=s=>{var _a2,_b2,_c2,_d2;return isPdf?(_b2=(_a2=s.words[0])==null?void 0:_a2.page)!=null?_b2:0:(_d2=(_c2=s.words[0])==null?void 0:_c2.para)!=null?_d2:0},units=[];let cur=null;for(const s of base){const k=keyOf(s),boundary=cur&&(mode==="paragraph"&&k!==cur._key||mode==="page"&&isPdf&&k!==cur._key),tooLong=cur&&cur.words.length&&cur.text.length+s.text.length+1>maxChars;(boundary||tooLong)&&(units.push(cur),cur=null),cur||(cur={text:"",words:[],status:"pending",_stat:null,_key:k,paraStart:!!s.paraStart}),cur.text+=cur.text?(s.paraStart?` -`:" ")+s.text:s.text,cur.words.push(...s.words)}return cur&&units.push(cur),units}function readerMarkParagraphBreaks(pageWords){if(!pageWords.length)return;const lines=[];let curLine=null,curTop=null;for(const w of pageWords)curLine&&Math.abs(w.top-curTop)a-b),median=sorted[Math.floor(sorted.length/2)]||0;if(!(median<=0))for(let i=1;imedian*1.35&&(lines[i][0].para=!0)}async function loadTesseract(){window.Tesseract||await new Promise((resolve,reject)=>{const s=document.createElement("script");s.src="/static/js/tesseract/tesseract.min.js",s.onload=resolve,s.onerror=reject,document.head.appendChild(s)})}async function readerGetOcrWorker(){return readerState.ocrWorker||(await loadTesseract(),readerState.ocrWorker=await Tesseract.createWorker(READER_OCR_LANGS,1,{workerPath:"/static/js/tesseract/worker.min.js",corePath:"/static/js/tesseract/tesseract-core-simd-lstm.wasm.js",langPath:"/static/js/tesseract/lang",gzip:!0})),readerState.ocrWorker}async function readerOcrPageHeading(page,base,pageIdx,gapPx){var _a2;let crop;try{const worker=await readerGetOcrWorker(),viewport=page.getViewport({scale:READER_OCR_RENDER_SCALE}),cropH=Math.max(Math.round(gapPx*READER_OCR_RENDER_SCALE),1);crop=document.createElement("canvas"),crop.width=viewport.width,crop.height=cropH,await page.render({canvasContext:crop.getContext("2d"),viewport}).promise;const{data}=await worker.recognize(crop),text=(data.text||"").replace(/\s+/g," ").trim();if(!text||((_a2=data.confidence)!=null?_a2:0)Math.max(base.height*READER_OCR_MIN_GAP_RATIO,READER_OCR_MIN_GAP_PX)){const ocrWords=await readerOcrPageHeading(page,base,pageIdx,gapPx);if(seq!==readerState._seq)return;ocrWords.length&&pageWords.unshift(...ocrWords)}}readerMarkParagraphBreaks(pageWords);const pageBase=readerBuildSentences(pageWords),pageUnits=readerGroupUnits(pageBase,readerState.chunkMode),startUnit=readerState.sentences.length;readerState.baseSentences.push(...pageBase),readerState.sentences.push(...pageUnits);const idxs=[];for(let u=startUnit;usetTimeout(r,0))}if(progress.done(),readerState.ocrWorker){try{readerState.ocrWorker.terminate()}catch{}readerState.ocrWorker=null}}async function readerLoadPdf(file){const loaded=await readerLoadPdfSkeleton(file);loaded&&await readerExtractPdfText(loaded)}function readerShowParseProgress(total){const doc=$("reader-doc");if(!doc)return{update(){},done(){}};const el=document.createElement("div");el.className="reader-parsing",el.innerHTML=' Reading PDF\u2026 page 0 / '+total+"",el.style.cssText="position:fixed; top:80px; left:50%; transform:translateX(-50%); z-index:100; background:var(--accent); color:#fff; padding:14px 28px; border-radius:32px; font-size:18px; display:inline-flex; align-items:center; gap:12px; box-shadow:0 6px 24px rgba(0,0,0,.35); font-weight:700;",doc.insertBefore(el,doc.firstChild);const label=el.querySelector("span:last-child");return{update(p){label&&(label.textContent="Reading PDF\u2026 page "+p+" / "+total)},done(){el.remove()}}}function readerComputeScale(mode){const doc=$("reader-doc");if(!doc||!readerState.pages.length)return 1;let maxW=0,maxH=0;for(const p of readerState.pages)p.base&&(p.base.width>maxW&&(maxW=p.base.width),p.base.height>maxH&&(maxH=p.base.height));if(!maxW)return 1;const padW=36,gap=16,availW=(doc.clientWidth||900)-padW,availH=(doc.clientHeight||600)-36;return mode==="fit-width"?Math.max(.2,availW/maxW):mode==="fit-height"?Math.max(.2,availH/maxH):mode==="two"?Math.max(.2,(availW-gap)/2/maxW):readerState.scale}function readerApplyZoom(mode){if(!readerState.pages.length)return;mode&&(readerState.zoomMode=mode),mode&&mode!=="custom"&&(readerState.scale=readerComputeScale(mode));const scale=readerState.scale;$("reader-doc").classList.toggle("two-page",readerState.zoomMode==="two"),readerState.pages.forEach(pg=>{pg.pageDiv.style.width=pg.base.width*scale+"px",pg.pageDiv.style.height=pg.base.height*scale+"px";const old=pg.pageDiv.querySelector("canvas.reader-canvas");if(old&&old.remove(),pg.renderTask){try{pg.renderTask.cancel()}catch{}pg.renderTask=null}pg.rendered=!1}),readerState.pages.forEach(pg=>pg.overlay.querySelectorAll(".reader-stat").forEach(el=>{const i=parseInt(el.dataset.si);isNaN(i)||readerPaintStatus(i)})),readerPaintSearchHighlights(),readerState.idx{entries.forEach(en=>{if(!en.isIntersecting)return;const idx=readerState.pages.findIndex(p=>p.pageDiv===en.target);idx>=0&&readerRenderPage(idx)})},{root:$("reader-doc"),rootMargin:"600px 0px"}),readerState.pages.forEach(p=>readerState.io.observe(p.pageDiv))}function readerRenderVisible(){$("reader-doc")&&(readerState.pages.forEach((pg,i)=>{readerIsPageNearView(pg,800)&&readerRenderPage(i)}),readerEvictCanvases())}function readerIsPageNearView(pg,margin=800){const doc=$("reader-doc");if(!doc||!(pg!=null&&pg.pageDiv))return!1;const dr=doc.getBoundingClientRect(),r=pg.pageDiv.getBoundingClientRect();return r.bottom>dr.top-margin&&r.top{if(!pg.rendered)return;const r=pg.pageDiv.getBoundingClientRect();if(r.bottomdr.bottom+READER_CANVAS_MARGIN){if(pg.renderTask){try{pg.renderTask.cancel()}catch{}pg.renderTask=null}const c=pg.pageDiv.querySelector("canvas.reader-canvas");c&&c.remove(),pg.rendered=!1}}))}async function readerRenderPage(idx){const pg=readerState.pages[idx];if(!pg||pg.rendered||!pg.page)return;pg.rendered=!0;const scale=readerState.scale,viewport=pg.page.getViewport({scale}),canvas=document.createElement("canvas");canvas.className="reader-canvas",canvas.width=Math.floor(viewport.width),canvas.height=Math.floor(viewport.height),pg.pageDiv.insertBefore(canvas,pg.overlay),readerCreatePageStatus(idx);try{pg.renderTask=pg.page.render({canvasContext:canvas.getContext("2d"),viewport}),await pg.renderTask.promise}catch{pg.rendered=!1,canvas.remove()}finally{pg.renderTask=null}}async function readerLoadText(text){readerState.mode="text",readerState.docText=text;const doc=$("reader-doc"),pane=document.createElement("div");pane.className="reader-text",doc.appendChild(pane);const words=[],paras=text.replace(/\r\n/g,` +`:" ")+s.text:s.text,cur.words.push(...s.words)}return cur&&units.push(cur),units}function readerMarkParagraphBreaks(pageWords){if(!pageWords.length)return;const lines=[];let curLine=null,curTop=null;for(const w of pageWords)curLine&&Math.abs(w.top-curTop)a-b),median=sorted[Math.floor(sorted.length/2)]||0;if(!(median<=0))for(let i=1;imedian*1.35&&(lines[i][0].para=!0)}async function loadTesseract(){window.Tesseract||await new Promise((resolve,reject)=>{const s=document.createElement("script");s.src="/static/js/tesseract/tesseract.min.js",s.onload=resolve,s.onerror=reject,document.head.appendChild(s)})}async function readerGetOcrWorker(){return readerState.ocrWorker||(await loadTesseract(),readerState.ocrWorker=await Tesseract.createWorker(READER_OCR_LANGS,1,{workerPath:"/static/js/tesseract/worker.min.js",corePath:"/static/js/tesseract/tesseract-core-simd-lstm.wasm.js",langPath:"/static/js/tesseract/lang",gzip:!0})),readerState.ocrWorker}function _readerTimeout(promise,ms,label){return new Promise(function(resolve,reject){const t=setTimeout(function(){reject(new Error((label||"operation")+" timed out after "+ms+"ms"))},ms);promise.then(function(v){clearTimeout(t),resolve(v)},function(e){clearTimeout(t),reject(e)})})}async function readerOcrPageHeading(page,base,pageIdx,gapPx){var _a2;let crop;try{const worker=await readerGetOcrWorker(),viewport=page.getViewport({scale:READER_OCR_RENDER_SCALE}),cropH=Math.max(Math.round(gapPx*READER_OCR_RENDER_SCALE),1);crop=document.createElement("canvas"),crop.width=viewport.width,crop.height=cropH,await _readerTimeout(page.render({canvasContext:crop.getContext("2d"),viewport}).promise,15e3,"Page render");const{data}=await _readerTimeout(worker.recognize(crop),2e4,"Heading OCR"),text=(data.text||"").replace(/\s+/g," ").trim();if(!text||((_a2=data.confidence)!=null?_a2:0)Math.max(base.height*READER_OCR_MIN_GAP_RATIO,READER_OCR_MIN_GAP_PX)){const ocrWords=await readerOcrPageHeading(page,base,pageIdx,gapPx);if(seq!==readerState._seq)return;ocrWords.length&&pageWords.unshift(...ocrWords)}}readerMarkParagraphBreaks(pageWords);const pageBase=readerBuildSentences(pageWords),pageUnits=readerGroupUnits(pageBase,readerState.chunkMode),startUnit=readerState.sentences.length;readerState.baseSentences.push(...pageBase),readerState.sentences.push(...pageUnits);const idxs=[];for(let u=startUnit;usetTimeout(r,0))}if(progress.done(),readerState.ocrWorker){try{readerState.ocrWorker.terminate()}catch{}readerState.ocrWorker=null}}async function readerLoadPdf(file){const loaded=await readerLoadPdfSkeleton(file);loaded&&await readerExtractPdfText(loaded)}function readerShowParseProgress(total){const doc=$("reader-doc");if(!doc)return{update(){},done(){}};const el=document.createElement("div");el.className="reader-parsing",el.innerHTML=' Reading PDF\u2026 page 0 / '+total+"",el.style.cssText="position:fixed; top:80px; left:50%; transform:translateX(-50%); z-index:100; background:var(--accent); color:#fff; padding:14px 28px; border-radius:32px; font-size:18px; display:inline-flex; align-items:center; gap:12px; box-shadow:0 6px 24px rgba(0,0,0,.35); font-weight:700;",doc.insertBefore(el,doc.firstChild);const label=el.querySelector("span:last-child");return{update(p){label&&(label.textContent="Reading PDF\u2026 page "+p+" / "+total)},done(){el.remove()}}}function readerComputeScale(mode){const doc=$("reader-doc");if(!doc||!readerState.pages.length)return 1;let maxW=0,maxH=0;for(const p of readerState.pages)p.base&&(p.base.width>maxW&&(maxW=p.base.width),p.base.height>maxH&&(maxH=p.base.height));if(!maxW)return 1;const padW=36,gap=16,availW=(doc.clientWidth||900)-padW,availH=(doc.clientHeight||600)-36;return mode==="fit-width"?Math.max(.2,availW/maxW):mode==="fit-height"?Math.max(.2,availH/maxH):mode==="two"?Math.max(.2,(availW-gap)/2/maxW):readerState.scale}function readerApplyZoom(mode){if(!readerState.pages.length)return;mode&&(readerState.zoomMode=mode),mode&&mode!=="custom"&&(readerState.scale=readerComputeScale(mode));const scale=readerState.scale;$("reader-doc").classList.toggle("two-page",readerState.zoomMode==="two"),readerState.pages.forEach(pg=>{pg.pageDiv.style.width=pg.base.width*scale+"px",pg.pageDiv.style.height=pg.base.height*scale+"px";const old=pg.pageDiv.querySelector("canvas.reader-canvas");if(old&&old.remove(),pg.renderTask){try{pg.renderTask.cancel()}catch{}pg.renderTask=null}pg.rendered=!1}),readerState.pages.forEach(pg=>pg.overlay.querySelectorAll(".reader-stat").forEach(el=>{const i=parseInt(el.dataset.si);isNaN(i)||readerPaintStatus(i)})),readerPaintSearchHighlights(),readerState.idx{entries.forEach(en=>{if(!en.isIntersecting)return;const idx=readerState.pages.findIndex(p=>p.pageDiv===en.target);idx>=0&&readerRenderPage(idx)})},{root:$("reader-doc"),rootMargin:"600px 0px"}),readerState.pages.forEach(p=>readerState.io.observe(p.pageDiv))}function readerRenderVisible(){$("reader-doc")&&(readerState.pages.forEach((pg,i)=>{readerIsPageNearView(pg,800)&&readerRenderPage(i)}),readerEvictCanvases())}function readerIsPageNearView(pg,margin=800){const doc=$("reader-doc");if(!doc||!(pg!=null&&pg.pageDiv))return!1;const dr=doc.getBoundingClientRect(),r=pg.pageDiv.getBoundingClientRect();return r.bottom>dr.top-margin&&r.top{if(!pg.rendered)return;const r=pg.pageDiv.getBoundingClientRect();if(r.bottomdr.bottom+READER_CANVAS_MARGIN){if(pg.renderTask){try{pg.renderTask.cancel()}catch{}pg.renderTask=null}const c=pg.pageDiv.querySelector("canvas.reader-canvas");c&&c.remove(),pg.rendered=!1}}))}const READER_MAX_CANVAS_PIXELS=2e6,READER_MAX_CANVAS_SIDE=1800;async function readerRenderPage(idx){var _a2;const pg=readerState.pages[idx];if(!pg||pg.rendered||!pg.page)return;pg.rendered=!0;const scale=readerState.scale,viewport=pg.page.getViewport({scale});let renderViewport=viewport;const overArea=viewport.width*viewport.height>READER_MAX_CANVAS_PIXELS,overSide=viewport.width>READER_MAX_CANVAS_SIDE||viewport.height>READER_MAX_CANVAS_SIDE;if(overArea||overSide){let fit=overArea?Math.sqrt(READER_MAX_CANVAS_PIXELS/(viewport.width*viewport.height)):1;fit=Math.min(fit,READER_MAX_CANVAS_SIDE/Math.max(viewport.width,viewport.height)),renderViewport=pg.page.getViewport({scale:scale*fit})}const canvas=document.createElement("canvas");canvas.className="reader-canvas",canvas.width=Math.floor(renderViewport.width),canvas.height=Math.floor(renderViewport.height),canvas.style.width=Math.floor(viewport.width)+"px",canvas.style.height=Math.floor(viewport.height)+"px",pg.pageDiv.insertBefore(canvas,pg.overlay),readerCreatePageStatus(idx);try{pg.renderTask=pg.page.render({canvasContext:canvas.getContext("2d"),viewport:renderViewport}),await _readerTimeout(pg.renderTask.promise,15e3,"Page render")}catch{pg.rendered=!1,canvas.remove();try{(_a2=pg.renderTask)==null||_a2.cancel()}catch{}}finally{pg.renderTask=null}}async function readerLoadText(text){readerState.mode="text",readerState.docText=text;const doc=$("reader-doc"),pane=document.createElement("div");pane.className="reader-text",doc.appendChild(pane);const words=[],paras=text.replace(/\r\n/g,` `).split(/\n{2,}/),frag=document.createDocumentFragment();let renderedWords=0;for(let pi=0;pi{const span=e.target.closest(".reader-word");if(!span)return;const si=readerState.sentences.findIndex(s=>s.words.some(w=>w.el===span));si<0||(readerState.selecting?readerPickSentence(si):readerJumpTo(si))})}function readerSentBBox(sentence){var _a2,_b2;const pageIdx=(_b2=(_a2=sentence.words[0])==null?void 0:_a2.page)!=null?_b2:0,on=sentence.words.filter(w=>w.page===pageIdx);return{page:pageIdx,x:Math.min(...on.map(w=>w.x)),top:Math.min(...on.map(w=>w.top)),w:Math.max(...on.map(w=>w.x+w.w))-Math.min(...on.map(w=>w.x)),h:Math.max(...on.map(w=>w.top+w.h))-Math.min(...on.map(w=>w.top))}}function readerBuildUnitIndex(){readerState.unitsByPage=new Map,readerState.mode==="pdf"&&readerState.sentences.forEach((s,i)=>{var _a2,_b2;const pg=(_b2=(_a2=s.words[0])==null?void 0:_a2.page)!=null?_b2:0;readerState.unitsByPage.has(pg)||readerState.unitsByPage.set(pg,[]),readerState.unitsByPage.get(pg).push(i)})}function readerCreatePageStatus(pageIdx){var _a2;if(readerState.mode!=="pdf")return;const pg=readerState.pages[pageIdx];if(!pg)return;const idxs=((_a2=readerState.unitsByPage)==null?void 0:_a2.get(pageIdx))||[];for(const i of idxs){const s=readerState.sentences[i];if(!s||s._stat)continue;const el=document.createElement("div");el.className="reader-stat",el.dataset.si=i,pg.overlay.appendChild(el),s._stat=el,readerPaintStatus(i)}}function readerEffectiveStatus(s){return s.status}function readerSetStatus(idx,status2){var _a2;const s=readerState.sentences[idx];s&&(s.status=status2,status2!=="pending"&&!readerState.synthStarted&&(readerState.synthStarted=!0,(_a2=$("reader-doc"))==null||_a2.classList.add("reader-synth-started")),readerPaintStatus(idx))}function readerPaintStatus(idx){const s=readerState.sentences[idx];if(!s)return;const eff=readerEffectiveStatus(s);if(readerState.mode==="pdf"){if(!s._stat)return;const b=readerSentBBox(s),scale=readerState.scale;Object.assign(s._stat.style,{left:b.x*scale+"px",top:b.top*scale+"px",width:b.w*scale+"px",height:b.h*scale+"px"}),s._stat.className="reader-stat reader-stat-"+eff}else s.words.forEach(w=>{w.el&&(w.el.classList.remove("stat-pending","stat-synth","stat-ready","stat-reading"),w.el.classList.add("stat-"+eff))})}let _readerWordBox=null;function readerEnsureBox(){_readerWordBox||(_readerWordBox=document.createElement("div"),_readerWordBox.className="reader-hl-word")}function readerClearHighlights(){readerState.mode==="text"?document.querySelectorAll(".reader-word.is-word").forEach(el=>el.classList.remove("is-word")):_readerWordBox&&(_readerWordBox.hidden=!0)}function readerEnsureVisible(el){const doc=$("reader-doc");if(!doc||!el)return;const dr=doc.getBoundingClientRect(),er=el.getBoundingClientRect();if(er.topdr.bottom-50){const delta=er.top-dr.top-doc.clientHeight*.38;doc.scrollTo({top:doc.scrollTop+delta,behavior:"smooth"})}}function readerHighlightSentence(sentence){var _a2;sentence&&(readerState.mode==="text"?readerEnsureVisible((_a2=sentence.words[0])==null?void 0:_a2.el):sentence._stat&&readerEnsureVisible(sentence._stat))}function readerHighlightWord(sentence,wi){const w=sentence.words[wi];if(!w)return;if(readerState.mode==="text"){document.querySelectorAll(".reader-word.is-word").forEach(el=>el.classList.remove("is-word")),w.el&&(w.el.classList.add("is-word"),readerEnsureVisible(w.el));return}readerEnsureBox();const pg=readerState.pages[w.page];if(!pg)return;const scale=readerState.scale;_readerWordBox.parentNode!==pg.overlay&&pg.overlay.appendChild(_readerWordBox),Object.assign(_readerWordBox.style,{left:w.x*scale+"px",top:w.top*scale+"px",width:w.w*scale+"px",height:w.h*scale+"px"}),_readerWordBox.hidden=!1,readerEnsureVisible(_readerWordBox)}function readerSetSelecting(on){readerState.selecting=!!on,readerState.selAnchored=!1;const doc=$("reader-doc");doc&&doc.classList.toggle("reader-selecting",readerState.selecting);const btn=$("reader-select-toggle");btn&&btn.classList.toggle("active",readerState.selecting);const lbl=$("reader-select-label");lbl&&(lbl.textContent=readerState.selecting?"Done":"Select range");const ic=$("reader-select-icon");ic&&(ic.className="mdi "+(readerState.selecting?"mdi-check":"mdi-cursor-default-click-outline")),readerSelHint(readerState.selecting?"Click the start sentence\u2026":""),readerState.selecting||readerPaintSelection()}function readerSelHint(html){const h=$("reader-sel-hint");h&&(h.innerHTML=html,h.hidden=!html)}function readerPickSentence(si){if(!readerState.selAnchored)readerState.selStart=si,readerState.selEnd=si,readerState.selAnchored=!0,readerSelHint("Start at sentence "+(si+1)+" \u2014 now click the end sentence");else{readerState.selEnd=si,readerState.selAnchored=!1;const r=readerSelRange();readerSelHint("Selected "+(r[1]-r[0]+1)+" sentences \u2014 click a new start, or Done")}readerPaintSelection(),readerUpdateScopeLabel()}function readerClearSelection(){readerState.selStart=readerState.selEnd=null,readerState.selAnchored=!1,readerPaintSelection(),readerUpdateScopeLabel(),readerState.selecting&&readerSelHint("Click the start sentence\u2026")}function readerSelRange(){return readerState.selStart===null||readerState.selEnd===null?null:[Math.min(readerState.selStart,readerState.selEnd),Math.max(readerState.selStart,readerState.selEnd)]}function readerPaintSelection(){const range=readerSelRange(),inSel=i=>range&&i>=range[0]&&i<=range[1],anchorI=readerState.selAnchored&&range&&range[0]===range[1]?range[0]:-1;readerState.sentences.forEach((s,i)=>{const sel=inSel(i)&&i!==anchorI,anchor=i===anchorI;readerState.mode==="pdf"?s._stat&&(s._stat.classList.toggle("sel",sel),s._stat.classList.toggle("sel-anchor",anchor)):s.words.forEach(w=>{w.el&&(w.el.classList.toggle("in-sel",sel),w.el.classList.toggle("sel-anchor",anchor))})})}function readerScopeIndices(){var _a2,_b2;const all=readerState.sentences.map((_,i)=>i),range=readerSelRange();if(range)return all.filter(i=>i>=range[0]&&i<=range[1]);if(readerState.mode==="pdf"){const n=readerState.pages.length;let from=parseInt((_a2=$("reader-page-from"))==null?void 0:_a2.value)||1,to=parseInt((_b2=$("reader-page-to"))==null?void 0:_b2.value)||n;return from=Math.max(1,Math.min(from,n)),to=Math.max(from,Math.min(to,n)),all.filter(i=>{var _a3,_b3;const p=((_b3=(_a3=readerState.sentences[i].words[0])==null?void 0:_a3.page)!=null?_b3:0)+1;return p>=from&&p<=to})}return all}function readerUpdateScopeLabel(){const scopeEl=$("reader-synth-scope"),range=readerSelRange();scopeEl&&(range?scopeEl.textContent="selection":readerState.mode==="pdf"?scopeEl.textContent="pages":scopeEl.textContent="all");const info=$("reader-sel-info"),txt=$("reader-sel-text");info&&(info.hidden=!range),txt&&range&&(txt.textContent="sentences "+(range[0]+1)+"\u2013"+(range[1]+1)+" ("+(range[1]-range[0]+1)+")")}async function readerSynthIndices(targets){var _a2,_b2,_c2;if(targets=targets.filter(i=>!readerState.blobCache.has(i)),!targets.length||readerState.synthRunning)return 0;const voice=(_a2=$("reader-voice-select"))==null?void 0:_a2.value,backend=(_b2=$("reader-backend-select"))==null?void 0:_b2.value;if(!voice)return toast("Pick a voice first","error"),0;if(!backend)return toast("No TTS backend selected","error"),0;const instruct=((_c2=$("reader-instruct"))==null?void 0:_c2.value.trim())||"";readerState.synthRunning=!0,readerState.synthCancel=!1;const prog=$("reader-synth-prog");prog&&(prog.hidden=!1);const total=targets.length;let done=0;const update=()=>{const f=$("reader-synth-fill");f&&(f.style.width=done/total*100+"%");const l=$("reader-synth-label");l&&(l.textContent=done+" / "+total)};update();const queue=targets.slice(),worker=async()=>{for(;queue.length&&!readerState.synthCancel;){const i=queue.shift();if(readerState.blobCache.has(i)){done++,update();continue}readerState.sentences[i].status==="pending"&&readerSetStatus(i,"synth");try{const blob=await fetchTtsPreviewBlob(voice,readerState.sentences[i].text,READER_FMT,instruct,backend,!1,readerGenParams());readerState.blobCache.set(i,blob),readerState.sentences[i].status==="synth"&&readerSetStatus(i,"ready")}catch{readerState.sentences[i].status==="synth"&&readerSetStatus(i,"pending")}done++,update()}},N=Math.min(2,targets.length);return await Promise.all(Array.from({length:N},worker)),readerState.synthRunning=!1,prog&&(prog.hidden=!0),done}async function readerSynthAll(){const targets=readerScopeIndices().filter(i=>!readerState.blobCache.has(i));if(!targets.length){toast("Selected range is already synthesised","success");return}const done=await readerSynthIndices(targets);(done||!readerState.synthCancel)&&toast(readerState.synthCancel?"Synthesis cancelled ("+done+" done)":"Synthesised "+done+" sentences",readerState.synthCancel?"error":"success")}function readerSafeName(s){return(s||"audio").replace(/[\/\\:*?"<>|]+/g,"_").replace(/\s+/g," ").trim().slice(0,60)||"audio"}function readerPad(n){return String(n).padStart(2,"0")}function readerPageOf(i){var _a2,_b2;return readerState.mode==="pdf"?((_b2=(_a2=readerState.sentences[i].words[0])==null?void 0:_a2.page)!=null?_b2:0)+1:1}function readerDownload(blob,filename){const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=filename,document.body.appendChild(a),a.click(),setTimeout(()=>{URL.revokeObjectURL(a.href),a.remove()},1500)}const _readerDelay=ms=>new Promise(r=>setTimeout(r,ms));async function readerExport(mode){if(!readerState.sentences.length){toast("Import a document first","error");return}const indices=readerScopeIndices(),missing=indices.filter(i=>!readerState.blobCache.has(i));if(missing.length&&(toast("Synthesising "+missing.length+" missing sentence(s) before export\u2026","success"),await readerSynthIndices(missing),readerState.synthCancel)){toast("Export cancelled","error");return}const ready=indices.filter(i=>readerState.blobCache.has(i));if(!ready.length){toast("Nothing to export","error");return}const title=readerSafeName(readerState.title);if(mode==="sentence"){const perPage={};for(const i of ready){const pg=readerPageOf(i);perPage[pg]=(perPage[pg]||0)+1;const name=readerState.mode==="pdf"?`${title} - p${readerPad(pg)} - ${readerPad(perPage[pg])}.mp3`:`${title} - ${readerPad(perPage[pg])}.mp3`;readerDownload(readerState.blobCache.get(i),name),await _readerDelay(350)}toast("Exported "+ready.length+" MP3 files","success");return}const byPage=new Map;ready.forEach(i=>{const pg=readerPageOf(i);byPage.has(pg)||byPage.set(pg,[]),byPage.get(pg).push(i)});let files=0;for(const[pg,idxs]of[...byPage.entries()].sort((a,b)=>a[0]-b[0])){const blob=new Blob(idxs.map(i=>readerState.blobCache.get(i)),{type:"audio/mpeg"}),name=readerState.mode==="pdf"?`${title} - p${readerPad(pg)}.mp3`:`${title}.mp3`;readerDownload(blob,name),files++,await _readerDelay(400)}toast("Exported "+files+(readerState.mode==="pdf"?" page MP3 file(s)":" MP3"),"success")}function readerRechunk(mode){readerState.chunkMode=mode,readerState.baseSentences.length&&(readerStopPlayback(),readerState.blobCache.clear(),readerState.bufCache.clear(),readerState.gainCache.clear(),readerState.savedAudioIdx=new Set,readerState.pages.forEach(p=>p.overlay.querySelectorAll(".reader-stat").forEach(e=>e.remove())),readerClearSelection(),readerState.sentences=readerGroupUnits(readerState.baseSentences,mode),readerBuildUnitIndex(),readerState.pages.forEach((p,i)=>{p.rendered&&readerCreatePageStatus(i)}),readerState.mode==="text"&&readerState.sentences.forEach((s,i)=>readerSetStatus(i,"pending")),readerState.idx=0,readerUpdateScopeLabel(),readerUpdateProgress(),readerHighlightSentence(readerState.sentences[0]),toast("Voice consistency: "+mode+" \u2014 audio cleared, re-synthesise","success"))}function readerGenParams(){var _a2,_b2,_c2;const out={},seed=(_a2=$("reader-seed"))==null?void 0:_a2.value.trim(),temp=(_b2=$("reader-temp"))==null?void 0:_b2.value.trim(),nspd=(_c2=$("reader-tts-speed"))==null?void 0:_c2.value.trim();return seed!==""&&seed!=null&&!isNaN(+seed)&&(out.seed=parseInt(seed,10)),temp!==""&&temp!=null&&!isNaN(+temp)&&(out.temperature=parseFloat(temp)),nspd!==""&&nspd!=null&&!isNaN(+nspd)&&parseFloat(nspd)!==1&&(out.speed=parseFloat(nspd)),Object.keys(out).length?out:null}function readerComputeGain(idx,buf){if(readerState.gainCache.has(idx))return readerState.gainCache.get(idx);const ch=buf.getChannelData(0),step=Math.max(1,Math.floor(ch.length/8e3));let sum=0,n=0,peak=1e-6;for(let i=0;ipeak&&(peak=Math.abs(v))}let gain=.1/(Math.sqrt(sum/Math.max(1,n))||1e-4);return gain=Math.max(.5,Math.min(gain,4)),gain=Math.min(gain,.99/peak),readerState.gainCache.set(idx,gain),gain}async function readerFetchServerAudio(idx){if(!readerState.savedId||!readerState.savedAudioIdx.has(idx))return null;try{const r=await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`);if(r.ok){const b=await r.blob();return readerState.blobCache.set(idx,b),b}}catch{}return null}async function readerGetBuffer(idx){var _a2,_b2,_c2;if(readerState.bufCache.has(idx))return readerState.bufCache.get(idx);let blob=readerState.blobCache.get(idx)||await readerFetchServerAudio(idx);if(!blob){const voice=(_a2=$("reader-voice-select"))==null?void 0:_a2.value,backend=(_b2=$("reader-backend-select"))==null?void 0:_b2.value;if(!voice)throw new Error("Pick a voice first");if(!backend)throw new Error("No TTS backend selected");const instruct=((_c2=$("reader-instruct"))==null?void 0:_c2.value.trim())||"";readerState.sentences[idx].status!=="reading"&&readerSetStatus(idx,"synth"),blob=await fetchTtsPreviewBlob(voice,readerState.sentences[idx].text,READER_FMT,instruct,backend,!1,readerGenParams()),readerState.blobCache.set(idx,blob),readerState.sentences[idx].status==="synth"&&readerSetStatus(idx,"ready")}const buf=await readerCtx().decodeAudioData(await blob.arrayBuffer());return readerState.bufCache.set(idx,buf),buf}function readerEvictBuffers(center){if(!(readerState.bufCache.size<=READER_BUF_WINDOW*2+1))for(const i of readerState.bufCache.keys())Math.abs(i-center)>READER_BUF_WINDOW&&readerState.bufCache.delete(i)}async function readerPrefetch(idx){var _a2,_b2,_c2;if(!(idx<0||idx>=readerState.sentences.length)){if(!readerState.blobCache.has(idx)&&!await readerFetchServerAudio(idx)){const voice=(_a2=$("reader-voice-select"))==null?void 0:_a2.value,backend=(_b2=$("reader-backend-select"))==null?void 0:_b2.value;if(!voice||!backend)return;const instruct=((_c2=$("reader-instruct"))==null?void 0:_c2.value.trim())||"";readerState.sentences[idx].status==="pending"&&readerSetStatus(idx,"synth");try{const b=await fetchTtsPreviewBlob(voice,readerState.sentences[idx].text,READER_FMT,instruct,backend,!1,readerGenParams());readerState.blobCache.set(idx,b),readerState.sentences[idx].status==="synth"&&readerSetStatus(idx,"ready")}catch{readerState.sentences[idx].status==="synth"&&readerSetStatus(idx,"pending");return}}if(!readerState.bufCache.has(idx)&&Math.abs(idx-readerState.idx)<=READER_BUF_WINDOW)try{readerState.bufCache.set(idx,await readerCtx().decodeAudioData(await readerState.blobCache.get(idx).arrayBuffer()))}catch{}}}function readerClearReading(){const i=readerState.readingIdx;i>=0&&i=readerState.sentences.length){readerStopPlayback(),readerState.idx=0,readerUpdateProgress();return}const idx=readerState.idx,sentence=readerState.sentences[idx];readerUpdateProgress(),readerSaveResume(),readerState.readingIdx=idx,readerSetStatus(idx,"reading"),readerHighlightSentence(sentence);let buf;try{buf=await readerGetBuffer(idx)}catch(e){toast(e.message||String(e),"error"),readerSetStatus(idx,"pending"),readerStopPlayback();return}if(!readerState.playing||readerState.idx!==idx)return;readerEvictBuffers(idx);const timings=computeWordTimings(sentence.text,buf.duration),ctx=readerCtx();readerStopSource();const src=ctx.createBufferSource();if(src.buffer=buf,src.playbackRate.value=readerState.speed,readerState.normalize){const g=ctx.createGain();g.gain.value=readerComputeGain(idx,buf),src.connect(g),g.connect(ctx.destination)}else src.connect(ctx.destination);readerState.currentSource=src;const t0=ctx.currentTime;readerPrefetch(idx+1),src.onended=()=>{readerState.currentSource===src&&(readerState.currentSource=null,readerState.raf&&(cancelAnimationFrame(readerState.raf),readerState.raf=null),readerSetStatus(idx,"ready"),readerState.readingIdx===idx&&(readerState.readingIdx=-1),readerState.playing&&(readerState.idx++,readerPlayCurrent()))},src.start(0);const tick=()=>{if(readerState.currentSource!==src)return;const elapsed=(ctx.currentTime-t0)*readerState.speed;let active=0;for(let i=timings.length-1;i>=0;i--)if(elapsed>=timings[i].start){active=i;break}readerHighlightWord(sentence,Math.min(active,sentence.words.length-1)),readerState.raf=requestAnimationFrame(tick)};readerState.raf=requestAnimationFrame(tick)}function readerStopSource(){if(readerState.currentSource){try{readerState.currentSource.onended=null,readerState.currentSource.stop(0)}catch{}readerState.currentSource=null}readerState.raf&&(cancelAnimationFrame(readerState.raf),readerState.raf=null)}function readerStopPlayback(){readerState.playing=!1,readerStopSource(),readerClearHighlights(),readerClearReading(),readerUpdatePlayBtn(),typeof window.setNavBusy=="function"&&window.setNavBusy("s-reader",!1)}function readerPlay(){var _a2;if(!readerState.sentences.length){toast("Import a document first","error");return}if(!((_a2=$("reader-voice-select"))!=null&&_a2.value)){toast("Pick a voice first","error");return}readerCtx(),readerState.playing=!0,readerUpdatePlayBtn(),typeof window.setNavBusy=="function"&&window.setNavBusy("s-reader",!0),readerPlayCurrent()}function readerPause(){readerState.playing=!1,readerStopSource(),readerSaveResume(),readerPersistProgress(),readerClearReading(),readerUpdatePlayBtn()}function readerJumpTo(idx){readerState.idx=Math.max(0,Math.min(idx,readerState.sentences.length-1)),readerStopSource(),readerClearHighlights(),readerClearReading(),readerUpdateProgress(),readerSaveResume(),readerState.playing?readerPlayCurrent():readerHighlightSentence(readerState.sentences[readerState.idx])}function readerUpdatePlayBtn(){const btn=$("reader-play");if(!btn)return;const ic=btn.querySelector(".mdi");ic&&(ic.className="mdi "+(readerState.playing?"mdi-pause":"mdi-play"))}function readerUpdateProgress(){const total=readerState.sentences.length,cur=total?readerState.idx+1:0,lbl=$("reader-progress-label");lbl&&(lbl.textContent=cur+" / "+total);const fill=$("reader-progress-fill");fill&&(fill.style.width=(total?readerState.idx/total*100:0)+"%")}function readerSaveResume(){if(!(!readerState.title||!readerState.sentences.length))try{localStorage.setItem(READER_RESUME_KEY,JSON.stringify({title:readerState.title,idx:readerState.idx,total:readerState.sentences.length}))}catch{}}function readerLoadResume(){try{const r=JSON.parse(localStorage.getItem(READER_RESUME_KEY)||"null");if(r&&r.title===readerState.title&&r.idx>0&&r.idx({}))).detail||r.statusText);const fresh=!readerState.savedId;if(readerState.savedId=(await r.json()).id,fresh||!readerState.sourceUploaded){const ext=readerState.mode==="pdf"?"pdf":"txt",body=readerState.mode==="pdf"?readerState.fileBlob:new Blob([readerState.docText||""],{type:"text/plain"});(await fetch(`${READER_API}/${readerState.savedId}/source?ext=${ext}`,{method:"PUT",body})).ok&&(readerState.sourceUploaded=!0)}let uploaded=0;for(let i=0;i{const file=inp.files[0];if(!file)return;if(!(await fetch(`${READER_API}/${id}/source?ext=pdf`,{method:"PUT",body:file})).ok){toast("Re-upload failed","error");return}toast("PDF restored \u2014 opening\u2026","success"),readerState.savedId=id,readerState.sourceUploaded=!0,readerState.fileBlob=file,await readerLoadPdf(file)},inp.click(),toast("PDF source missing \u2014 please re-select the original file","error");return}readerState.fileBlob=sourceBlob,await readerLoadPdf(sourceBlob)}else{const text=sourceBlob?await sourceBlob.text():rec.text||"";readerState.docText=text,await readerLoadText(text)}}catch(e){toast("Open failed: "+(e.message||e),"error");return}if(!readerState.sentences.length){toast("Document had no readable text","error");return}readerState.mode==="text"&&readerState.sentences.forEach((s,i)=>readerSetStatus(i,"pending")),readerState.savedId=id,readerState.sourceUploaded=!0,readerState.savedAudioIdx=new Set,rec.sentenceCount===readerState.sentences.length?(rec.audioIdx||[]).forEach(i=>{io.value===value)){const o=document.createElement("option");o.value=o.textContent=value,sel.appendChild(o)}sel.value=value,id==="reader-voice-select"&&window.VoicePicker&&VoicePicker.setValue(id,value)}}async function readerRenderLibrary(){const card=$("reader-library-card"),list=$("reader-lib-list");if(!card||!list)return;let all=[];try{const r=await fetch(READER_API);r.ok&&(all=(await r.json()).docs||[])}catch{all=[]}if(!all.length){list.innerHTML='
No saved books yet.
';return}list.innerHTML=all.map(rec=>{const total=rec.sentenceCount||0,synth=rec.synthCount||0,readPct=total?Math.round((rec.idx||0)/total*100):0,synthPct=total?Math.round(synth/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"";let h=0;const titleStr=rec.title||"Untitled";for(let i=0;i +`);for(let li=0;li{const span=e.target.closest(".reader-word");if(!span)return;const si=readerState.sentences.findIndex(s=>s.words.some(w=>w.el===span));si<0||(readerState.selecting?readerPickSentence(si):readerJumpTo(si))})}function readerSentBBox(sentence){var _a2,_b2;const pageIdx=(_b2=(_a2=sentence.words[0])==null?void 0:_a2.page)!=null?_b2:0,on=sentence.words.filter(w=>w.page===pageIdx);return{page:pageIdx,x:Math.min(...on.map(w=>w.x)),top:Math.min(...on.map(w=>w.top)),w:Math.max(...on.map(w=>w.x+w.w))-Math.min(...on.map(w=>w.x)),h:Math.max(...on.map(w=>w.top+w.h))-Math.min(...on.map(w=>w.top))}}function readerBuildUnitIndex(){readerState.unitsByPage=new Map,readerState.mode==="pdf"&&readerState.sentences.forEach((s,i)=>{var _a2,_b2;const pg=(_b2=(_a2=s.words[0])==null?void 0:_a2.page)!=null?_b2:0;readerState.unitsByPage.has(pg)||readerState.unitsByPage.set(pg,[]),readerState.unitsByPage.get(pg).push(i)})}function readerCreatePageStatus(pageIdx){var _a2;if(readerState.mode!=="pdf")return;const pg=readerState.pages[pageIdx];if(!pg)return;const idxs=((_a2=readerState.unitsByPage)==null?void 0:_a2.get(pageIdx))||[];for(const i of idxs){const s=readerState.sentences[i];if(!s||s._stat)continue;const el=document.createElement("div");el.className="reader-stat",el.dataset.si=i,pg.overlay.appendChild(el),s._stat=el,readerPaintStatus(i)}}function readerEffectiveStatus(s){return s.status}function readerSetStatus(idx,status2){var _a2;const s=readerState.sentences[idx];s&&(s.status=status2,status2!=="pending"&&!readerState.synthStarted&&(readerState.synthStarted=!0,(_a2=$("reader-doc"))==null||_a2.classList.add("reader-synth-started")),readerPaintStatus(idx))}function readerPaintStatus(idx){const s=readerState.sentences[idx];if(!s)return;const eff=readerEffectiveStatus(s);if(readerState.mode==="pdf"){if(!s._stat)return;const b=readerSentBBox(s),scale=readerState.scale;Object.assign(s._stat.style,{left:b.x*scale+"px",top:b.top*scale+"px",width:b.w*scale+"px",height:b.h*scale+"px"}),s._stat.className="reader-stat reader-stat-"+eff}else s.words.forEach(w=>{w.el&&(w.el.classList.remove("stat-pending","stat-synth","stat-ready","stat-reading"),w.el.classList.add("stat-"+eff))})}let _readerWordBox=null;function readerEnsureBox(){_readerWordBox||(_readerWordBox=document.createElement("div"),_readerWordBox.className="reader-hl-word")}function readerClearHighlights(){readerState.mode==="text"?document.querySelectorAll(".reader-word.is-word").forEach(el=>el.classList.remove("is-word")):_readerWordBox&&(_readerWordBox.hidden=!0)}function readerEnsureVisible(el){const doc=$("reader-doc");if(!doc||!el)return;const dr=doc.getBoundingClientRect(),er=el.getBoundingClientRect();if(er.topdr.bottom-50){const delta=er.top-dr.top-doc.clientHeight*.38;doc.scrollTo({top:doc.scrollTop+delta,behavior:"smooth"})}}function readerHighlightSentence(sentence){var _a2;sentence&&(readerState.mode==="text"?readerEnsureVisible((_a2=sentence.words[0])==null?void 0:_a2.el):sentence._stat&&readerEnsureVisible(sentence._stat))}function readerHighlightWord(sentence,wi){const w=sentence.words[wi];if(!w)return;if(readerState.mode==="text"){document.querySelectorAll(".reader-word.is-word").forEach(el=>el.classList.remove("is-word")),w.el&&(w.el.classList.add("is-word"),readerEnsureVisible(w.el));return}readerEnsureBox();const pg=readerState.pages[w.page];if(!pg)return;const scale=readerState.scale;_readerWordBox.parentNode!==pg.overlay&&pg.overlay.appendChild(_readerWordBox),Object.assign(_readerWordBox.style,{left:w.x*scale+"px",top:w.top*scale+"px",width:w.w*scale+"px",height:w.h*scale+"px"}),_readerWordBox.hidden=!1,readerEnsureVisible(_readerWordBox)}function readerSetSelecting(on){readerState.selecting=!!on,readerState.selAnchored=!1;const doc=$("reader-doc");doc&&doc.classList.toggle("reader-selecting",readerState.selecting);const btn=$("reader-select-toggle");btn&&btn.classList.toggle("active",readerState.selecting);const lbl=$("reader-select-label");lbl&&(lbl.textContent=readerState.selecting?"Done":"Select range");const ic=$("reader-select-icon");ic&&(ic.className="mdi "+(readerState.selecting?"mdi-check":"mdi-cursor-default-click-outline")),readerSelHint(readerState.selecting?"Click the start sentence\u2026":""),readerState.selecting||readerPaintSelection()}function readerSelHint(html){const h=$("reader-sel-hint");h&&(h.innerHTML=html,h.hidden=!html)}function readerPickSentence(si){if(!readerState.selAnchored)readerState.selStart=si,readerState.selEnd=si,readerState.selAnchored=!0,readerSelHint("Start at sentence "+(si+1)+" \u2014 now click the end sentence");else{readerState.selEnd=si,readerState.selAnchored=!1;const r=readerSelRange();readerSelHint("Selected "+(r[1]-r[0]+1)+" sentences \u2014 click a new start, or Done")}readerPaintSelection(),readerUpdateScopeLabel()}function readerClearSelection(){readerState.selStart=readerState.selEnd=null,readerState.selAnchored=!1,readerPaintSelection(),readerUpdateScopeLabel(),readerState.selecting&&readerSelHint("Click the start sentence\u2026")}function readerSelRange(){return readerState.selStart===null||readerState.selEnd===null?null:[Math.min(readerState.selStart,readerState.selEnd),Math.max(readerState.selStart,readerState.selEnd)]}function readerPaintSelection(){const range=readerSelRange(),inSel=i=>range&&i>=range[0]&&i<=range[1],anchorI=readerState.selAnchored&&range&&range[0]===range[1]?range[0]:-1;readerState.sentences.forEach((s,i)=>{const sel=inSel(i)&&i!==anchorI,anchor=i===anchorI;readerState.mode==="pdf"?s._stat&&(s._stat.classList.toggle("sel",sel),s._stat.classList.toggle("sel-anchor",anchor)):s.words.forEach(w=>{w.el&&(w.el.classList.toggle("in-sel",sel),w.el.classList.toggle("sel-anchor",anchor))})})}function readerScopeIndices(){var _a2,_b2;const all=readerState.sentences.map((_,i)=>i),range=readerSelRange();if(range)return all.filter(i=>i>=range[0]&&i<=range[1]);if(readerState.mode==="pdf"){const n=readerState.pages.length;let from=parseInt((_a2=$("reader-page-from"))==null?void 0:_a2.value)||1,to=parseInt((_b2=$("reader-page-to"))==null?void 0:_b2.value)||n;return from=Math.max(1,Math.min(from,n)),to=Math.max(from,Math.min(to,n)),all.filter(i=>{var _a3,_b3;const p=((_b3=(_a3=readerState.sentences[i].words[0])==null?void 0:_a3.page)!=null?_b3:0)+1;return p>=from&&p<=to})}return all}function readerUpdateScopeLabel(){const scopeEl=$("reader-synth-scope"),range=readerSelRange();scopeEl&&(range?scopeEl.textContent="selection":readerState.mode==="pdf"?scopeEl.textContent="pages":scopeEl.textContent="all");const info=$("reader-sel-info"),txt=$("reader-sel-text");info&&(info.hidden=!range),txt&&range&&(txt.textContent="sentences "+(range[0]+1)+"\u2013"+(range[1]+1)+" ("+(range[1]-range[0]+1)+")")}async function readerSynthIndices(targets){var _a2,_b2,_c2;if(targets=targets.filter(i=>!readerState.blobCache.has(i)),!targets.length||readerState.synthRunning)return{done:0,failed:[]};const voice=(_a2=$("reader-voice-select"))==null?void 0:_a2.value,backend=(_b2=$("reader-backend-select"))==null?void 0:_b2.value;if(!voice)return toast("Pick a voice first","error"),{done:0,failed:[]};if(!backend)return toast("No TTS backend selected","error"),{done:0,failed:[]};const instruct=((_c2=$("reader-instruct"))==null?void 0:_c2.value.trim())||"";readerState.synthRunning=!0,readerState.synthCancel=!1;const prog=$("reader-synth-prog");prog&&(prog.hidden=!1);const total=targets.length;let done=0;const failed=[],update=()=>{const f=$("reader-synth-fill");f&&(f.style.width=done/total*100+"%");const l=$("reader-synth-label");l&&(l.textContent=done+" / "+total)};update();try{const queue=targets.slice(),worker=async()=>{var _a3;for(;queue.length&&!readerState.synthCancel;){const i=queue.shift();if(readerState.blobCache.has(i)){done++,update();continue}readerState.sentences[i].status==="pending"&&readerSetStatus(i,"synth");try{const blob=await fetchTtsPreviewBlob(voice,readerState.sentences[i].text,READER_FMT,instruct,backend,!1,readerGenParams());readerState.blobCache.set(i,blob),readerState.sentences[i].status==="synth"&&readerSetStatus(i,"ready")}catch(e){failed.push(i),((_a3=readerState.sentences[i])==null?void 0:_a3.status)==="synth"&&readerSetStatus(i,"pending"),console.error("[reader] synth failed for sentence",i,e)}done++,update()}},N=Math.min(2,targets.length);await Promise.all(Array.from({length:N},worker))}finally{readerState.synthRunning=!1,prog&&(prog.hidden=!0)}return{done,failed}}async function readerSynthAll(){const targets=readerScopeIndices().filter(i=>!readerState.blobCache.has(i));if(!targets.length){toast("Selected range is already synthesised","success");return}const{done,failed}=await readerSynthIndices(targets);if(readerState.synthCancel){toast("Synthesis cancelled ("+done+" done)","error");return}if(failed.length){toast(done-failed.length+" / "+done+" sentences synthesised \u2014 "+failed.length+" failed, see red markers","error");return}toast("Synthesised "+done+" sentences","success")}function readerSafeName(s){return(s||"audio").replace(/[\/\\:*?"<>|]+/g,"_").replace(/\s+/g," ").trim().slice(0,60)||"audio"}function readerPad(n){return String(n).padStart(2,"0")}function readerPageOf(i){var _a2,_b2;return readerState.mode==="pdf"?((_b2=(_a2=readerState.sentences[i].words[0])==null?void 0:_a2.page)!=null?_b2:0)+1:1}function readerDownload(blob,filename){const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=filename,document.body.appendChild(a),a.click(),setTimeout(()=>{URL.revokeObjectURL(a.href),a.remove()},1500)}const _readerDelay=ms=>new Promise(r=>setTimeout(r,ms));async function readerExport(mode){if(!readerState.sentences.length){toast("Import a document first","error");return}const indices=readerScopeIndices(),missing=indices.filter(i=>!readerState.blobCache.has(i));let failedCount=0;if(missing.length){toast("Synthesising "+missing.length+" missing sentence(s) before export\u2026","success");const{failed}=await readerSynthIndices(missing);if(readerState.synthCancel){toast("Export cancelled","error");return}failedCount=failed.length}const ready=indices.filter(i=>readerState.blobCache.has(i));if(!ready.length){toast("Nothing to export","error");return}if(failedCount){toast(failedCount+" sentence(s) failed to synthesise \u2014 fix them (see red markers) before exporting, or missing audio will silently drop from the file","error");return}const title=readerSafeName(readerState.title);if(mode==="sentence"){const perPage={};for(const i of ready){const pg=readerPageOf(i);perPage[pg]=(perPage[pg]||0)+1;const name=readerState.mode==="pdf"?`${title} - p${readerPad(pg)} - ${readerPad(perPage[pg])}.mp3`:`${title} - ${readerPad(perPage[pg])}.mp3`;readerDownload(readerState.blobCache.get(i),name),await _readerDelay(350)}toast("Exported "+ready.length+" MP3 files","success");return}const byPage=new Map;ready.forEach(i=>{const pg=readerPageOf(i);byPage.has(pg)||byPage.set(pg,[]),byPage.get(pg).push(i)});let files=0;for(const[pg,idxs]of[...byPage.entries()].sort((a,b)=>a[0]-b[0])){const blob=new Blob(idxs.map(i=>readerState.blobCache.get(i)),{type:"audio/mpeg"}),name=readerState.mode==="pdf"?`${title} - p${readerPad(pg)}.mp3`:`${title}.mp3`;readerDownload(blob,name),files++,await _readerDelay(400)}toast("Exported "+files+(readerState.mode==="pdf"?" page MP3 file(s)":" MP3"),"success")}function readerRechunk(mode){readerState.chunkMode=mode,readerState.baseSentences.length&&(readerStopPlayback(),readerState.blobCache.clear(),readerState.bufCache.clear(),readerState.gainCache.clear(),readerState.savedAudioIdx=new Set,readerState.pages.forEach(p=>p.overlay.querySelectorAll(".reader-stat").forEach(e=>e.remove())),readerClearSelection(),readerState.sentences=readerGroupUnits(readerState.baseSentences,mode),readerBuildUnitIndex(),readerState.pages.forEach((p,i)=>{p.rendered&&readerCreatePageStatus(i)}),readerState.mode==="text"&&readerState.sentences.forEach((s,i)=>readerSetStatus(i,"pending")),readerState.idx=0,readerUpdateScopeLabel(),readerUpdateProgress(),readerHighlightSentence(readerState.sentences[0]),toast("Voice consistency: "+mode+" \u2014 audio cleared, re-synthesise","success"))}function readerGenParams(){var _a2,_b2,_c2;const out={},seed=(_a2=$("reader-seed"))==null?void 0:_a2.value.trim(),temp=(_b2=$("reader-temp"))==null?void 0:_b2.value.trim(),nspd=(_c2=$("reader-tts-speed"))==null?void 0:_c2.value.trim();return seed!==""&&seed!=null&&!isNaN(+seed)&&(out.seed=parseInt(seed,10)),temp!==""&&temp!=null&&!isNaN(+temp)&&(out.temperature=parseFloat(temp)),nspd!==""&&nspd!=null&&!isNaN(+nspd)&&parseFloat(nspd)!==1&&(out.speed=parseFloat(nspd)),Object.keys(out).length?out:null}function readerComputeGain(idx,buf){if(readerState.gainCache.has(idx))return readerState.gainCache.get(idx);const ch=buf.getChannelData(0),step=Math.max(1,Math.floor(ch.length/8e3));let sum=0,n=0,peak=1e-6;for(let i=0;ipeak&&(peak=Math.abs(v))}let gain=.1/(Math.sqrt(sum/Math.max(1,n))||1e-4);return gain=Math.max(.5,Math.min(gain,4)),gain=Math.min(gain,.99/peak),readerState.gainCache.set(idx,gain),gain}async function readerFetchServerAudio(idx){if(!readerState.savedId||!readerState.savedAudioIdx.has(idx))return null;try{const r=await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`);if(r.ok){const b=await r.blob();return readerState.blobCache.set(idx,b),b}}catch{}return null}async function readerGetBuffer(idx){var _a2,_b2,_c2;if(readerState.bufCache.has(idx))return readerState.bufCache.get(idx);let blob=readerState.blobCache.get(idx)||await readerFetchServerAudio(idx);if(!blob){const voice=(_a2=$("reader-voice-select"))==null?void 0:_a2.value,backend=(_b2=$("reader-backend-select"))==null?void 0:_b2.value;if(!voice)throw new Error("Pick a voice first");if(!backend)throw new Error("No TTS backend selected");const instruct=((_c2=$("reader-instruct"))==null?void 0:_c2.value.trim())||"";readerState.sentences[idx].status!=="reading"&&readerSetStatus(idx,"synth"),blob=await fetchTtsPreviewBlob(voice,readerState.sentences[idx].text,READER_FMT,instruct,backend,!1,readerGenParams()),readerState.blobCache.set(idx,blob),readerState.sentences[idx].status==="synth"&&readerSetStatus(idx,"ready")}const buf=await readerCtx().decodeAudioData(await blob.arrayBuffer());return readerState.bufCache.set(idx,buf),buf}function readerEvictBuffers(center){if(!(readerState.bufCache.size<=READER_BUF_WINDOW*2+1))for(const i of readerState.bufCache.keys())Math.abs(i-center)>READER_BUF_WINDOW&&readerState.bufCache.delete(i)}async function readerPrefetch(idx){var _a2,_b2,_c2;if(!(idx<0||idx>=readerState.sentences.length)){if(!readerState.blobCache.has(idx)&&!await readerFetchServerAudio(idx)){const voice=(_a2=$("reader-voice-select"))==null?void 0:_a2.value,backend=(_b2=$("reader-backend-select"))==null?void 0:_b2.value;if(!voice||!backend)return;const instruct=((_c2=$("reader-instruct"))==null?void 0:_c2.value.trim())||"";readerState.sentences[idx].status==="pending"&&readerSetStatus(idx,"synth");try{const b=await fetchTtsPreviewBlob(voice,readerState.sentences[idx].text,READER_FMT,instruct,backend,!1,readerGenParams());readerState.blobCache.set(idx,b),readerState.sentences[idx].status==="synth"&&readerSetStatus(idx,"ready")}catch{readerState.sentences[idx].status==="synth"&&readerSetStatus(idx,"pending");return}}if(!readerState.bufCache.has(idx)&&Math.abs(idx-readerState.idx)<=READER_BUF_WINDOW)try{readerState.bufCache.set(idx,await readerCtx().decodeAudioData(await readerState.blobCache.get(idx).arrayBuffer()))}catch{}}}function readerClearReading(){const i=readerState.readingIdx;i>=0&&i=readerState.sentences.length){readerStopPlayback(),readerState.idx=0,readerUpdateProgress();return}const idx=readerState.idx,sentence=readerState.sentences[idx];readerUpdateProgress(),readerSaveResume(),readerState.readingIdx=idx,readerSetStatus(idx,"reading"),readerHighlightSentence(sentence);let buf;try{buf=await readerGetBuffer(idx)}catch(e){toast(e.message||String(e),"error"),readerSetStatus(idx,"pending"),readerStopPlayback();return}if(!readerState.playing||readerState.idx!==idx)return;readerEvictBuffers(idx);const timings=computeWordTimings(sentence.text,buf.duration),ctx=readerCtx();readerStopSource();const src=ctx.createBufferSource();if(src.buffer=buf,src.playbackRate.value=readerState.speed,readerState.normalize){const g=ctx.createGain();g.gain.value=readerComputeGain(idx,buf),src.connect(g),g.connect(ctx.destination)}else src.connect(ctx.destination);readerState.currentSource=src;const t0=ctx.currentTime;readerPrefetch(idx+1),src.onended=()=>{readerState.currentSource===src&&(readerState.currentSource=null,readerState.raf&&(cancelAnimationFrame(readerState.raf),readerState.raf=null),readerSetStatus(idx,"ready"),readerState.readingIdx===idx&&(readerState.readingIdx=-1),readerState.playing&&(readerState.idx++,readerPlayCurrent()))},src.start(0);const tick=()=>{if(readerState.currentSource!==src)return;const elapsed=(ctx.currentTime-t0)*readerState.speed;let active=0;for(let i=timings.length-1;i>=0;i--)if(elapsed>=timings[i].start){active=i;break}readerHighlightWord(sentence,Math.min(active,sentence.words.length-1)),readerState.raf=requestAnimationFrame(tick)};readerState.raf=requestAnimationFrame(tick)}function readerStopSource(){if(readerState.currentSource){try{readerState.currentSource.onended=null,readerState.currentSource.stop(0)}catch{}readerState.currentSource=null}readerState.raf&&(cancelAnimationFrame(readerState.raf),readerState.raf=null)}function readerStopPlayback(){readerState.playing=!1,readerStopSource(),readerClearHighlights(),readerClearReading(),readerUpdatePlayBtn(),typeof window.setNavBusy=="function"&&window.setNavBusy("s-reader",!1)}function readerPlay(){var _a2;if(!readerState.sentences.length){toast("Import a document first","error");return}if(!((_a2=$("reader-voice-select"))!=null&&_a2.value)){toast("Pick a voice first","error");return}readerCtx(),readerState.playing=!0,readerUpdatePlayBtn(),typeof window.setNavBusy=="function"&&window.setNavBusy("s-reader",!0),readerPlayCurrent()}function readerPause(){readerState.playing=!1,readerStopSource(),readerSaveResume(),readerPersistProgress(),readerClearReading(),readerUpdatePlayBtn()}function readerJumpTo(idx){readerState.idx=Math.max(0,Math.min(idx,readerState.sentences.length-1)),readerStopSource(),readerClearHighlights(),readerClearReading(),readerUpdateProgress(),readerSaveResume(),readerState.playing?readerPlayCurrent():readerHighlightSentence(readerState.sentences[readerState.idx])}function readerUpdatePlayBtn(){const btn=$("reader-play");if(!btn)return;const ic=btn.querySelector(".mdi");ic&&(ic.className="mdi "+(readerState.playing?"mdi-pause":"mdi-play"))}function readerUpdateProgress(){const total=readerState.sentences.length,cur=total?readerState.idx+1:0,lbl=$("reader-progress-label");lbl&&(lbl.textContent=cur+" / "+total);const fill=$("reader-progress-fill");fill&&(fill.style.width=(total?readerState.idx/total*100:0)+"%")}function readerSaveResume(){if(!(!readerState.title||!readerState.sentences.length))try{localStorage.setItem(READER_RESUME_KEY,JSON.stringify({title:readerState.title,idx:readerState.idx,total:readerState.sentences.length}))}catch{}}function readerLoadResume(){try{const r=JSON.parse(localStorage.getItem(READER_RESUME_KEY)||"null");if(r&&r.title===readerState.title&&r.idx>0&&r.idx({}))).detail||r.statusText);const fresh=!readerState.savedId;if(readerState.savedId=(await r.json()).id,fresh||!readerState.sourceUploaded){const ext=readerState.mode==="pdf"?"pdf":"txt",body=readerState.mode==="pdf"?readerState.fileBlob:new Blob([readerState.docText||""],{type:"text/plain"});(await fetch(`${READER_API}/${readerState.savedId}/source?ext=${ext}`,{method:"PUT",body})).ok&&(readerState.sourceUploaded=!0)}let uploaded=0;for(let i=0;i{const file=inp.files[0];if(!file)return;if(!(await fetch(`${READER_API}/${id}/source?ext=pdf`,{method:"PUT",body:file})).ok){toast("Re-upload failed","error");return}toast("PDF restored \u2014 opening\u2026","success"),readerState.savedId=id,readerState.sourceUploaded=!0,readerState.fileBlob=file,await readerLoadPdf(file)},inp.click(),toast("PDF source missing \u2014 please re-select the original file","error");return}readerState.fileBlob=sourceBlob,await readerLoadPdf(sourceBlob)}else{const text=sourceBlob?await sourceBlob.text():rec.text||"";readerState.docText=text,await readerLoadText(text)}}catch(e){toast("Open failed: "+(e.message||e),"error");return}if(!readerState.sentences.length){toast("Document had no readable text","error");return}readerState.mode==="text"&&readerState.sentences.forEach((s,i)=>readerSetStatus(i,"pending")),readerState.savedId=id,readerState.sourceUploaded=!0,readerState.savedAudioIdx=new Set,rec.sentenceCount===readerState.sentences.length?(rec.audioIdx||[]).forEach(i=>{io.value===value)){const o=document.createElement("option");o.value=o.textContent=value,sel.appendChild(o)}sel.value=value,id==="reader-voice-select"&&window.VoicePicker&&VoicePicker.setValue(id,value)}}async function readerRenderLibrary(){const card=$("reader-library-card"),list=$("reader-lib-list");if(!card||!list)return;let all=[];try{const r=await fetch(READER_API);r.ok&&(all=(await r.json()).docs||[])}catch{all=[]}if(!all.length){list.innerHTML='
No saved books yet.
';return}list.innerHTML=all.map(rec=>{const total=rec.sentenceCount||0,synth=rec.synthCount||0,readPct=total?Math.round((rec.idx||0)/total*100):0,synthPct=total?Math.round(synth/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"";let h=0;const titleStr=rec.title||"Untitled";for(let i=0;i
@@ -1125,11 +1132,13 @@ ${lines.trim()}
- `,confirmOverlay.addEventListener("click",ce=>ce.stopPropagation()),confirmOverlay.querySelector("#btn-cancel-del").addEventListener("click",ce=>{ce.stopPropagation(),confirmOverlay.remove()}),confirmOverlay.querySelector("#btn-confirm-del").addEventListener("click",async ce=>{ce.stopPropagation(),confirmOverlay.innerHTML='';try{if(!(await fetch(`${READER_API}/${el.dataset.id}`,{method:"DELETE"})).ok)throw new Error("Failed to delete book");toast("Book deleted","success"),readerRenderLibrary()}catch(err){toast(err.message,"error"),confirmOverlay.remove()}}),el.appendChild(confirmOverlay)}),el.addEventListener("dragover",e=>{e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.style.borderColor="var(--accent)")}),el.addEventListener("dragleave",()=>{el.style.borderColor=""}),el.addEventListener("drop",async e=>{var _a2;if(!(e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")))return;e.preventDefault(),e.stopPropagation(),el.style.borderColor="";const f=(_a2=e.dataTransfer.files)==null?void 0:_a2[0];if(!f||!f.type.startsWith("image/")){toast("Please drop an image file","error");return}try{const id=el.dataset.id,r=await fetch(`${READER_API}/${id}/cover`,{method:"PUT",body:f});if(!r.ok)throw new Error(r.statusText);await fetch(`${READER_API}/${id}/progress`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({updated:new Date().toISOString()})}),toast("Cover image updated","success"),readerRenderLibrary()}catch(err){toast("Failed to upload cover: "+(err.message||err),"error")}})}),list.querySelectorAll(".reader-book-del").forEach(btn=>btn.addEventListener("click",e=>{e.stopPropagation(),confirm("Delete this saved document and its audio?")&&readerDeleteLibraryDoc(btn.dataset.del)}))}(_Hb=$("reader-file-input"))==null||_Hb.addEventListener("change",function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];this.value="",readerImportFile(f)}),(_Ib=$("reader-paste-load"))==null||_Ib.addEventListener("click",readerImportPastedText),(_Jb=$("reader-paste-text"))==null||_Jb.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&e.key==="Enter"&&(e.preventDefault(),readerImportPastedText())}),(_Kb=$("reader-fetch-voices-btn"))==null||_Kb.addEventListener("click",readerFetchVoices),(_Lb=$("reader-backend-select"))==null||_Lb.addEventListener("change",()=>{const sel=$("reader-voice-select");sel&&(sel.innerHTML=''),window.VoicePicker&&(VoicePicker.upgrade("reader-voice-select"),VoicePicker.populate("reader-voice-select",[])),readerUpdateBackendHint()}),(_Mb=$("reader-chunk-mode"))==null||_Mb.addEventListener("change",function(){readerRechunk(this.value)}),(_Nb=$("reader-normalize"))==null||_Nb.addEventListener("change",function(){readerState.normalize=this.checked}),(_Ob=$("reader-play"))==null||_Ob.addEventListener("click",()=>{readerState.playing?readerPause():readerPlay()}),(_Pb=$("reader-stop"))==null||_Pb.addEventListener("click",()=>{readerStopPlayback(),readerState.idx=0,readerSaveResume(),readerUpdateProgress()}),(_Qb=$("reader-prev"))==null||_Qb.addEventListener("click",()=>readerJumpTo(readerState.idx-1)),(_Rb=$("reader-next"))==null||_Rb.addEventListener("click",()=>readerJumpTo(readerState.idx+1)),(_Sb=$("reader-speed"))==null||_Sb.addEventListener("input",function(){readerState.speed=parseFloat(this.value)||1;const lbl=$("reader-speed-label");lbl&&(lbl.textContent=readerState.speed.toFixed(2).replace(/0$/,"")+"\xD7"),readerState.currentSource&&(readerState.currentSource.playbackRate.value=readerState.speed)}),(_Tb=$("reader-zoom-fitw"))==null||_Tb.addEventListener("click",()=>readerApplyZoom("fit-width")),(_Ub=$("reader-zoom-fith"))==null||_Ub.addEventListener("click",()=>readerApplyZoom("fit-height")),(_Vb=$("reader-zoom-two"))==null||_Vb.addEventListener("click",()=>readerApplyZoom("two")),(_Wb=$("reader-zoom-in"))==null||_Wb.addEventListener("click",()=>{readerState.scale=Math.min(4,readerState.scale*1.2),readerApplyZoom("custom")}),(_Xb=$("reader-zoom-out"))==null||_Xb.addEventListener("click",()=>{readerState.scale=Math.max(.2,readerState.scale/1.2),readerApplyZoom("custom")});let _readerSearchTerm="",_readerSearchHits=[],_readerSearchCurrent=-1,_readerSearchTimer=null;const READER_SEARCH_MAX_HITS=2500;function readerSearchTextMap(words){var _a2;let text="";const spans=[];for(let i=0;isp.end>pos&&sp.startsp.wordIndex);idxs.length&&groups.push(idxs),pos=Math.max(pos+1,end)}return groups}function readerRemoveSearchHighlights(){document.querySelectorAll(".reader-search-hit").forEach(el=>el.remove()),document.querySelectorAll(".reader-word.search-hit, .reader-word.search-current").forEach(el=>{el.classList.remove("search-hit","search-current")})}function readerSetSearchStatus(text){const st=$("reader-search-status");st&&(st.textContent=text||"")}function readerClearSearch({clearInput=!1}={}){if(_readerSearchTerm="",_readerSearchHits=[],_readerSearchCurrent=-1,readerRemoveSearchHighlights(),readerSetSearchStatus(""),clearInput){const inp=$("reader-search");inp&&(inp.value="")}}function readerCollectSearchHits(term){var _a2,_b2,_c2;const hits=[],sents=readerState.sentences||[];for(let si=0;si=READER_SEARCH_MAX_HITS)return hits}}return hits}function readerPaintSearchHighlights(){if(readerRemoveSearchHighlights(),!_readerSearchTerm||!_readerSearchHits.length)return;const scale=readerState.scale||1;_readerSearchHits.forEach((hit,hi)=>{var _a2;const s=readerState.sentences[hit.sentenceIndex];if(s){hit.nodes=[];for(const wi of hit.wordIndexes){const w=(_a2=s.words)==null?void 0:_a2[wi];if(w)if(readerState.mode==="pdf"){const pg=readerState.pages[w.page];if(!(pg!=null&&pg.overlay))continue;const el=document.createElement("div");el.className="reader-search-hit"+(hi===_readerSearchCurrent?" search-current":""),Object.assign(el.style,{left:w.x*scale+"px",top:w.top*scale+"px",width:Math.max(w.w,2)*scale+"px",height:Math.max(w.h,2)*scale+"px"}),pg.overlay.appendChild(el),hit.nodes.push(el)}else w.el&&(w.el.classList.add("search-hit"),hi===_readerSearchCurrent&&w.el.classList.add("search-current"),hit.nodes.push(w.el))}}})}function readerUpdateSearchStatus(){var _a2,_b2;if(!_readerSearchTerm){readerSetSearchStatus("");return}if(!((_a2=readerState.sentences)!=null&&_a2.length)){readerSetSearchStatus("No text loaded");return}if(!_readerSearchHits.length){readerSetSearchStatus("0/0");return}const current=_readerSearchCurrent>=0?_readerSearchCurrent+1:1,hit=_readerSearchHits[Math.max(0,_readerSearchCurrent)],capped=_readerSearchHits.length>=READER_SEARCH_MAX_HITS?"+":"",page=readerState.mode==="pdf"?" \xB7 p."+(((_b2=hit==null?void 0:hit.page)!=null?_b2:0)+1):"";readerSetSearchStatus(current+"/"+_readerSearchHits.length+capped+page)}function readerJumpToSearchHit(idx){var _a2;if(!_readerSearchHits.length)return;_readerSearchCurrent=(idx+_readerSearchHits.length)%_readerSearchHits.length,readerPaintSearchHighlights(),readerUpdateSearchStatus();const hit=_readerSearchHits[_readerSearchCurrent],node=(_a2=hit==null?void 0:hit.nodes)==null?void 0:_a2[0];if(node)readerEnsureVisible(node);else if(readerState.mode==="pdf"){const pg=readerState.pages[hit.page];pg!=null&&pg.pageDiv&&(readerRenderPage(hit.page),pg.pageDiv.scrollIntoView({behavior:"smooth",block:"center"}))}}function readerSearchApply(term,{jump=!0}={}){if(term=String(term||"").trim(),!term){readerClearSearch();return}_readerSearchTerm=term,_readerSearchHits=readerCollectSearchHits(term),_readerSearchCurrent=_readerSearchHits.length?0:-1,readerPaintSearchHighlights(),readerUpdateSearchStatus(),jump&&_readerSearchHits.length&&readerJumpToSearchHit(0)}function readerSearchStep(direction){var _a2;const term=((_a2=$("reader-search"))==null?void 0:_a2.value.trim())||_readerSearchTerm;if(!_readerSearchHits.length||term!==_readerSearchTerm){readerSearchApply(term,{jump:!0});return}readerJumpToSearchHit(_readerSearchCurrent+(direction===-1?-1:1))}(_Yb=$("reader-search"))==null||_Yb.addEventListener("keydown",e=>{e.key==="Enter"&&(e.preventDefault(),readerSearchStep(e.shiftKey?-1:1)),e.key==="Escape"&&readerClearSearch({clearInput:!0})}),(_Zb=$("reader-search"))==null||_Zb.addEventListener("input",e=>{clearTimeout(_readerSearchTimer);const term=e.target.value.trim();if(!term){readerClearSearch();return}_readerSearchTimer=setTimeout(()=>readerSearchApply(term,{jump:!0}),160)}),(__b=$("reader-search-prev"))==null||__b.addEventListener("click",()=>readerSearchStep(-1)),(_$b=$("reader-search-next"))==null||_$b.addEventListener("click",()=>readerSearchStep(1)),(_ac=$("reader-search-clear"))==null||_ac.addEventListener("click",()=>readerClearSearch({clearInput:!0})),(_bc=$("reader-save-lib"))==null||_bc.addEventListener("click",readerSaveLibrary),(_cc=$("reader-export-btn"))==null||_cc.addEventListener("click",()=>{var _a2;return readerExport(((_a2=$("reader-export-mode"))==null?void 0:_a2.value)||"page")}),(_dc=$("reader-synth-all"))==null||_dc.addEventListener("click",readerSynthAll),(_ec=$("reader-synth-cancel"))==null||_ec.addEventListener("click",()=>{readerState.synthCancel=!0}),(_fc=$("reader-select-toggle"))==null||_fc.addEventListener("click",()=>{readerState.selecting?readerSetSelecting(!1):(readerClearSelection(),readerSetSelecting(!0))}),(_gc=$("reader-sel-clear"))==null||_gc.addEventListener("click",readerClearSelection),document.addEventListener("keydown",e=>{e.key==="Escape"&&readerState.selecting&&readerSetSelecting(!1)}),(_hc=$("reader-page-from"))==null||_hc.addEventListener("change",readerUpdateScopeLabel),(_ic=$("reader-page-to"))==null||_ic.addEventListener("change",readerUpdateScopeLabel),(_jc=$("reader-doc"))==null||_jc.addEventListener("click",e=>{if(!readerState.selecting||readerState.mode!=="pdf")return;const stat=e.target.closest(".reader-stat");if(!stat)return;const si=parseInt(stat.dataset.si);isNaN(si)||readerPickSentence(si)}),(_kc=$("reader-doc"))==null||_kc.addEventListener("scroll",function(){let t=null;return()=>{t||(t=setTimeout(()=>{t=null,readerRenderVisible()},120))}}()),window.addEventListener("resize",function(){let t=null;return()=>{readerState.mode!=="pdf"||readerState.zoomMode==="custom"||(clearTimeout(t),t=setTimeout(()=>readerApplyZoom(readerState.zoomMode),200))}}()),function(){["reader-dropzone","reader-doc"].map(id=>$(id)).filter(Boolean).forEach(el=>{el.addEventListener("dragover",e=>{e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.classList.add("dragover"))}),el.addEventListener("dragleave",()=>el.classList.remove("dragover")),el.addEventListener("drop",e=>{var _a2;e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.classList.remove("dragover"),readerImportFile((_a2=e.dataTransfer.files)==null?void 0:_a2[0]))})})}(),window.showReaderView=function(view){const main=$("reader-main-view"),cast=$("reader-audiobook-panel"),chars=$("reader-charsheets-panel"),lib=$("reader-library-card");main&&(main.hidden=view!=="main"),cast&&(cast.hidden=view!=="cast",view!=="cast"&&(cast.style.display="",cast.style.flexDirection="",cast.style.minHeight=""),view==="cast"&&!cast.innerHTML.trim()&&(cast.innerHTML=`

No active casting session

Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.


`,cast.className="ab-castpanel-inline card")),chars&&(chars.hidden=view!=="chars",view==="chars"&&!chars.innerHTML.trim()&&(chars.innerHTML=`
Character sheets

This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.

`,chars.className="")),lib&&(lib.hidden=view!=="library",view==="library"&&(lib.classList.remove("card"),lib.style.boxShadow="none",lib.style.border="none",lib.style.background="transparent",readerRenderLibrary())),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs(view==="cast"?"cast":view==="chars"?"chars":"source")},window.navReaderView=function(view){window.showReaderView(view)},window.readerJumpToPage=function(pageNum){const pg=parseInt(pageNum,10);if(!pg)return Promise.resolve(!1);typeof navTo=="function"&&navTo("s-reader"),window.showReaderView("main");let tries=0;const waitFrame=()=>new Promise(resolve=>requestAnimationFrame(resolve)),jump=async()=>{var _a2;await waitFrame();const pageState=(_a2=readerState.pages)==null?void 0:_a2[pg-1],pageDiv=pageState==null?void 0:pageState.pageDiv;return pageDiv&&!pageDiv.hidden?(await readerRenderPage(pg-1),pageDiv.scrollIntoView({behavior:"smooth",block:"start",inline:"nearest"}),readerRenderVisible(),!0):tries++<20?(await new Promise(resolve=>setTimeout(resolve,100)),jump()):(typeof toast=="function"&&toast("Source page is not loaded in Reader yet","info"),!1)};return jump()},window.readerOnShow=async function(){const sel=$("reader-backend-select");if(sel&&(!sel.value||sel.options.length<=1)){if(typeof availableTtsBackends=="function"&&!availableTtsBackends().length&&typeof refreshTtsBackendAvailability=="function")try{await refreshTtsBackendAvailability()}catch{}typeof availableTtsBackends=="function"&&availableTtsBackends().length&&(sel.innerHTML=ttsBackendOptions(sel.value),sel.disabled=!1)}readerUpdateBackendHint(),window.VoicePicker&&VoicePicker.upgrade("reader-voice-select"),readerRenderLibrary(),window._readerStartView&&(window.showReaderView(window._readerStartView),window._readerStartView=null)};const AUDIOBOOK_CHUNK_CHARS=3e3,AUDIOBOOK_WARMUP_TIMEOUT_MS=24e4,AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS=6e5,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS=3e5,AUDIOBOOK_RECAST_TIMEOUT_MS=24e4,AUDIOBOOK_RECAST_CONTEXT_CHARS=4200,AUDIOBOOK_RECAST_TARGETS_PER_CALL=6,AUDIOBOOK_DRAFT_AUTOSAVE_MS=5*60*1e3,_audiobook={running:!1,cancel:!1};window._audiobook=_audiobook;let _abDraftAutosaveTimer=null,_abLastServerDraftErrorAt=0;async function audiobookFetchWithTimeout(url,options={},timeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const parentSignal=options.signal,ac=new AbortController;let timedOut=!1;const timer=setTimeout(()=>{timedOut=!0,ac.abort()},timeoutMs),onParentAbort=()=>ac.abort(parentSignal==null?void 0:parentSignal.reason);parentSignal&&(parentSignal.aborted?onParentAbort():parentSignal.addEventListener("abort",onParentAbort,{once:!0}));try{return await fetch(url,{...options,signal:ac.signal})}catch(err){if(timedOut){const timeoutErr=new Error(`Timed out after ${Math.ceil(timeoutMs/1e3)}s`);throw timeoutErr.name="TimeoutError",timeoutErr}throw err}finally{clearTimeout(timer),parentSignal&&parentSignal.removeEventListener("abort",onParentAbort)}}function audiobookTimeoutSeconds(timeoutMs){return Math.max(5,Math.round(timeoutMs/1e3))}async function audiobookAttributeStream(body,view,outerSignal,idleTimeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const ctl=new AbortController,onAbort=()=>ctl.abort();outerSignal&&(outerSignal.aborted?ctl.abort():outerSignal.addEventListener("abort",onAbort,{once:!0}));let idleTimer=null;const armIdle=()=>{clearTimeout(idleTimer),idleTimer=setTimeout(()=>ctl.abort(),idleTimeoutMs)};try{armIdle();const r=await fetch("/api/attribute-dialogue/stream",{method:"POST",headers:{"Content-Type":"application/json"},signal:ctl.signal,body:JSON.stringify({...body,want_reasoning:!0})});if(!r.ok||!r.body)throw new Error("stream HTTP "+r.status);const reader=r.body.getReader(),dec=new TextDecoder;let buf="",result=null;for(;;){const{done,value}=await reader.read();if(done)break;armIdle(),buf+=dec.decode(value,{stream:!0});let at;for(;(at=buf.indexOf(` + `,confirmOverlay.addEventListener("click",ce=>ce.stopPropagation()),confirmOverlay.querySelector("#btn-cancel-del").addEventListener("click",ce=>{ce.stopPropagation(),confirmOverlay.remove()}),confirmOverlay.querySelector("#btn-confirm-del").addEventListener("click",async ce=>{ce.stopPropagation(),confirmOverlay.innerHTML='';try{if(!(await fetch(`${READER_API}/${el.dataset.id}`,{method:"DELETE"})).ok)throw new Error("Failed to delete book");toast("Book deleted","success"),readerRenderLibrary()}catch(err){toast(err.message,"error"),confirmOverlay.remove()}}),el.appendChild(confirmOverlay)}),el.addEventListener("dragover",e=>{e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.style.borderColor="var(--accent)")}),el.addEventListener("dragleave",()=>{el.style.borderColor=""}),el.addEventListener("drop",async e=>{var _a2;if(!(e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")))return;e.preventDefault(),e.stopPropagation(),el.style.borderColor="";const f=(_a2=e.dataTransfer.files)==null?void 0:_a2[0];if(!f||!f.type.startsWith("image/")){toast("Please drop an image file","error");return}try{const id=el.dataset.id,r=await fetch(`${READER_API}/${id}/cover`,{method:"PUT",body:f});if(!r.ok)throw new Error(r.statusText);await fetch(`${READER_API}/${id}/progress`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({updated:new Date().toISOString()})}),toast("Cover image updated","success"),readerRenderLibrary()}catch(err){toast("Failed to upload cover: "+(err.message||err),"error")}})}),list.querySelectorAll(".reader-book-del").forEach(btn=>btn.addEventListener("click",e=>{e.stopPropagation(),confirm("Delete this saved document and its audio?")&&readerDeleteLibraryDoc(btn.dataset.del)}))}(_Ib=$("reader-file-input"))==null||_Ib.addEventListener("change",function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];this.value="",readerImportFile(f)}),(_Jb=$("reader-paste-load"))==null||_Jb.addEventListener("click",readerImportPastedText),(_Kb=$("reader-paste-text"))==null||_Kb.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&e.key==="Enter"&&(e.preventDefault(),readerImportPastedText())}),(_Lb=$("reader-fetch-voices-btn"))==null||_Lb.addEventListener("click",readerFetchVoices),(_Mb=$("reader-backend-select"))==null||_Mb.addEventListener("change",()=>{const sel=$("reader-voice-select");sel&&(sel.innerHTML=''),window.VoicePicker&&(VoicePicker.upgrade("reader-voice-select"),VoicePicker.populate("reader-voice-select",[])),readerUpdateBackendHint()}),(_Nb=$("reader-chunk-mode"))==null||_Nb.addEventListener("change",function(){readerRechunk(this.value)}),(_Ob=$("reader-normalize"))==null||_Ob.addEventListener("change",function(){readerState.normalize=this.checked}),(_Pb=$("reader-play"))==null||_Pb.addEventListener("click",()=>{readerState.playing?readerPause():readerPlay()}),(_Qb=$("reader-stop"))==null||_Qb.addEventListener("click",()=>{readerStopPlayback(),readerState.idx=0,readerSaveResume(),readerUpdateProgress()}),(_Rb=$("reader-prev"))==null||_Rb.addEventListener("click",()=>readerJumpTo(readerState.idx-1)),(_Sb=$("reader-next"))==null||_Sb.addEventListener("click",()=>readerJumpTo(readerState.idx+1)),(_Tb=$("reader-speed"))==null||_Tb.addEventListener("input",function(){readerState.speed=parseFloat(this.value)||1;const lbl=$("reader-speed-label");lbl&&(lbl.textContent=readerState.speed.toFixed(2).replace(/0$/,"")+"\xD7"),readerState.currentSource&&(readerState.currentSource.playbackRate.value=readerState.speed)}),(_Ub=$("reader-zoom-fitw"))==null||_Ub.addEventListener("click",()=>readerApplyZoom("fit-width")),(_Vb=$("reader-zoom-fith"))==null||_Vb.addEventListener("click",()=>readerApplyZoom("fit-height")),(_Wb=$("reader-zoom-two"))==null||_Wb.addEventListener("click",()=>readerApplyZoom("two")),(_Xb=$("reader-zoom-in"))==null||_Xb.addEventListener("click",()=>{readerState.scale=Math.min(4,readerState.scale*1.2),readerApplyZoom("custom")}),(_Yb=$("reader-zoom-out"))==null||_Yb.addEventListener("click",()=>{readerState.scale=Math.max(.2,readerState.scale/1.2),readerApplyZoom("custom")});let _readerSearchTerm="",_readerSearchHits=[],_readerSearchCurrent=-1,_readerSearchTimer=null;const READER_SEARCH_MAX_HITS=2500;function readerSearchTextMap(words){var _a2;let text="";const spans=[];for(let i=0;isp.end>pos&&sp.startsp.wordIndex);idxs.length&&groups.push(idxs),pos=Math.max(pos+1,end)}return groups}function readerRemoveSearchHighlights(){document.querySelectorAll(".reader-search-hit").forEach(el=>el.remove()),document.querySelectorAll(".reader-word.search-hit, .reader-word.search-current").forEach(el=>{el.classList.remove("search-hit","search-current")})}function readerSetSearchStatus(text){const st=$("reader-search-status");st&&(st.textContent=text||"")}function readerClearSearch({clearInput=!1}={}){if(_readerSearchTerm="",_readerSearchHits=[],_readerSearchCurrent=-1,readerRemoveSearchHighlights(),readerSetSearchStatus(""),clearInput){const inp=$("reader-search");inp&&(inp.value="")}}function readerCollectSearchHits(term){var _a2,_b2,_c2;const hits=[],sents=readerState.sentences||[];for(let si=0;si=READER_SEARCH_MAX_HITS)return hits}}return hits}function readerPaintSearchHighlights(){if(readerRemoveSearchHighlights(),!_readerSearchTerm||!_readerSearchHits.length)return;const scale=readerState.scale||1;_readerSearchHits.forEach((hit,hi)=>{var _a2;const s=readerState.sentences[hit.sentenceIndex];if(s){hit.nodes=[];for(const wi of hit.wordIndexes){const w=(_a2=s.words)==null?void 0:_a2[wi];if(w)if(readerState.mode==="pdf"){const pg=readerState.pages[w.page];if(!(pg!=null&&pg.overlay))continue;const el=document.createElement("div");el.className="reader-search-hit"+(hi===_readerSearchCurrent?" search-current":""),Object.assign(el.style,{left:w.x*scale+"px",top:w.top*scale+"px",width:Math.max(w.w,2)*scale+"px",height:Math.max(w.h,2)*scale+"px"}),pg.overlay.appendChild(el),hit.nodes.push(el)}else w.el&&(w.el.classList.add("search-hit"),hi===_readerSearchCurrent&&w.el.classList.add("search-current"),hit.nodes.push(w.el))}}})}function readerUpdateSearchStatus(){var _a2,_b2;if(!_readerSearchTerm){readerSetSearchStatus("");return}if(!((_a2=readerState.sentences)!=null&&_a2.length)){readerSetSearchStatus("No text loaded");return}if(!_readerSearchHits.length){readerSetSearchStatus("0/0");return}const current=_readerSearchCurrent>=0?_readerSearchCurrent+1:1,hit=_readerSearchHits[Math.max(0,_readerSearchCurrent)],capped=_readerSearchHits.length>=READER_SEARCH_MAX_HITS?"+":"",page=readerState.mode==="pdf"?" \xB7 p."+(((_b2=hit==null?void 0:hit.page)!=null?_b2:0)+1):"";readerSetSearchStatus(current+"/"+_readerSearchHits.length+capped+page)}function readerJumpToSearchHit(idx){var _a2;if(!_readerSearchHits.length)return;_readerSearchCurrent=(idx+_readerSearchHits.length)%_readerSearchHits.length,readerPaintSearchHighlights(),readerUpdateSearchStatus();const hit=_readerSearchHits[_readerSearchCurrent],node=(_a2=hit==null?void 0:hit.nodes)==null?void 0:_a2[0];if(node)readerEnsureVisible(node);else if(readerState.mode==="pdf"){const pg=readerState.pages[hit.page];pg!=null&&pg.pageDiv&&(readerRenderPage(hit.page),pg.pageDiv.scrollIntoView({behavior:"smooth",block:"center"}))}}function readerSearchApply(term,{jump=!0}={}){if(term=String(term||"").trim(),!term){readerClearSearch();return}_readerSearchTerm=term,_readerSearchHits=readerCollectSearchHits(term),_readerSearchCurrent=_readerSearchHits.length?0:-1,readerPaintSearchHighlights(),readerUpdateSearchStatus(),jump&&_readerSearchHits.length&&readerJumpToSearchHit(0)}function readerSearchStep(direction){var _a2;const term=((_a2=$("reader-search"))==null?void 0:_a2.value.trim())||_readerSearchTerm;if(!_readerSearchHits.length||term!==_readerSearchTerm){readerSearchApply(term,{jump:!0});return}readerJumpToSearchHit(_readerSearchCurrent+(direction===-1?-1:1))}(_Zb=$("reader-search"))==null||_Zb.addEventListener("keydown",e=>{e.key==="Enter"&&(e.preventDefault(),readerSearchStep(e.shiftKey?-1:1)),e.key==="Escape"&&readerClearSearch({clearInput:!0})}),(__b=$("reader-search"))==null||__b.addEventListener("input",e=>{clearTimeout(_readerSearchTimer);const term=e.target.value.trim();if(!term){readerClearSearch();return}_readerSearchTimer=setTimeout(()=>readerSearchApply(term,{jump:!0}),160)}),(_$b=$("reader-search-prev"))==null||_$b.addEventListener("click",()=>readerSearchStep(-1)),(_ac=$("reader-search-next"))==null||_ac.addEventListener("click",()=>readerSearchStep(1)),(_bc=$("reader-search-clear"))==null||_bc.addEventListener("click",()=>readerClearSearch({clearInput:!0})),(_cc=$("reader-save-lib"))==null||_cc.addEventListener("click",readerSaveLibrary),(_dc=$("reader-export-btn"))==null||_dc.addEventListener("click",()=>{var _a2;return readerExport(((_a2=$("reader-export-mode"))==null?void 0:_a2.value)||"page")}),(_ec=$("reader-synth-all"))==null||_ec.addEventListener("click",readerSynthAll),(_fc=$("reader-synth-cancel"))==null||_fc.addEventListener("click",()=>{readerState.synthCancel=!0}),(_gc=$("reader-select-toggle"))==null||_gc.addEventListener("click",()=>{readerState.selecting?readerSetSelecting(!1):(readerClearSelection(),readerSetSelecting(!0))}),(_hc=$("reader-sel-clear"))==null||_hc.addEventListener("click",readerClearSelection),document.addEventListener("keydown",e=>{e.key==="Escape"&&readerState.selecting&&readerSetSelecting(!1)}),(_ic=$("reader-page-from"))==null||_ic.addEventListener("change",readerUpdateScopeLabel),(_jc=$("reader-page-to"))==null||_jc.addEventListener("change",readerUpdateScopeLabel),(_kc=$("reader-doc"))==null||_kc.addEventListener("click",e=>{if(!readerState.selecting||readerState.mode!=="pdf")return;const stat=e.target.closest(".reader-stat");if(!stat)return;const si=parseInt(stat.dataset.si);isNaN(si)||readerPickSentence(si)}),(_lc=$("reader-doc"))==null||_lc.addEventListener("scroll",function(){let t=null;return()=>{t||(t=setTimeout(()=>{t=null,readerRenderVisible()},120))}}()),window.addEventListener("resize",function(){let t=null;return()=>{readerState.mode!=="pdf"||readerState.zoomMode==="custom"||(clearTimeout(t),t=setTimeout(()=>readerApplyZoom(readerState.zoomMode),200))}}()),function(){["reader-dropzone","reader-doc"].map(id=>$(id)).filter(Boolean).forEach(el=>{el.addEventListener("dragover",e=>{e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.classList.add("dragover"))}),el.addEventListener("dragleave",()=>el.classList.remove("dragover")),el.addEventListener("drop",e=>{var _a2;e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.classList.remove("dragover"),readerImportFile((_a2=e.dataTransfer.files)==null?void 0:_a2[0]))})})}(),window.showReaderView=function(view){const main=$("reader-main-view"),cast=$("reader-audiobook-panel"),chars=$("reader-charsheets-panel"),lib=$("reader-library-card");main&&(main.hidden=view!=="main"),cast&&(cast.hidden=view!=="cast",view!=="cast"&&(cast.style.display="",cast.style.flexDirection="",cast.style.minHeight=""),view==="cast"&&!cast.innerHTML.trim()&&(cast.innerHTML=`

No active casting session

Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.


`,cast.className="ab-castpanel-inline card")),chars&&(chars.hidden=view!=="chars",view==="chars"&&!chars.innerHTML.trim()&&(chars.innerHTML=`
Character sheets

This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.

`,chars.className="")),lib&&(lib.hidden=view!=="library",view==="library"&&(lib.classList.remove("card"),lib.style.boxShadow="none",lib.style.border="none",lib.style.background="transparent",readerRenderLibrary())),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs(view==="cast"?"cast":view==="chars"?"chars":"source")},window.navReaderView=function(view){window.showReaderView(view)},window.readerJumpToPage=function(pageNum){const pg=parseInt(pageNum,10);if(!pg)return Promise.resolve(!1);typeof navTo=="function"&&navTo("s-reader"),window.showReaderView("main");let tries=0;const waitFrame=()=>new Promise(resolve=>requestAnimationFrame(resolve)),jump=async()=>{var _a2;await waitFrame();const pageState=(_a2=readerState.pages)==null?void 0:_a2[pg-1],pageDiv=pageState==null?void 0:pageState.pageDiv;return pageDiv&&!pageDiv.hidden?(await readerRenderPage(pg-1),pageDiv.scrollIntoView({behavior:"smooth",block:"start",inline:"nearest"}),readerRenderVisible(),!0):tries++<20?(await new Promise(resolve=>setTimeout(resolve,100)),jump()):(typeof toast=="function"&&toast("Source page is not loaded in Reader yet","info"),!1)};return jump()},window.readerOnShow=async function(){const sel=$("reader-backend-select");if(sel&&(!sel.value||sel.options.length<=1)){if(typeof availableTtsBackends=="function"&&!availableTtsBackends().length&&typeof refreshTtsBackendAvailability=="function")try{await refreshTtsBackendAvailability()}catch{}typeof availableTtsBackends=="function"&&availableTtsBackends().length&&(sel.innerHTML=ttsBackendOptions(sel.value),sel.disabled=!1)}readerUpdateBackendHint(),window.VoicePicker&&VoicePicker.upgrade("reader-voice-select"),readerRenderLibrary(),window._readerStartView&&(window.showReaderView(window._readerStartView),window._readerStartView=null),_readerEnsureImportVisible()};function _readerEnsureImportVisible(){if(readerState.sentences.length)return;const card=document.getElementById("reader-config-card"),body=card?card.querySelector(".card-col-body"):null;body&&body.hidden&&(body.hidden=!1,card.classList.remove("card-col-closed"))}const AUDIOBOOK_CHUNK_CHARS=3e3,AUDIOBOOK_WARMUP_TIMEOUT_MS=24e4,AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS=6e5,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS=3e5,AUDIOBOOK_RECAST_TIMEOUT_MS=24e4,AUDIOBOOK_RECAST_CONTEXT_CHARS=4200,AUDIOBOOK_RECAST_TARGETS_PER_CALL=6,AUDIOBOOK_DRAFT_AUTOSAVE_MS=5*60*1e3,_audiobook={running:!1,cancel:!1};window._audiobook=_audiobook;let _abDraftAutosaveTimer=null,_abLastServerDraftErrorAt=0;async function audiobookFetchWithTimeout(url,options={},timeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const parentSignal=options.signal,ac=new AbortController;let timedOut=!1;const timer=setTimeout(()=>{timedOut=!0,ac.abort()},timeoutMs),onParentAbort=()=>ac.abort(parentSignal==null?void 0:parentSignal.reason);parentSignal&&(parentSignal.aborted?onParentAbort():parentSignal.addEventListener("abort",onParentAbort,{once:!0}));try{return await fetch(url,{...options,signal:ac.signal})}catch(err){if(timedOut){const timeoutErr=new Error(`Timed out after ${Math.ceil(timeoutMs/1e3)}s`);throw timeoutErr.name="TimeoutError",timeoutErr}throw err}finally{clearTimeout(timer),parentSignal&&parentSignal.removeEventListener("abort",onParentAbort)}}function audiobookTimeoutSeconds(timeoutMs){return Math.max(5,Math.round(timeoutMs/1e3))}async function audiobookAttributeStream(body,view,outerSignal,idleTimeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const ctl=new AbortController,onAbort=()=>ctl.abort();outerSignal&&(outerSignal.aborted?ctl.abort():outerSignal.addEventListener("abort",onAbort,{once:!0}));let idleTimer=null;const armIdle=()=>{clearTimeout(idleTimer),idleTimer=setTimeout(()=>ctl.abort(),idleTimeoutMs)};try{armIdle();const r=await fetch("/api/attribute-dialogue/stream",{method:"POST",headers:{"Content-Type":"application/json"},signal:ctl.signal,body:JSON.stringify({...body,want_reasoning:!0})});if(!r.ok||!r.body)throw new Error("stream HTTP "+r.status);const reader=r.body.getReader(),dec=new TextDecoder;let buf="",result=null;for(;;){const{done,value}=await reader.read();if(done)break;armIdle(),buf+=dec.decode(value,{stream:!0});let at;for(;(at=buf.indexOf(` `))>=0;){const line=buf.slice(0,at).trim();if(buf=buf.slice(at+2),!line.startsWith("data:"))continue;let d;try{d=JSON.parse(line.slice(5))}catch{continue}if(d.t&&(view!=null&&view.thinking)&&view.thinking(d.t),d.error)throw new Error(d.error);d.done&&(result=d.result||null)}}if(!result)throw new Error("stream ended without result");return result}catch(err){throw(err==null?void 0:err.name)==="AbortError"&&!(outerSignal&&outerSignal.aborted)?new Error("stream idle timeout"):err}finally{clearTimeout(idleTimer),outerSignal&&outerSignal.removeEventListener("abort",onAbort)}}function audiobookDialogueKey(text){return String(text||"").normalize("NFKC").replace(/[»«„“”"‘’'`]+/g,"").replace(/\s+/g," ").trim().toLowerCase()}function audiobookSegmentText(seg){return seg?seg.type==="narration"||!seg.speaker||/^Unknown|Unbekannt/i.test(seg.speaker)?seg.text||"":`"${seg.text||""}"`:""}function _abWordRangeAtPoint(x,y){let node=null,offset=0;if(document.caretRangeFromPoint){const r=document.caretRangeFromPoint(x,y);if(!r)return null;node=r.startContainer,offset=r.startOffset}else if(document.caretPositionFromPoint){const p=document.caretPositionFromPoint(x,y);if(!p)return null;node=p.offsetNode,offset=p.offset}else return null;if(!node||node.nodeType!==3)return null;const text=node.nodeValue||"",isW=ch=>ch!=null&&/[\p{L}\p{N}'’-]/u.test(ch);if(offset>=text.length&&(offset=text.length-1),!isW(text[offset]))if(offset>0&&isW(text[offset-1]))offset--;else return null;let a=offset,b=offset;for(;a>0&&isW(text[a-1]);)a--;for(;b+1!s||s.type==="narration"||/^Unknown|Unbekannt/i.test(s.speaker||""));!cur.length||shortGap&&cur.lengthsegs.slice(start,end).map(audiobookSegmentText).join(" ").trim();let text=render();for(;text.length>AUDIOBOOK_RECAST_CONTEXT_CHARS&&(starttargetEnd)&&(end>targetEnd&&end--,text=render(),!(text.length<=AUDIOBOOK_RECAST_CONTEXT_CHARS));)start=end?"after":"near"}: ${s.speaker}: ${(s.text||"").slice(0,100)}`)}return lines.slice(-12).join(` `)}function audiobookFindReturnedSegment(targetSeg,returned,used){const targetKey=audiobookDialogueKey(targetSeg==null?void 0:targetSeg.text);if(!targetKey)return null;let loose=null;for(let i=0;i=18&&(candKey.includes(targetKey)||targetKey.includes(candKey))&&(loose=loose||{idx:i,seg:cand})}return loose}function audiobookFindReturnedSegmentSequence(targetSeg,returned,used){const targetKey=audiobookDialogueKey(targetSeg==null?void 0:targetSeg.text);if(!targetKey)return null;for(let i=0;ireturned[k])};if(key.length>targetKey.length*1.35+40)break}}return null}function _abStr(v){return v==null?"":typeof v=="string"?v:Array.isArray(v)?v.filter(Boolean).join(", "):String(v)}const _AB_DRAFT_KEY="ttsvc_ab_draft";function _abBookId(){return window.readerState&&readerState.savedId||_audiobook.bookId||null}function _abDraftKey(bookId){return bookId?_AB_DRAFT_KEY+"_"+bookId:_AB_DRAFT_KEY}function _abTextId(text){const s=(text.slice(0,300)+text.slice(-300)).replace(/\s+/g,"");let h=5381;for(let i=0;i>>0;return h.toString(36)+"_"+text.length}function _abSafeFilename(name,fallback="cast"){return(String(name||fallback||"cast").replace(/[\\/:*?"<>|]+/g,"_").replace(/\s+/g,"_").replace(/^_+|_+$/g,"")||fallback||"cast").slice(0,120)}function _abCastMarkdown(data){const payload=data||{},segments=Array.isArray(payload.segments)?payload.segments:[],speakers=[...new Set(segments.filter(s=>(s==null?void 0:s.type)==="dialogue"&&s.speaker).map(s=>String(s.speaker)))].sort(),lines=[`# ${payload.title||"Cast Script"}`,""],metaBits=[];payload.savedAt&&metaBits.push("exported "+new Date(payload.savedAt).toISOString().slice(0,16).replace("T"," ")),metaBits.push(`${speakers.length} character${speakers.length!==1?"s":""}`),metaBits.push(`${segments.length} segment${segments.length!==1?"s":""}`),lines.push(`*${metaBits.join(" \xB7 ")}*`,""),speakers.length&&lines.push(`**Characters:** ${speakers.join(", ")}`,"");let lastPage=null;for(const seg of segments){const text=String((seg==null?void 0:seg.text)||"").trim();if(text){if(seg.page!=null&&seg.page!==lastPage&&(lines.push("---","",`## Page ${seg.page}`,""),lastPage=seg.page),seg.type==="dialogue"){const speaker=String(seg.speaker||"Narrator"),tag=seg.emotion?` *(${seg.emotion})*`:"";lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`)}else lines.push(text);lines.push("")}}return lines.join(` -`)}function _abStampSegmentPages(segs,pageMarks,text){if(!Array.isArray(segs)||!segs.length||segs.some(s=>s&&s.page!=null))return;const marks=(pageMarks||[]).slice().sort((a,b)=>(a.offset||0)-(b.offset||0)),src=text||"";if(!marks.length||!src)return;let markIdx=0,searchPos=0,cur=null;marks[0].offset<=2&&(cur=marks[0].page+1,markIdx=1);for(const s of segs){const probe=String(s.text||"").trim().slice(0,24),at=probe?src.indexOf(probe,searchPos):-1,pos=at>=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)cur=marks[markIdx].page+1,markIdx++;cur!=null&&(s.page=cur)}}function _abSaveDraft(segs,roster,text,done,total){var _a2;const bookId=_abBookId(),payload={bookId,title:((_a2=window.readerState)==null?void 0:_a2.title)||"",textId:_abTextId(text),segments:segs,roster,pageMarks:_audiobook.pageMarks||[],rehId:_audiobook.rehId||null,done,total,savedAt:Date.now()};try{localStorage.setItem(_abDraftKey(bookId),JSON.stringify(payload))}catch{}bookId&&fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}).then(r=>{if(!r.ok)throw new Error(r.statusText||`HTTP ${r.status}`)}).catch(err=>{console.warn("[audiobook] server draft autosave failed:",err);const now=Date.now();now-_abLastServerDraftErrorAt>6e4&&(_abLastServerDraftErrorAt=now,typeof toast=="function"&&toast("Server autosave failed. Browser draft is still saved.","error"))})}let _abExportBusy=!1;async function audiobookExportCastMd(){var _a2;if(_abExportBusy)return;_abExportBusy=!0,setTimeout(()=>{_abExportBusy=!1},2e3);const segs=_audiobook.segments||[];if(!segs.length){toast("No cast to export","error");return}const bookId=_abBookId();_audiobook.lastText&&_abSaveDraft(segs,_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0);const title=((_a2=window.readerState)==null?void 0:_a2.title)||_audiobook.title||"audiobook";if(bookId)try{const r=await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast/export`);if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const blob2=await r.blob(),match=(r.headers.get("Content-Disposition")||"").match(/filename="([^"]+)"/i),name=(match==null?void 0:match[1])||`${_abSafeFilename(title)}_cast.zip`;if(typeof readerDownload=="function")readerDownload(blob2,name);else{const a=document.createElement("a");a.href=URL.createObjectURL(blob2),a.download=name,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),500)}toast("Exported cast + character sheets (.zip)","success");return}catch{toast("Server export failed, downloading local cast copy","error")}const payload={bookId,title,textId:_audiobook.lastText?_abTextId(_audiobook.lastText):"",segments:segs,roster:_audiobook.roster||[],pageMarks:_audiobook.pageMarks||[],rehId:_audiobook.rehId||null,done:_audiobook.completedChunks||0,total:_audiobook.completedTotal||0,savedAt:Date.now()},blob=new Blob([_abCastMarkdown(payload)],{type:"text/markdown;charset=utf-8"});if(typeof readerDownload=="function")readerDownload(blob,`${_abSafeFilename(title)}_cast.md`);else{const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=`${_abSafeFilename(title)}_cast.md`,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),500)}}function _abStartDraftAutosave(saveNow){if(_abStopDraftAutosave(),typeof saveNow!="function")return;_abDraftAutosaveTimer=setInterval(saveNow,AUDIOBOOK_DRAFT_AUTOSAVE_MS);const saveOnLeave=()=>saveNow();_audiobook._draftSaveOnLeave=saveOnLeave,window.addEventListener("pagehide",saveOnLeave),window.addEventListener("beforeunload",saveOnLeave)}function _abStopDraftAutosave(){_abDraftAutosaveTimer&&clearInterval(_abDraftAutosaveTimer),_abDraftAutosaveTimer=null,_audiobook._draftSaveOnLeave&&(window.removeEventListener("pagehide",_audiobook._draftSaveOnLeave),window.removeEventListener("beforeunload",_audiobook._draftSaveOnLeave),_audiobook._draftSaveOnLeave=null)}async function _abEnsureLibraryBook(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2;const rs=window.readerState;if(!rs||rs.savedId||!((_a2=rs.sentences)!=null&&_a2.length))return!!(rs!=null&&rs.savedId);if(rs.mode==="pdf"&&!rs.fileBlob)return!1;const meta={title:rs.title||"Untitled",kind:rs.mode,idx:rs.idx,voice:((_b2=$("reader-voice-select"))==null?void 0:_b2.value)||"",backend:((_c2=$("reader-backend-select"))==null?void 0:_c2.value)||"",speed:rs.speed,instruct:((_d2=$("reader-instruct"))==null?void 0:_d2.value.trim())||"",chunkMode:rs.chunkMode,seed:((_e2=$("reader-seed"))==null?void 0:_e2.value.trim())||"",temperature:((_f2=$("reader-temp"))==null?void 0:_f2.value.trim())||"",tts_speed:parseFloat((_g2=$("reader-tts-speed"))==null?void 0:_g2.value)||1,normalize:rs.normalize,sentenceCount:rs.sentences.length,pageCount:((_h2=rs.pages)==null?void 0:_h2.length)||0,synthCount:0,updated:new Date().toISOString()};try{const r=await fetch("/api/reader/docs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(meta)});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);rs.savedId=(await r.json()).id,_audiobook.bookId=rs.savedId;const ext=rs.mode==="pdf"?"pdf":"txt",body=rs.mode==="pdf"?rs.fileBlob:new Blob([rs.docText||audiobookScopeText()||""],{type:"text/plain"});return(await fetch(`/api/reader/docs/${encodeURIComponent(rs.savedId)}/source?ext=${ext}`,{method:"PUT",body})).ok&&(rs.sourceUploaded=!0),typeof readerRenderLibrary=="function"&&readerRenderLibrary(),!0}catch(err){return console.warn("[audiobook] could not create reader-library autosave target:",err),!1}}async function _abLoadDraftServer(bookId){if(!bookId)return null;for(let attempt=0;attempt<2;attempt++)try{const r=await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`);if(!r.ok){if(attempt===0&&(r.status===404||r.status>=500)){await new Promise(resolve=>setTimeout(resolve,650));continue}return null}const d=await r.json();return d&&Array.isArray(d.segments)&&d.segments.length?d:null}catch{attempt===0&&await new Promise(resolve=>setTimeout(resolve,650))}return null}function _abLoadDraft(text){var _a2;const bookId=_abBookId(),textId=_abTextId(text),title=String(((_a2=window.readerState)==null?void 0:_a2.title)||"").trim().toLowerCase();try{if(bookId){const raw2=localStorage.getItem(_abDraftKey(bookId));if(raw2){const d=JSON.parse(raw2);if(d&&Array.isArray(d.segments)&&d.segments.length)return d}}const raw=localStorage.getItem(_AB_DRAFT_KEY);if(raw){const d=JSON.parse(raw);if(d&&Array.isArray(d.segments)&&d.segments.length&&d.textId===textId)return d}let best=null;for(let i=0;ibest.score)&&(best={score,draft:cand})}catch{}}if(best!=null&&best.draft)return best.draft}catch{return null}return null}function _abClearDraft(){const bookId=_abBookId();try{localStorage.removeItem(_abDraftKey(bookId))}catch{}bookId&&fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`,{method:"DELETE"}).catch(()=>{})}const AB_DIALOGUE_RE=/[«»„“”"‟‚‘’›‹『「<]|(?:^|\n)\s*[—–]\s/;function audiobookHasDialogue(t){return AB_DIALOGUE_RE.test(t||"")}function audiobookDehyphenate(t){return(t||"").replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g,"$1")}const AB_SPEECH_VERBS="(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|fl\xFCsterte|wisperte|raunte|murmelte|brummte|knurrte|br\xFCllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)",AB_NOTNAME=new Set(["Der","Die","Das","Den","Dem","Ein","Eine","Einen","Er","Sie","Es","Ich","Du","Wir","Ihr","Man","Und","Aber","Da","Dann","Doch","So","Nun","Jetzt","The","He","She","It","They","A","An","And","But","Then","Now","Sofort","Pl\xF6tzlich","Endlich","Schlie\xDFlich","Stille","Schweigen","Stimme","Stimmen","Frage","Antwort","Gel\xE4chter","Wieder","Gleich","Sogleich","Langsam","Leise","Laut","Kaum","Vielleicht","Nat\xFCrlich","Wirklich","Ja","Nein","Komm","Warte","Halt","Geh","Hier","Dort","Oben","Unten","Schon","Noch","Auch","Nur","Immer","Nie"]),_AB_NAME="([A-Z\xC4\xD6\xDC][A-Za-z\xE4\xF6\xFC\xDF'\\-]+)",AB_PERSON_NOUNS=new Set(["Mann","Frau","Junge","M\xE4dchen","Alte","Alter","Fremde","Fremder","Krieger","Kriegerin","W\xE4chter","Wache","Soldat","Hauptmann","Ork","Ritter","Magier","Magierin","Zwerg","Elf","Elfe","H\xE4ndler","Wirt","Wirtin","Bauer","Priester","Priesterin","Nachbar","Nachbarin","Sklave","Sklavin","Verweser","Inquisitor","General","K\xF6nig","K\xF6nigin","Prinz","Prinzessin","F\xFCrst","F\xFCrstin","Baron","Baronin","Bote","Diener","Dienerin","Knabe","Kind","Reiter","Reiterin","Bogensch\xFCtze","Schmied","Schmiedin","Heiler","Heilerin","Gelehrte","Gelehrter","Kapit\xE4n","Anf\xFChrer","Anf\xFChrerin"]);function audiobookResolveUnknowns(segs,prevTail,roster){var _a2,_b2;const isUnknown=s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker)),isNamed=s=>(s==null?void 0:s.type)==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt|Narrator$/i.test(s.speaker),names=(roster||[]).filter(n=>n&&!/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a,b)=>b.length-a.length),lastNameIn=text=>{let best=null,bestAt=-1;for(const n of names){const at=text.lastIndexOf(n);at>bestAt&&(bestAt=at,best=n)}if(best)return best;const clause=text.match(new RegExp(_AB_NAME+"[^.!?:]{0,80}\\b(?:und\\s+)?(?:"+AB_SPEECH_VERBS+")[^:]{0,60}:\\s*$"));if(clause&&!AB_NOTNAME.has(clause[1])&&!AB_PERSON_NOUNS.has(clause[1]))return clause[1];const m=[...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)].map(x=>x[1]).filter(n=>AB_PERSON_NOUNS.has(n));return m.length?m[m.length-1]:null},all=[...prevTail||[],...segs],offset=(prevTail||[]).length,resolved=[];for(let i=offset;i=0&&j>=i-6&&prevDialogues.length<2&&!(((_a2=all[j])==null?void 0:_a2.page)!=null&&s.page!=null&&all[j].page!==s.page);j--)if(((_b2=all[j])==null?void 0:_b2.type)==="dialogue"){if(!isNamed(all[j])){prevDialogues.length=0;break}prevDialogues.push(all[j].speaker)}prevDialogues.length===2&&prevDialogues[0]!==prevDialogues[1]&&(who=prevDialogues[1])}who&&!/^(Narrator|Unknown|Unbekannt)$/i.test(who)&&(s.speaker=who,resolved.push(who))}return resolved}const _AB_TAG_NAME="[A-Z\xC4\xD6\xDC][A-Za-z\xE4\xF6\xFC\xDF'\\-]+",AB_SPEECH_TAG_ONLY_RE=new RegExp("^\\s*[,.;:!?\u2013-]*\\s*(?:(?:"+AB_SPEECH_VERBS+")\\b\\s+(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|"+_AB_TAG_NAME+")|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|"+_AB_TAG_NAME+")\\s+(?:"+AB_SPEECH_VERBS+")\\b)(?:\\s+(?:mit|in|leise|laut|kalt|heiser|erstickt|ruhig|zornig|w\xFCtend|\xE4ngstlich|sp\xF6ttisch|ver\xE4chtlich|fragend|fl\xFCsternd|schrill|dumpf|slowly|coldly|quietly|softly|angrily|hoarsely)\\b[\\s\\S]*)?[.!?\u2026]*\\s*$","i");function audiobookIsSpeechTagOnly(text){const t=String(text||"").trim();return!t||t.length>180||/[»«„“”"‟‚‘’›‹『「]/.test(t)?!1:AB_SPEECH_TAG_ONLY_RE.test(t)}function audiobookGuessSpeaker(after,before){let m;const ok=n=>n&&!AB_NOTNAME.has(n)?n:null;if(m=new RegExp("^[\\s,;\u2013-]*"+AB_SPEECH_VERBS+"\\s+(?:der|die|das|ein|eine)?\\s*"+_AB_NAME).exec(after||"")){const r=ok(m[1]);if(r)return r}if(m=new RegExp("^[\\s,;\u2013-]*"+_AB_NAME+"\\s+"+AB_SPEECH_VERBS).exec(after||"")){const r=ok(m[1]);if(r)return r}if(m=new RegExp(_AB_NAME+"\\s+"+AB_SPEECH_VERBS+"[\\s:,\u2013-]*$").exec(before||"")){const r=ok(m[1]);if(r)return r}return null}const AB_QUOTE_PAIRS={"\xBB":"\xAB","\xAB":"\xBB","\u201E":"\u201C","\u201C":"\u201D",'"':'"',"\u300C":"\u300D","\u300E":"\u300F","\u2018":"\u2019","\u201A":"\u2018","\u203A":"\u2039","\u2039":"\u203A"},_AB_UNCLOSED_TAG_RE=new RegExp("^\\s*(?:[,;:\u2013-]\\s*)?(?:"+AB_SPEECH_VERBS+"|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+"+AB_SPEECH_VERBS+")\\b","i");function _abUnclosedQuoteEnd(raw,closeIdx){const limit=closeIdx>=0?closeIdx:raw.length;for(let i=0;i40)return m[1].length}return closeIdx>=0?closeIdx:raw.length}function _abOrphanClosingQuoteSpan(text,closeIdx,minStart){var _a2;if(!"\xAB\u201D\u2019\u2039\u300D\u300F".includes(text[closeIdx]))return null;const before=text.slice(minStart,closeIdx),prev=((_a2=before.match(/\S(?=\s*$)/))==null?void 0:_a2[0])||"";if(!/[.!?]/.test(prev))return null;const trimmedLen=before.trimEnd().length;let localStart=0;const boundaryRe=/[\n\r]|[.!?:]\s+/g;let m;for(;m=boundaryRe.exec(before);){const next=m.index+m[0].length;next0&&!/[\s([{—–-]/.test(text[i-1]))continue;const raw=text.slice(i+1),closeIdx=raw.indexOf(close),endInRaw=_abUnclosedQuoteEnd(raw,closeIdx),quote=raw.slice(0,endInRaw).trim();if(!quote)continue;const consumedClose=closeIdx>=0&&endInRaw===closeIdx;spans.push({start:i,end:i+1+endInRaw+(consumedClose?1:0),quote}),i=spans[spans.length-1].end-1}if(!spans.length)return[{speaker:"Narrator",type:"narration",text,emotion:""}];const out=[];let last=0;for(let k=0;k{});const rehModel=$("reh-llm-model");return rehModel&&cleanModel&&[...rehModel.options].some(o=>o.value===cleanModel)&&(rehModel.value=cleanModel),{url:cleanUrl,model:cleanModel}}function audiobookScopeText(){var _a2,_b2,_c2;if(_audiobook.pageMarks=[],typeof readerScopeIndices!="function"||!((_a2=readerState==null?void 0:readerState.sentences)!=null&&_a2.length))return"";const idxs=readerScopeIndices(),anchors=[];let lastPage=null;for(const i of idxs){const u=readerState.sentences[i],pg=(_c2=(_b2=u==null?void 0:u.words)==null?void 0:_b2[0])==null?void 0:_c2.page;pg!=null&&pg!==lastPage&&(anchors.push({page:pg,anchor:(u.text||"").trim().slice(0,40)}),lastPage=pg)}let raw="";for(const i of idxs){const u=readerState.sentences[i];u!=null&&u.text&&(raw+=raw?(u.paraStart?` +`)}function _abStampSegmentPages(segs,pageMarks,text){if(!Array.isArray(segs)||!segs.length||segs.some(s=>s&&s.page!=null))return;const marks=(pageMarks||[]).slice().sort((a,b)=>(a.offset||0)-(b.offset||0)),src=text||"";if(!marks.length||!src)return;let markIdx=0,searchPos=0,cur=null;marks[0].offset<=2&&(cur=marks[0].page+1,markIdx=1);for(const s of segs){const probe=String(s.text||"").trim().slice(0,24),at=probe?src.indexOf(probe,searchPos):-1,pos=at>=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)cur=marks[markIdx].page+1,markIdx++;cur!=null&&(s.page=cur)}}let _abLastDraftSaveAt=0;const AB_DRAFT_SAVE_THROTTLE_MS=4e3;function _abSaveDraft(segs,roster,text,done,total){var _a2;const isFinal=total>0&&done>=total,now=Date.now();if(!isFinal&&now-_abLastDraftSaveAt{if(!r.ok)throw new Error(r.statusText||`HTTP ${r.status}`)}).catch(err=>{console.warn("[audiobook] server draft autosave failed:",err);const now2=Date.now();now2-_abLastServerDraftErrorAt>6e4&&(_abLastServerDraftErrorAt=now2,typeof toast=="function"&&toast("Server autosave failed. Browser draft is still saved.","error"))})}let _abExportBusy=!1;async function audiobookExportCastMd(){var _a2;if(_abExportBusy)return;_abExportBusy=!0,setTimeout(()=>{_abExportBusy=!1},2e3);const segs=_audiobook.segments||[];if(!segs.length){toast("No cast to export","error");return}const bookId=_abBookId();_audiobook.lastText&&_abSaveDraft(segs,_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0);const title=((_a2=window.readerState)==null?void 0:_a2.title)||_audiobook.title||"audiobook";if(bookId)try{const r=await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast/export`);if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const blob2=await r.blob(),match=(r.headers.get("Content-Disposition")||"").match(/filename="([^"]+)"/i),name=(match==null?void 0:match[1])||`${_abSafeFilename(title)}_cast.zip`;if(typeof readerDownload=="function")readerDownload(blob2,name);else{const a=document.createElement("a");a.href=URL.createObjectURL(blob2),a.download=name,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),500)}toast("Exported cast + character sheets (.zip)","success");return}catch{toast("Server export failed, downloading local cast copy","error")}const payload={bookId,title,textId:_audiobook.lastText?_abTextId(_audiobook.lastText):"",segments:segs,roster:_audiobook.roster||[],pageMarks:_audiobook.pageMarks||[],rehId:_audiobook.rehId||null,done:_audiobook.completedChunks||0,total:_audiobook.completedTotal||0,savedAt:Date.now()},blob=new Blob([_abCastMarkdown(payload)],{type:"text/markdown;charset=utf-8"});if(typeof readerDownload=="function")readerDownload(blob,`${_abSafeFilename(title)}_cast.md`);else{const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=`${_abSafeFilename(title)}_cast.md`,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),500)}}function _abStartDraftAutosave(saveNow){if(_abStopDraftAutosave(),typeof saveNow!="function")return;_abDraftAutosaveTimer=setInterval(saveNow,AUDIOBOOK_DRAFT_AUTOSAVE_MS);const saveOnLeave=()=>saveNow();_audiobook._draftSaveOnLeave=saveOnLeave,window.addEventListener("pagehide",saveOnLeave),window.addEventListener("beforeunload",saveOnLeave)}function _abStopDraftAutosave(){_abDraftAutosaveTimer&&clearInterval(_abDraftAutosaveTimer),_abDraftAutosaveTimer=null,_audiobook._draftSaveOnLeave&&(window.removeEventListener("pagehide",_audiobook._draftSaveOnLeave),window.removeEventListener("beforeunload",_audiobook._draftSaveOnLeave),_audiobook._draftSaveOnLeave=null)}async function _abEnsureLibraryBook(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2;const rs=window.readerState;if(!rs||rs.savedId||!((_a2=rs.sentences)!=null&&_a2.length))return!!(rs!=null&&rs.savedId);if(rs.mode==="pdf"&&!rs.fileBlob)return!1;const meta={title:rs.title||"Untitled",kind:rs.mode,idx:rs.idx,voice:((_b2=$("reader-voice-select"))==null?void 0:_b2.value)||"",backend:((_c2=$("reader-backend-select"))==null?void 0:_c2.value)||"",speed:rs.speed,instruct:((_d2=$("reader-instruct"))==null?void 0:_d2.value.trim())||"",chunkMode:rs.chunkMode,seed:((_e2=$("reader-seed"))==null?void 0:_e2.value.trim())||"",temperature:((_f2=$("reader-temp"))==null?void 0:_f2.value.trim())||"",tts_speed:parseFloat((_g2=$("reader-tts-speed"))==null?void 0:_g2.value)||1,normalize:rs.normalize,sentenceCount:rs.sentences.length,pageCount:((_h2=rs.pages)==null?void 0:_h2.length)||0,synthCount:0,updated:new Date().toISOString()};try{const r=await fetch("/api/reader/docs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(meta)});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);rs.savedId=(await r.json()).id,_audiobook.bookId=rs.savedId;const ext=rs.mode==="pdf"?"pdf":"txt",body=rs.mode==="pdf"?rs.fileBlob:new Blob([rs.docText||audiobookScopeText()||""],{type:"text/plain"});return(await fetch(`/api/reader/docs/${encodeURIComponent(rs.savedId)}/source?ext=${ext}`,{method:"PUT",body})).ok&&(rs.sourceUploaded=!0),typeof readerRenderLibrary=="function"&&readerRenderLibrary(),!0}catch(err){return console.warn("[audiobook] could not create reader-library autosave target:",err),!1}}async function _abLoadDraftServer(bookId){if(!bookId)return null;for(let attempt=0;attempt<2;attempt++)try{const r=await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`);if(!r.ok){if(attempt===0&&(r.status===404||r.status>=500)){await new Promise(resolve=>setTimeout(resolve,650));continue}return null}const d=await r.json();return d&&Array.isArray(d.segments)&&d.segments.length?d:null}catch{attempt===0&&await new Promise(resolve=>setTimeout(resolve,650))}return null}function _abLoadDraft(text){var _a2;const bookId=_abBookId(),textId=_abTextId(text),title=String(((_a2=window.readerState)==null?void 0:_a2.title)||"").trim().toLowerCase();try{if(bookId){const raw2=localStorage.getItem(_abDraftKey(bookId));if(raw2){const d=JSON.parse(raw2);if(d&&Array.isArray(d.segments)&&d.segments.length)return d}}const raw=localStorage.getItem(_AB_DRAFT_KEY);if(raw){const d=JSON.parse(raw);if(d&&Array.isArray(d.segments)&&d.segments.length&&d.textId===textId)return d}let best=null;for(let i=0;ibest.score)&&(best={score,draft:cand})}catch{}}if(best!=null&&best.draft)return best.draft}catch{return null}return null}function _abClearDraft(){const bookId=_abBookId();try{localStorage.removeItem(_abDraftKey(bookId))}catch{}bookId&&fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`,{method:"DELETE"}).catch(()=>{})}const AB_DIALOGUE_RE=/[«»„“”"‟‚‘’『「<]|(?:^|\n)\s*[—–]\s/;function audiobookHasDialogue(t){return AB_DIALOGUE_RE.test(t||"")}function audiobookDehyphenate(t){return(t||"").replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g,"$1")}const AB_SPEECH_VERBS="(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|fl\xFCsterte|wisperte|raunte|murmelte|brummte|knurrte|br\xFCllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)",AB_NOTNAME=new Set(["Der","Die","Das","Den","Dem","Ein","Eine","Einen","Er","Sie","Es","Ich","Du","Wir","Ihr","Man","Und","Aber","Da","Dann","Doch","So","Nun","Jetzt","The","He","She","It","They","A","An","And","But","Then","Now","Sofort","Pl\xF6tzlich","Endlich","Schlie\xDFlich","Stille","Schweigen","Stimme","Stimmen","Frage","Antwort","Gel\xE4chter","Wieder","Gleich","Sogleich","Langsam","Leise","Laut","Kaum","Vielleicht","Nat\xFCrlich","Wirklich","Ja","Nein","Komm","Warte","Halt","Geh","Hier","Dort","Oben","Unten","Schon","Noch","Auch","Nur","Immer","Nie"]),_AB_NAME="([A-Z\xC4\xD6\xDC][A-Za-z\xE4\xF6\xFC\xDF'\\-]+)",AB_PERSON_NOUNS=new Set(["Mann","Frau","Junge","M\xE4dchen","Alte","Alter","Fremde","Fremder","Krieger","Kriegerin","W\xE4chter","Wache","Soldat","Hauptmann","Ork","Ritter","Magier","Magierin","Zwerg","Elf","Elfe","H\xE4ndler","Wirt","Wirtin","Bauer","Priester","Priesterin","Nachbar","Nachbarin","Sklave","Sklavin","Verweser","Inquisitor","General","K\xF6nig","K\xF6nigin","Prinz","Prinzessin","F\xFCrst","F\xFCrstin","Baron","Baronin","Bote","Diener","Dienerin","Knabe","Kind","Reiter","Reiterin","Bogensch\xFCtze","Schmied","Schmiedin","Heiler","Heilerin","Gelehrte","Gelehrter","Kapit\xE4n","Anf\xFChrer","Anf\xFChrerin"]);function audiobookResolveUnknowns(segs,prevTail,roster){var _a2,_b2;const isUnknown=s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker)),isNamed=s=>(s==null?void 0:s.type)==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt|Narrator$/i.test(s.speaker),names=(roster||[]).filter(n=>n&&!/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a,b)=>b.length-a.length),lastNameIn=text=>{let best=null,bestAt=-1;for(const n of names){const at=text.lastIndexOf(n);at>bestAt&&(bestAt=at,best=n)}if(best)return best;const clause=text.match(new RegExp(_AB_NAME+"[^.!?:]{0,80}\\b(?:und\\s+)?(?:"+AB_SPEECH_VERBS+")[^:]{0,60}:\\s*$"));if(clause&&!AB_NOTNAME.has(clause[1])&&!AB_PERSON_NOUNS.has(clause[1]))return clause[1];const m=[...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)].map(x=>x[1]).filter(n=>AB_PERSON_NOUNS.has(n));return m.length?m[m.length-1]:null},all=[...prevTail||[],...segs],offset=(prevTail||[]).length,resolved=[];for(let i=offset;i=0&&j>=i-6&&prevDialogues.length<2&&!(((_a2=all[j])==null?void 0:_a2.page)!=null&&s.page!=null&&all[j].page!==s.page);j--)if(((_b2=all[j])==null?void 0:_b2.type)==="dialogue"){if(!isNamed(all[j])){prevDialogues.length=0;break}prevDialogues.push(all[j].speaker)}prevDialogues.length===2&&prevDialogues[0]!==prevDialogues[1]&&(who=prevDialogues[1])}who&&!/^(Narrator|Unknown|Unbekannt)$/i.test(who)&&(s.speaker=who,resolved.push(who))}return resolved}const _AB_TAG_NAME="[A-Z\xC4\xD6\xDC][A-Za-z\xE4\xF6\xFC\xDF'\\-]+",AB_SPEECH_TAG_ONLY_RE=new RegExp("^\\s*[,.;:!?\u2013-]*\\s*(?:(?:"+AB_SPEECH_VERBS+")\\b\\s+(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|"+_AB_TAG_NAME+")|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|"+_AB_TAG_NAME+")\\s+(?:"+AB_SPEECH_VERBS+")\\b)(?:\\s+(?:mit|in|leise|laut|kalt|heiser|erstickt|ruhig|zornig|w\xFCtend|\xE4ngstlich|sp\xF6ttisch|ver\xE4chtlich|fragend|fl\xFCsternd|schrill|dumpf|slowly|coldly|quietly|softly|angrily|hoarsely)\\b[\\s\\S]*)?[.!?\u2026]*\\s*$","i");function audiobookIsSpeechTagOnly(text){const t=String(text||"").trim();return!t||t.length>180||/[»«„“”"‟‚‘’『「]/.test(t)?!1:AB_SPEECH_TAG_ONLY_RE.test(t)}function audiobookGuessSpeaker(after,before){let m;const ok=n=>n&&!AB_NOTNAME.has(n)?n:null;if(m=new RegExp("^[\\s,;\u2013-]*"+AB_SPEECH_VERBS+"\\s+(?:der|die|das|ein|eine)?\\s*"+_AB_NAME).exec(after||"")){const r=ok(m[1]);if(r)return r}if(m=new RegExp("^[\\s,;\u2013-]*"+_AB_NAME+"\\s+"+AB_SPEECH_VERBS).exec(after||"")){const r=ok(m[1]);if(r)return r}if(m=new RegExp(_AB_NAME+"\\s+"+AB_SPEECH_VERBS+"[\\s:,\u2013-]*$").exec(before||"")){const r=ok(m[1]);if(r)return r}return null}const AB_QUOTE_PAIRS={"\xBB":"\xAB","\xAB":"\xBB","\u201E":"\u201C","\u201C":"\u201D",'"':'"',"\u300C":"\u300D","\u300E":"\u300F","\u2018":"\u2019","\u201A":"\u2018"},_AB_UNCLOSED_TAG_RE=new RegExp("^\\s*(?:[,;:\u2013-]\\s*)?(?:"+AB_SPEECH_VERBS+"|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+"+AB_SPEECH_VERBS+")\\b","i");function _abUnclosedQuoteEnd(raw,closeIdx){const limit=closeIdx>=0?closeIdx:raw.length;for(let i=0;i40)return m[1].length}return closeIdx>=0?closeIdx:raw.length}function _abOrphanClosingQuoteSpan(text,closeIdx,minStart){var _a2;if(!"\xAB\u201D\u2019\u2039\u300D\u300F".includes(text[closeIdx]))return null;const before=text.slice(minStart,closeIdx),prev=((_a2=before.match(/\S(?=\s*$)/))==null?void 0:_a2[0])||"";if(!/[.!?]/.test(prev))return null;const trimmedLen=before.trimEnd().length;let localStart=0;const boundaryRe=/[\n\r]|[.!?:]\s+/g;let m;for(;m=boundaryRe.exec(before);){const next=m.index+m[0].length;nextch&&(AB_QUOTE_OPENERS+AB_QUOTE_CLOSERS).includes(ch);for(let i=0;is.text.trim())}function _audiobookMergeAdjacentSameSpeaker(segments){const out=[];for(const s of segments){const last=out[out.length-1];if(last&&last.type===s.type&&last.page===s.page){const lastSpeaker=(last.speaker||"Narrator").trim().toLowerCase(),curSpeaker=(s.speaker||"Narrator").trim().toLowerCase(),sameEmotion=s.type!=="dialogue"||(last.emotion||"").toLowerCase()===(s.emotion||"").toLowerCase();if(lastSpeaker===curSpeaker&&sameEmotion){const lastText=last.text.trimEnd(),curText=s.text.trimStart();last.text=/[.!?…»«”"’']$/.test(lastText)?lastText+` + +`+curText:lastText+" "+curText;continue}}out.push(Object.assign({},s))}return out}function _audiobookDedupNearbyDuplicates(segments){const QUOTE_CHARS='\xBB\xAB\u201E"\u2018\u2019\u203A\u2039',stripRe=new RegExp(`(^[${QUOTE_CHARS}\\s]+)|([${QUOTE_CHARS}\\s]+$)`,"g"),norm=t=>String(t||"").replace(stripRe,"").toLowerCase().replace(/\s+/g," ").trim(),WINDOW=30,MIN_LEN=20,out=[];let removed=0;for(const s of segments){const nt=norm(s==null?void 0:s.text);if(nt.length>=MIN_LEN){let isDup=!1;for(let k=out.length-1;k>=0&&out.length-k<=WINDOW;k--)if(out[k].type===s.type&&norm(out[k].text)===nt){isDup=!0;break}if(isDup){removed++;continue}}out.push(s)}return{segments:out,removed}}function audiobookSplitByQuotes(text){const spans=[];for(let i=0;i0&&!/[\s([{—–-]/.test(text[i-1]))continue;const raw=text.slice(i+1),closeIdx=raw.indexOf(close),endInRaw=_abUnclosedQuoteEnd(raw,closeIdx),quote=raw.slice(0,endInRaw).trim();if(!quote)continue;const consumedClose=closeIdx>=0&&endInRaw===closeIdx;spans.push({start:i,end:i+1+endInRaw+(consumedClose?1:0),quote}),i=spans[spans.length-1].end-1}if(!spans.length)return[{speaker:"Narrator",type:"narration",text,emotion:""}];const out=[];let last=0;for(let k=0;k{});const rehModel=$("reh-llm-model");return rehModel&&cleanModel&&[...rehModel.options].some(o=>o.value===cleanModel)&&(rehModel.value=cleanModel),{url:cleanUrl,model:cleanModel}}function audiobookScopeText(){var _a2,_b2,_c2;if(_audiobook.pageMarks=[],typeof readerScopeIndices!="function"||!((_a2=readerState==null?void 0:readerState.sentences)!=null&&_a2.length))return"";const idxs=readerScopeIndices(),anchors=[];let lastPage=null;for(const i of idxs){const u=readerState.sentences[i],pg=(_c2=(_b2=u==null?void 0:u.words)==null?void 0:_b2[0])==null?void 0:_c2.page;pg!=null&&pg!==lastPage&&(anchors.push({page:pg,anchor:(u.text||"").trim().slice(0,40)}),lastPage=pg)}let raw="";for(const i of idxs){const u=readerState.sentences[i];u!=null&&u.text&&(raw+=raw?(u.paraStart?` `:" ")+u.text:u.text)}raw=raw.replace(/[ \t]+/g," ").replace(/\n{3,}/g,` @@ -1142,7 +1151,12 @@ ${lines.trim()}
${escHtml(message)}
${withProgress?'
':""} -
`,document.body.appendChild(ov),ov.setProgress=(done,total)=>{const fill=ov.querySelector(".ab-busy-fill");fill&&total&&(fill.style.width=Math.round(done/total*100)+"%");const msgEl=ov.querySelector(".ab-busy-msg");msgEl&&(msgEl.textContent=`${message} (${done} / ${total})`)},ov}function _abOpenRecastCharsMenu(anchorEl,bookTitle,existingChars){document.querySelectorAll(".ab-recast-menu").forEach(el=>el.remove());const menu=document.createElement("div");menu.className="ab-recast-menu",menu.innerHTML='',document.body.appendChild(menu);const rect=anchorEl.getBoundingClientRect(),openUp=rect.bottom+90+12>window.innerHeight;menu.style.top=openUp?"":rect.bottom+4+"px",menu.style.bottom=openUp?window.innerHeight-rect.top+4+"px":"",menu.style.left=Math.max(10,rect.right-190)+"px";const close=()=>{menu.remove(),document.removeEventListener("mousedown",onDoc)},onDoc=e=>{!menu.contains(e.target)&&e.target!==anchorEl&&close()};setTimeout(()=>document.addEventListener("mousedown",onDoc),0),menu.querySelector('[data-action="all"]').addEventListener("click",()=>{close(),typeof window.csForReader=="function"&&window.csForReader()}),menu.querySelector('[data-action="selected"]').addEventListener("click",()=>{close(),_abOpenRecastSelectPopup(bookTitle,existingChars)})}function _abOpenRecastSelectPopup(bookTitle,existingChars){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML=`
Define selected characters
Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context \u2014 everyone else's sheet is left as-is.
`+existingChars.map(c=>'").join("")+'
',document.body.appendChild(ov),ov.querySelector("#ab-recast-cancel").addEventListener("click",()=>ov.remove()),ov.querySelector("#ab-recast-select-all").addEventListener("click",e=>{const boxes=ov.querySelectorAll(".ab-recast-cb"),allChecked=[...boxes].every(b=>b.checked);boxes.forEach(b=>{b.checked=!allChecked}),e.currentTarget.textContent=allChecked?"Select all":"Select none"}),ov.querySelector("#ab-recast-confirm").addEventListener("click",async()=>{const names=[...ov.querySelectorAll(".ab-recast-cb:checked")].map(b=>b.value);if(!names.length){toast("Select at least one character","error");return}ov.remove(),typeof window.audiobookRecastSelectedCharacters=="function"?await window.audiobookRecastSelectedCharacters(names):typeof window.csForReaderSelective=="function"&&await window.csForReaderSelective(names)})}async function audiobookRecastSelectedCharacters(selectedNames){return typeof window.csForReaderSelective=="function"?window.csForReaderSelective(selectedNames):(toast("Character-sheet recast is unavailable right now","error"),null)}window.audiobookRecastSelectedCharacters=audiobookRecastSelectedCharacters;function audiobookCastView(total,llmUrl,defaultModel,isIdle=!1){var _a2,_b2,_c2,_d2,_e2,_f2;const panel=document.getElementById("reader-audiobook-panel");if(!panel)return;if(typeof window.navReaderView=="function")window.navReaderView("cast");else if(typeof window.showReaderView=="function")window.showReaderView("cast");else{const mainView=document.getElementById("reader-main-view");mainView&&(mainView.hidden=!0),panel.hidden=!1}panel.className="ab-castpanel-inline card",panel.style.display="",panel.style.flexDirection="",panel.style.minHeight="",panel.innerHTML=` +
`,document.body.appendChild(ov),ov.setProgress=(done,total)=>{const fill=ov.querySelector(".ab-busy-fill");fill&&total&&(fill.style.width=Math.round(done/total*100)+"%");const msgEl=ov.querySelector(".ab-busy-msg");msgEl&&(msgEl.textContent=`${message} (${done} / ${total})`)},ov}let _abFootMenuEl=null;function _abCloseFootMenu(){var _a2;_abFootMenuEl&&((_a2=document.getElementById(_abFootMenuEl.dataset.forId))==null||_a2.classList.remove("is-menu-open"),_abFootMenuEl.remove(),_abFootMenuEl=null,document.removeEventListener("click",_abFootMenuOutside,!0),document.removeEventListener("keydown",_abFootMenuKey,!0))}function _abFootMenuOutside(e){_abFootMenuEl&&!_abFootMenuEl.contains(e.target)&&!e.target.closest(".ab-foot-trigger")&&_abCloseFootMenu()}function _abFootMenuKey(e){e.key==="Escape"&&_abCloseFootMenu()}function _abToggleFootMenu(triggerBtn,items){const reopening=_abFootMenuEl&&_abFootMenuEl.dataset.forId===triggerBtn.id;if(_abCloseFootMenu(),reopening)return;const el=document.createElement("div");el.className="ab-foot-menu",el.dataset.forId=triggerBtn.id,el.innerHTML=items.map((it,i)=>it.divider?'
':``).join(""),document.body.appendChild(el);const rect=triggerBtn.getBoundingClientRect();el.style.left=Math.max(8,Math.min(rect.left,window.innerWidth-el.offsetWidth-8))+"px",el.style.bottom=window.innerHeight-rect.top+6+"px",el.querySelectorAll(".ab-foot-menu-item").forEach(btn=>{btn.addEventListener("click",()=>{var _a2;const it=items[Number(btn.dataset.idx)];_abCloseFootMenu(),(_a2=it==null?void 0:it.onClick)==null||_a2.call(it)})}),_abFootMenuEl=el,triggerBtn.classList.add("is-menu-open"),setTimeout(()=>{document.addEventListener("click",_abFootMenuOutside,!0),document.addEventListener("keydown",_abFootMenuKey,!0)},0)}function _abOpenRecastSelectPopup(existingChars){const ov=document.createElement("div");ov.className="audiobook-overlay";const selected=new Set;let sort="lines",filter="";try{sort=localStorage.getItem("ttsvc_ab_recast_sort")||"lines"}catch{}const rowHtml=c=>{var _a2;const lineCount=((_a2=c==null?void 0:c.sheet)==null?void 0:_a2.line_count)||0,color=_abRecordColor(c,c.name),avatar=c!=null&&c.image?``:`${escHtml(((c==null?void 0:c.name)||"?")[0].toUpperCase())}`;return``},render=()=>{const q=filter.trim().toLowerCase(),items=existingChars.filter(c=>!q||(c.name||"").toLowerCase().includes(q)).sort(sort==="alpha"?(a,b)=>(a.name||"").localeCompare(b.name||""):(a,b)=>{var _a2,_b2;return(((_a2=b.sheet)==null?void 0:_a2.line_count)||0)-(((_b2=a.sheet)==null?void 0:_b2.line_count)||0)}),list=ov.querySelector(".ab-recast-select-list");list&&(list.innerHTML=items.length?items.map(rowHtml).join(""):'
No matches
',list.querySelectorAll(".ab-recast-cb").forEach(cb=>{cb.addEventListener("change",()=>{cb.checked?selected.add(cb.value):selected.delete(cb.value)})}))};ov.innerHTML=`
Cast selected character roles
Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context. Everyone else's sheet stays as-is.
`,document.body.appendChild(ov),render();const sortSel=ov.querySelector("#ab-recast-sort");sortSel.value=sort,sortSel.addEventListener("change",()=>{sort=sortSel.value;try{localStorage.setItem("ttsvc_ab_recast_sort",sort)}catch{}render()});let _searchT=null;ov.querySelector("#ab-recast-search").addEventListener("input",e=>{clearTimeout(_searchT);const v=e.target.value;_searchT=setTimeout(()=>{filter=v,render()},120)}),ov.querySelector("#ab-recast-cancel").addEventListener("click",()=>ov.remove()),ov.querySelector("#ab-recast-select-all").addEventListener("click",e=>{const allSelected=existingChars.length>0&&existingChars.every(c=>selected.has(c.name));allSelected?selected.clear():existingChars.forEach(c=>selected.add(c.name)),e.currentTarget.textContent=allSelected?"Select all":"Select none",render()}),ov.querySelector("#ab-recast-confirm").addEventListener("click",async()=>{const names=[...selected];if(!names.length){toast("Select at least one character","error");return}ov.remove(),typeof window.audiobookRecastSelectedCharacters=="function"?await window.audiobookRecastSelectedCharacters(names):typeof window.csForReaderSelective=="function"&&await window.csForReaderSelective(names)})}async function audiobookRecastSelectedCharacters(selectedNames){return typeof window.csForReaderSelective=="function"?window.csForReaderSelective(selectedNames):(toast("Character-sheet recast is unavailable right now","error"),null)}window.audiobookRecastSelectedCharacters=audiobookRecastSelectedCharacters;function _abScrollIntoView(el,opts){el&&(el.scrollIntoView(Object.assign({behavior:"auto"},opts)),requestAnimationFrame(()=>requestAnimationFrame(()=>{el.scrollIntoView(Object.assign({behavior:"smooth"},opts))})))}function audiobookCastView(total,llmUrl,defaultModel,isIdle=!1){var _a2,_b2,_c2,_d2,_e2,_f2;const panel=document.getElementById("reader-audiobook-panel");if(!panel)return;if(typeof window.navReaderView=="function")window.navReaderView("cast");else if(typeof window.showReaderView=="function")window.showReaderView("cast");else{const mainView=document.getElementById("reader-main-view");mainView&&(mainView.hidden=!0),panel.hidden=!1}panel.className="ab-castpanel-inline card",panel.style.display="",panel.style.flexDirection="",panel.style.minHeight="",panel.innerHTML=`
Casting audiobook @@ -1208,15 +1222,15 @@ ${lines.trim()}
-
- +
+ - - + +
`;const AB_DEFAULT_PROMPT=`Du bist ein erfahrener Drehbuchautor und H\xF6rbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente f\xFCr Erz\xE4hler und Dialoge (w\xF6rtliche Rede) zu unterteilen. @@ -1250,18 +1264,19 @@ ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv \u2014 arbeite w F\xDCR JEDES SEGMENT GIBST DU FOLGENDES AUS: - speaker: 'Narrator' f\xFCr Narration/Erz\xE4hlertext, oder den EXAKTEN Namen des Charakters f\xFCr gesprochene Dialoge. - type: 'narration' oder 'dialogue' -- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen vollst\xE4ndig (nie nur ein einzelnes \xBB oder \xAB stehen lassen). +- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' BEH\xC4LTST du die umschlie\xDFenden Anf\xFChrungszeichen als Teil des Texts (z.B. \xBBHallo!\xAB bleibt \xBBHallo!\xAB) \u2014 entferne sie NICHT und lasse niemals nur eines der beiden \xFCbrig. - emotion: Bei Dialogen 1-2 deutsche W\xF6rter, die den Tonfall beschreiben (z.B. w\xFCtend, fl\xFCsternd, \xE4ngstlich). Bei Narration leer lassen (''). STRIKTE FORMAT- UND TEXTREGELN: -- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (\xBBNein\xAB, sagte sie, \xBBhalt.\xAB), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt."). +- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (\xBBNein\xAB, sagte sie, \xBBhalt.\xAB), erstelle 3 Segmente: dialogue ("\xBBNein\xAB"), narration (", sagte sie, "), dialogue ("\xBBhalt.\xAB"). - \xBBText?\xAB und \xBBText!\xAB sind vollst\xE4ndige Dialoge \u2014 das ?\xAB bzw. !\xAB schlie\xDFt das Zitat ab, auch wenn es ungewohnt aussieht. - PDF-/OCR-SCHUTZ: Wenn ein \xBB oder \xAB offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schlie\xDFe ein offenes \xBB-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("fl\xFCsterte er", "sagte sie", "rief Uriens") oder normale Erz\xE4hlerhandlung weitergeht. -- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und entferne das einzelne \xAB aus dem ausgegebenen Text. +- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und h\xE4nge das schlie\xDFende \xAB an dessen Ende an, statt es als eigenes Segment stehen zu lassen \u2014 ein Anf\xFChrungszeichen darf NIE ein eigenes Segment f\xFCr sich bilden. - Nur wenn ein offenes \xBB wirklich am Ende des Auszugs steht und danach KEINE Erz\xE4hlerhandlung/Inquit-Formel mehr folgt, behandle den Text ab \xBB bis Textende als 'dialogue'. -- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren \u2014 abgesehen von entfernten \xE4u\xDFeren Dialog-Anf\xFChrungszeichen!`,normalizeCastingPrompt=prompt=>{let p=prompt||AB_DEFAULT_PROMPT;if(p=p.replace("- Wenn ein Auszug mit einem offenen \xBB-Zitat endet (kein schlie\xDFendes \xAB), behandle den Text ab \xBB bis Textende als 'dialogue'.",`- PDF-/OCR-SCHUTZ: Wenn ein \xBB oder \xAB offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schlie\xDFe ein offenes \xBB-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("fl\xFCsterte er", "sagte sie", "rief Uriens") oder normale Erz\xE4hlerhandlung weitergeht. +- Anf\xFChrungszeichen geh\xF6ren IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schlie\xDFende \xAB am Ende einer Figurenrede geh\xF6rt ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das \xF6ffnende \xBB am Anfang einer Figurenrede geh\xF6rt an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen \xBB, \u201E, \u201A oder \u203A beginnen oder enden, und NIE mit einem einzelnen \xAB, ", ' oder \u2039 enden oder beginnen \u2014 verschiebe das Zeichen ins richtige Nachbar-Segment. +- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!`,normalizeCastingPrompt=prompt=>{let p=prompt||AB_DEFAULT_PROMPT;if(p=p.replace("- Wenn ein Auszug mit einem offenen \xBB-Zitat endet (kein schlie\xDFendes \xAB), behandle den Text ab \xBB bis Textende als 'dialogue'.",`- PDF-/OCR-SCHUTZ: Wenn ein \xBB oder \xAB offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schlie\xDFe ein offenes \xBB-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("fl\xFCsterte er", "sagte sie", "rief Uriens") oder normale Erz\xE4hlerhandlung weitergeht. - Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und entferne das einzelne \xAB aus dem ausgegebenen Text. -- Nur wenn ein offenes \xBB wirklich am Ende des Auszugs steht und danach KEINE Erz\xE4hlerhandlung/Inquit-Formel mehr folgt, behandle den Text ab \xBB bis Textende als 'dialogue'.`),p=p.replace("- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen.","- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen vollst\xE4ndig (nie nur ein einzelnes \xBB oder \xAB stehen lassen)."),p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren!","- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren \u2014 abgesehen von entfernten \xE4u\xDFeren Dialog-Anf\xFChrungszeichen!"),/Doppelpunkt-Regel/.test(p)||(p=p.replace(`ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv): +- Nur wenn ein offenes \xBB wirklich am Ende des Auszugs steht und danach KEINE Erz\xE4hlerhandlung/Inquit-Formel mehr folgt, behandle den Text ab \xBB bis Textende als 'dialogue'.`),p=p.replace("- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen.","- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen vollst\xE4ndig (nie nur ein einzelnes \xBB oder \xAB stehen lassen)."),p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren!","- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren \u2014 abgesehen von entfernten \xE4u\xDFeren Dialog-Anf\xFChrungszeichen!"),p=p.replace("- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen vollst\xE4ndig (nie nur ein einzelnes \xBB oder \xAB stehen lassen).","- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' BEH\xC4LTST du die umschlie\xDFenden Anf\xFChrungszeichen als Teil des Texts (z.B. \xBBHallo!\xAB bleibt \xBBHallo!\xAB) \u2014 entferne sie NICHT und lasse niemals nur eines der beiden \xFCbrig."),p=p.replace('- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und entferne das einzelne \xAB aus dem ausgegebenen Text.','- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und h\xE4nge das schlie\xDFende \xAB an dessen Ende an, statt es als eigenes Segment stehen zu lassen \u2014 ein Anf\xFChrungszeichen darf NIE ein eigenes Segment f\xFCr sich bilden.'),p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren \u2014 abgesehen von entfernten \xE4u\xDFeren Dialog-Anf\xFChrungszeichen!","- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!"),/Doppelpunkt-Regel/.test(p)||(p=p.replace(`ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv): 1. Direkte Zuordnung: Achte auf W\xF6rter wie "sagte [Name]", "fragte er", "rief sie". L\xF6se Pronomen (er/sie) zum tats\xE4chlichen Namen auf. 2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausf\xFChrt und direkt davor/danach w\xF6rtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB"). 3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette l\xFCckenlos zur\xFCck zur letzten eindeutigen Nennung. @@ -1289,7 +1304,8 @@ ABSATZ- UND KAPITELSTRUKTUR: Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitel\xFCberschrift \u2014 immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenst\xE4ndige narration-Segmente oder als Teil des umgebenden Erz\xE4hler-Segments bei; erfinde daraus keinen Dialog und l\xF6sche sie nicht aus dem rekonstruierten Text.`)),!/Gedankenstrich-Pause-Regel/.test(p)){const newRules=`11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende \u2014 die Rede geht danach unver\xE4ndert weiter, bis das tats\xE4chliche schlie\xDFende Anf\xFChrungszeichen erscheint. 12. Stimm-Ank\xFCndigung: Erw\xE4hnt ein Erz\xE4hlersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), geh\xF6rt diese Zeile dieser Person \u2014 nicht 'Unknown'.`,unknownRuleRe=/(\n\d+\. 'Unknown' NUR,[^\n]*'Unknown'\.)/;unknownRuleRe.test(p)?p=p.replace(unknownRuleRe,`$1 ${newRules}`):p+=` -${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.audiobook_prompt?_appSettings.audiobook_prompt:AB_DEFAULT_PROMPT,globalPrompt=normalizeCastingPrompt(rawPrompt);globalPrompt!==rawPrompt&&typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=globalPrompt,fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:globalPrompt})}).catch(()=>{})),panel.querySelector("#ab-cv-prompt-text").value=globalPrompt;const closePanel=()=>{if(panel.hidden=!0,panel.innerHTML="",typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1),typeof window.navReaderView=="function")window.navReaderView("main");else if(typeof window.showReaderView=="function")window.showReaderView("main");else{const mv=document.getElementById("reader-main-view");mv&&(mv.hidden=!1)}},setStoppingState=()=>{const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.disabled=!0,cancelBtn.innerHTML=' Stopping...',cancelBtn.title="Stopping the current casting request");const status2=panel.querySelector("#ab-cv-status-msg");status2&&(status2.style.display="inline-block",status2.textContent="Stopping... completed passages will stay saved.")};panel.querySelector("#ab-cv-cancel").addEventListener("click",()=>{if(_audiobook.running){_audiobook.cancel=!0,setStoppingState(),typeof _audiobook.abort=="function"&&_audiobook.abort();return}closePanel()}),panel.querySelector("#ab-cv-prompt-btn").addEventListener("click",()=>{const p=panel.querySelector("#ab-cv-prompt-panel");p.hidden=!p.hidden;const chevron=panel.querySelector("#ab-cv-prompt-chevron");chevron&&(chevron.className=p.hidden?"mdi mdi-chevron-down":"mdi mdi-chevron-up")}),function(){const tBtn=panel.querySelector("#ab-cv-settings-toggle"),sBox=panel.querySelector("#ab-cv-settings"),chev=panel.querySelector("#ab-cv-settings-chevron");function apply(collapsed2){if(sBox&&(sBox.style.display=collapsed2?"none":"flex"),collapsed2){const p=panel.querySelector("#ab-cv-prompt-panel");p&&(p.hidden=!0)}chev&&(chev.className="mdi "+(collapsed2?"mdi-chevron-down":"mdi-chevron-up"))}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_settings_collapsed")==="1"}catch{}apply(collapsed),tBtn&&tBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_settings_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}(),function(){const side=panel.querySelector("#ab-cv-side"),body=panel.querySelector(".ab-cv-body"),cBtn=panel.querySelector("#ab-cv-side-collapse");function apply(collapsed2){side&&side.classList.toggle("is-collapsed",collapsed2),body&&body.classList.toggle("side-collapsed",collapsed2),cBtn&&(cBtn.querySelector(".mdi").className="mdi "+(collapsed2?"mdi-chevron-left":"mdi-chevron-right")),cBtn&&(cBtn.title=collapsed2?"Expand character list":"Collapse to avatars")}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_side_collapsed")==="1"}catch{}apply(collapsed),cBtn&&cBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_side_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}();let savedPrompts=[];try{savedPrompts=JSON.parse(localStorage.getItem("ttsvc_ab_prompts")||"[]")}catch{savedPrompts=[]}const libSelect=panel.querySelector("#ab-cv-prompt-lib"),delBtn=panel.querySelector("#ab-cv-prompt-del"),promptText=panel.querySelector("#ab-cv-prompt-text"),renderPromptLib=(selectedIdx=-1)=>{libSelect.innerHTML=''+savedPrompts.map((p,i)=>``).join(""),selectedIdx>=0?(libSelect.value=selectedIdx,delBtn.style.display="block"):(libSelect.value="",delBtn.style.display="none")};renderPromptLib(),libSelect.addEventListener("change",()=>{const idx=parseInt(libSelect.value),nameInput=panel.querySelector("#ab-cv-prompt-name");!isNaN(idx)&&savedPrompts[idx]?(promptText.value=savedPrompts[idx].prompt,nameInput&&(nameInput.value=savedPrompts[idx].name),delBtn.style.display="block"):(nameInput&&(nameInput.value=""),delBtn.style.display="none")}),delBtn.addEventListener("click",()=>{const idx=parseInt(libSelect.value);isNaN(idx)||confirm("Delete this saved prompt preset?")&&(savedPrompts.splice(idx,1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(),toast("Prompt deleted","success"))}),panel.querySelector("#ab-cv-prompt-save").addEventListener("click",async()=>{const val=promptText.value.trim();if(!val){toast("Prompt is empty","error");return}const name=panel.querySelector("#ab-cv-prompt-name").value.trim()||"Custom Prompt "+(savedPrompts.length+1);let targetIdx=parseInt(libSelect.value);!isNaN(targetIdx)&&savedPrompts[targetIdx]&&savedPrompts[targetIdx].name===name?savedPrompts[targetIdx].prompt=val:(savedPrompts.push({name,prompt:val}),targetIdx=savedPrompts.length-1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(targetIdx),toast("Prompt preset saved","success"),typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=val);try{await fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:val})})}catch{}}),(()=>{const localSel=panel.querySelector("#ab-cv-llm-url"),globalSel=document.getElementById("llm-active-url");if(globalSel&&localSel){const currentVal=localSel.value;localSel.innerHTML=globalSel.innerHTML,localSel.value=currentVal||globalSel.value||""}})();const loadModels=()=>{const urlInput2=panel.querySelector("#ab-cv-llm-url"),sel=panel.querySelector("#ab-cv-llm-select");if(!sel||!urlInput2)return;const currentUrl=urlInput2.value.trim(),oldVal=sel.value;sel.innerHTML='',fetch("/api/conversation/llm-models"+(currentUrl?"?url="+encodeURIComponent(currentUrl):"")).then(r=>r.json()).then(d=>{if(d.models&&d.models.length){const preferred=audiobookSafeLlmModel(oldVal||defaultModel);sel.innerHTML=d.models.map(m=>``).join(""),preferred&&d.models.includes(preferred)?sel.value=preferred:oldVal&&d.models.includes(oldVal)&&!audiobookIsRouterModel(oldVal)?sel.value=oldVal:d.models.includes(defaultModel)&&(sel.value=defaultModel)}else sel.innerHTML=``}).catch(()=>{sel.innerHTML=``})};loadModels(),panel.querySelector("#ab-cv-llm-refresh").addEventListener("click",loadModels);const urlInput=panel.querySelector("#ab-cv-llm-url");urlInput&&urlInput.addEventListener("change",loadModels);const applyPromptAndRun=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{callback&&callback(savedChoice.url,savedChoice.model)})};if(isIdle){const hasSegs=_audiobook.segments&&_audiobook.segments.length>0;if(panel.querySelector("#ab-cv-status-msg").style.display="inline-block",hasSegs){const recastUnkBtn=panel.querySelector("#ab-cv-start-recast-unk"),recastBtn=panel.querySelector("#ab-cv-start-recast");recastUnkBtn.style.display="inline-block",recastBtn.style.display="inline-block",recastUnkBtn.addEventListener("click",()=>applyPromptAndRun(audiobookRecastUnknown)),recastBtn.addEventListener("click",()=>applyPromptAndRun(audiobookCast)),panel.querySelector("#ab-cv-status-msg").textContent="Ready to define characters."}else{const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>applyPromptAndRun(audiobookCast))}}else panel.querySelector("#ab-cv-cancel").innerHTML=' Stop Casting';const fill=panel.querySelector("#ab-cv-fill"),count=panel.querySelector("#ab-cv-count"),feed=panel.querySelector("#ab-cv-feed"),chars=panel.querySelector("#ab-cv-chars"),roster=new Map,characterRecords=new Map,identityNames=recOrSheet=>{const s=(recOrSheet==null?void 0:recOrSheet.sheet)||recOrSheet||{},out=new Set,add=(v,opts={})=>clSplitIdentityTokens(v,opts).forEach(x=>out.add(x));return add((recOrSheet==null?void 0:recOrSheet.name)||s.name),["aliases","first_name","last_name","full_name","title"].forEach(k=>add(s[k],{aliases:k==="aliases"})),[...out]};let _hlVer=0,_hlCache={ver:-1,rosterSize:-1,regex:null,byLower:new Map};const registerCharacterRecord=rec=>{if(rec!=null&&rec.name){_hlVer++;for(const n of identityNames(rec))characterRecords.set(n.toLowerCase(),rec)}},recordForName=name=>characterRecords.get(String(name||"").toLowerCase())||null,colorFor=(name,rec)=>{const key=String(name||"").toLowerCase(),stored=rec||characterRecords.get(key),color=stored?_abRecordColor(stored,name):_abDefaultCharacterColor(name);return roster.has(name)?roster.get(name).color=color:roster.set(name,{count:0,color}),roster.get(name).color},_abAvatarHtml=(name,color)=>{var _a3;const img=(_a3=recordForName(name))==null?void 0:_a3.image,c=color||colorFor(name);return img?``:`${escHtml((name||"?")[0].toUpperCase())}`},setCharacterColor=(name,color)=>{const safe=_abNormalizeColor(color,name),info=roster.get(name);info&&(info.color=safe);const key=String(name||"").toLowerCase(),rec=characterRecords.get(key);return rec&&(rec.color=safe,rec.sheet&&(rec.sheet.color=safe)),safe},highlightText2=text=>{if(!text)return"";let html=escHtml(text);if(_hlCache.ver!==_hlVer||_hlCache.rosterSize!==roster.size){const extraNames=[];new Set([...characterRecords.values()]).forEach(rec=>extraNames.push(...identityNames(rec)));const seen=new Set,names=[];for(const n of[...roster.keys(),...extraNames]){const k=n.toLowerCase();n.length<2||k==="narrator"||/^unknown|unbekannt/i.test(k)||seen.has(k)||(seen.add(k),names.push(n))}names.sort((a,b)=>b.length-a.length);const pattern=names.map(n=>n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");_hlCache={ver:_hlVer,rosterSize:roster.size,regex:pattern?new RegExp(`\\b(${pattern})\\b`,"gi"):null,byLower:new Map(names.map(n=>[n.toLowerCase(),n]))}}return _hlCache.regex&&(html=html.replace(_hlCache.regex,match=>{const canon=_hlCache.byLower.get(match.toLowerCase())||match;return`${match}`})),html},feedWrap=panel.querySelector(".ab-cv-feed-wrap"),jumpBtn=panel.querySelector("#ab-cv-jump-btn"),_abTopbar=document.createElement("div");_abTopbar.className="ab-cv-topbar",feedWrap.insertBefore(_abTopbar,feedWrap.firstChild);const _abBar=document.createElement("div");_abBar.className="ab-char-bar",_abBar.hidden=!0,_abTopbar.appendChild(_abBar);const _abEditToolbar=document.createElement("div");_abEditToolbar.className="ab-edit-toolbar",_abEditToolbar.innerHTML=` +${newRules}`}return/gehören IMMER zum Dialog-Segment/.test(p)||(p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!",`- Anf\xFChrungszeichen geh\xF6ren IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schlie\xDFende \xAB am Ende einer Figurenrede geh\xF6rt ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das \xF6ffnende \xBB am Anfang einer Figurenrede geh\xF6rt an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen \xBB, \u201E, \u201A oder \u203A beginnen oder enden, und NIE mit einem einzelnen \xAB, ", ' oder \u2039 enden oder beginnen \u2014 verschiebe das Zeichen ins richtige Nachbar-Segment. +- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!`)),p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.audiobook_prompt?_appSettings.audiobook_prompt:AB_DEFAULT_PROMPT,globalPrompt=normalizeCastingPrompt(rawPrompt);globalPrompt!==rawPrompt&&typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=globalPrompt,fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:globalPrompt})}).catch(()=>{})),panel.querySelector("#ab-cv-prompt-text").value=globalPrompt;const closePanel=()=>{if(panel.hidden=!0,panel.innerHTML="",typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1),typeof window.navReaderView=="function")window.navReaderView("main");else if(typeof window.showReaderView=="function")window.showReaderView("main");else{const mv=document.getElementById("reader-main-view");mv&&(mv.hidden=!1)}},setStoppingState=()=>{const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.disabled=!0,cancelBtn.innerHTML=' Stopping...',cancelBtn.title="Stopping the current casting request");const status2=panel.querySelector("#ab-cv-status-msg");status2&&(status2.style.display="inline-block",status2.textContent="Stopping... completed passages will stay saved.")};panel.querySelector("#ab-cv-cancel").addEventListener("click",()=>{if(_audiobook.running){_audiobook.cancel=!0,setStoppingState(),typeof _audiobook.abort=="function"&&_audiobook.abort();return}closePanel()}),panel.querySelector("#ab-cv-prompt-btn").addEventListener("click",()=>{const p=panel.querySelector("#ab-cv-prompt-panel");p.hidden=!p.hidden;const chevron=panel.querySelector("#ab-cv-prompt-chevron");chevron&&(chevron.className=p.hidden?"mdi mdi-chevron-down":"mdi mdi-chevron-up")}),function(){const tBtn=panel.querySelector("#ab-cv-settings-toggle"),sBox=panel.querySelector("#ab-cv-settings"),chev=panel.querySelector("#ab-cv-settings-chevron");function apply(collapsed2){if(sBox&&(sBox.style.display=collapsed2?"none":"flex"),collapsed2){const p=panel.querySelector("#ab-cv-prompt-panel");p&&(p.hidden=!0)}chev&&(chev.className="mdi "+(collapsed2?"mdi-chevron-down":"mdi-chevron-up"))}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_settings_collapsed")==="1"}catch{}apply(collapsed),tBtn&&tBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_settings_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}(),function(){const side=panel.querySelector("#ab-cv-side"),body=panel.querySelector(".ab-cv-body"),cBtn=panel.querySelector("#ab-cv-side-collapse");function apply(collapsed2){side&&side.classList.toggle("is-collapsed",collapsed2),body&&body.classList.toggle("side-collapsed",collapsed2),cBtn&&(cBtn.querySelector(".mdi").className="mdi "+(collapsed2?"mdi-chevron-left":"mdi-chevron-right")),cBtn&&(cBtn.title=collapsed2?"Expand character list":"Collapse to avatars")}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_side_collapsed")==="1"}catch{}apply(collapsed),cBtn&&cBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_side_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}();let savedPrompts=[];try{savedPrompts=JSON.parse(localStorage.getItem("ttsvc_ab_prompts")||"[]")}catch{savedPrompts=[]}const libSelect=panel.querySelector("#ab-cv-prompt-lib"),delBtn=panel.querySelector("#ab-cv-prompt-del"),promptText=panel.querySelector("#ab-cv-prompt-text"),renderPromptLib=(selectedIdx=-1)=>{libSelect.innerHTML=''+savedPrompts.map((p,i)=>``).join(""),selectedIdx>=0?(libSelect.value=selectedIdx,delBtn.style.display="block"):(libSelect.value="",delBtn.style.display="none")};renderPromptLib(),libSelect.addEventListener("change",()=>{const idx=parseInt(libSelect.value),nameInput=panel.querySelector("#ab-cv-prompt-name");!isNaN(idx)&&savedPrompts[idx]?(promptText.value=savedPrompts[idx].prompt,nameInput&&(nameInput.value=savedPrompts[idx].name),delBtn.style.display="block"):(nameInput&&(nameInput.value=""),delBtn.style.display="none")}),delBtn.addEventListener("click",()=>{const idx=parseInt(libSelect.value);isNaN(idx)||confirm("Delete this saved prompt preset?")&&(savedPrompts.splice(idx,1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(),toast("Prompt deleted","success"))}),panel.querySelector("#ab-cv-prompt-save").addEventListener("click",async()=>{const val=promptText.value.trim();if(!val){toast("Prompt is empty","error");return}const name=panel.querySelector("#ab-cv-prompt-name").value.trim()||"Custom Prompt "+(savedPrompts.length+1);let targetIdx=parseInt(libSelect.value);!isNaN(targetIdx)&&savedPrompts[targetIdx]&&savedPrompts[targetIdx].name===name?savedPrompts[targetIdx].prompt=val:(savedPrompts.push({name,prompt:val}),targetIdx=savedPrompts.length-1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(targetIdx),toast("Prompt preset saved","success"),typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=val);try{await fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:val})})}catch{}}),(()=>{const localSel=panel.querySelector("#ab-cv-llm-url"),globalSel=document.getElementById("llm-active-url");if(globalSel&&localSel){const currentVal=localSel.value;localSel.innerHTML=globalSel.innerHTML,localSel.value=currentVal||globalSel.value||""}})();const loadModels=()=>{const urlInput2=panel.querySelector("#ab-cv-llm-url"),sel=panel.querySelector("#ab-cv-llm-select");if(!sel||!urlInput2)return;const currentUrl=urlInput2.value.trim(),oldVal=sel.value;sel.innerHTML='',fetch("/api/conversation/llm-models"+(currentUrl?"?url="+encodeURIComponent(currentUrl):"")).then(r=>r.json()).then(d=>{if(d.models&&d.models.length){const preferred=audiobookSafeLlmModel(oldVal||defaultModel);sel.innerHTML=d.models.map(m=>``).join(""),preferred&&d.models.includes(preferred)?sel.value=preferred:oldVal&&d.models.includes(oldVal)&&!audiobookIsRouterModel(oldVal)?sel.value=oldVal:d.models.includes(defaultModel)&&(sel.value=defaultModel)}else sel.innerHTML=``}).catch(()=>{sel.innerHTML=``})};loadModels(),panel.querySelector("#ab-cv-llm-refresh").addEventListener("click",loadModels);const urlInput=panel.querySelector("#ab-cv-llm-url");urlInput&&urlInput.addEventListener("change",loadModels);const applyPromptAndRun=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{callback&&callback(savedChoice.url,savedChoice.model)})};if(isIdle){const hasSegs=_audiobook.segments&&_audiobook.segments.length>0;if(panel.querySelector("#ab-cv-status-msg").style.display="inline-block",hasSegs){const recastUnkBtn=panel.querySelector("#ab-cv-start-recast-unk"),recastBtn=panel.querySelector("#ab-cv-start-recast");recastUnkBtn.style.display="inline-block",recastBtn.style.display="inline-block",recastUnkBtn.addEventListener("click",()=>applyPromptAndRun(audiobookRecastUnknown)),recastBtn.addEventListener("click",async()=>{await confirmDialog("Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.",{title:"Discard current cast?",okLabel:"Start from scratch",danger:!0})&&applyPromptAndRun(audiobookCast)}),panel.querySelector("#ab-cv-status-msg").textContent="Ready to identify characters."}else{const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>applyPromptAndRun(audiobookCast))}}else panel.querySelector("#ab-cv-cancel").innerHTML=' Stop Casting';const fill=panel.querySelector("#ab-cv-fill"),count=panel.querySelector("#ab-cv-count"),feed=panel.querySelector("#ab-cv-feed"),chars=panel.querySelector("#ab-cv-chars"),roster=new Map,characterRecords=new Map,identityNames=recOrSheet=>{const s=(recOrSheet==null?void 0:recOrSheet.sheet)||recOrSheet||{},out=new Set,add=(v,opts={})=>clSplitIdentityTokens(v,opts).forEach(x=>out.add(x));return add((recOrSheet==null?void 0:recOrSheet.name)||s.name),["aliases","first_name","last_name","full_name","title"].forEach(k=>add(s[k],{aliases:k==="aliases"})),[...out]};let _hlVer=0,_hlCache={ver:-1,rosterSize:-1,regex:null,byLower:new Map};const registerCharacterRecord=rec=>{if(rec!=null&&rec.name){_hlVer++;for(const n of identityNames(rec))characterRecords.set(n.toLowerCase(),rec)}},recordForName=name=>characterRecords.get(String(name||"").toLowerCase())||null,colorFor=(name,rec)=>{const key=String(name||"").toLowerCase(),stored=rec||characterRecords.get(key),color=stored?_abRecordColor(stored,name):_abDefaultCharacterColor(name);return roster.has(name)?roster.get(name).color=color:roster.set(name,{count:0,color}),roster.get(name).color},_abAvatarHtml=(name,color)=>{var _a3;const img=(_a3=recordForName(name))==null?void 0:_a3.image,c=color||colorFor(name);return img?``:`${escHtml((name||"?")[0].toUpperCase())}`},setCharacterColor=(name,color)=>{const safe=_abNormalizeColor(color,name),info=roster.get(name);info&&(info.color=safe);const key=String(name||"").toLowerCase(),rec=characterRecords.get(key);return rec&&(rec.color=safe,rec.sheet&&(rec.sheet.color=safe)),safe},highlightText2=text=>{if(!text)return"";let html=escHtml(text);if(_hlCache.ver!==_hlVer||_hlCache.rosterSize!==roster.size){const extraNames=[];new Set([...characterRecords.values()]).forEach(rec=>extraNames.push(...identityNames(rec)));const seen=new Set,names=[];for(const n of[...roster.keys(),...extraNames]){const k=n.toLowerCase();n.length<2||k==="narrator"||/^unknown|unbekannt/i.test(k)||seen.has(k)||(seen.add(k),names.push(n))}names.sort((a,b)=>b.length-a.length);const pattern=names.map(n=>n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");_hlCache={ver:_hlVer,rosterSize:roster.size,regex:pattern?new RegExp(`\\b(${pattern})\\b`,"gi"):null,byLower:new Map(names.map(n=>[n.toLowerCase(),n]))}}return _hlCache.regex&&(html=html.replace(_hlCache.regex,match=>{const canon=_hlCache.byLower.get(match.toLowerCase())||match;return`${match}`})),html},feedWrap=panel.querySelector(".ab-cv-feed-wrap"),jumpBtn=panel.querySelector("#ab-cv-jump-btn"),_abTopbar=document.createElement("div");_abTopbar.className="ab-cv-topbar",feedWrap.insertBefore(_abTopbar,feedWrap.firstChild);const _abBar=document.createElement("div");_abBar.className="ab-char-bar",_abBar.hidden=!0,_abTopbar.appendChild(_abBar);const _abEditToolbar=document.createElement("div");_abEditToolbar.className="ab-edit-toolbar",_abEditToolbar.innerHTML=` `,_abTopbar.appendChild(_abEditToolbar);const _abPageNav=document.createElement("div");_abPageNav.className="ab-page-nav",_abPageNav.hidden=!0,_abPageNav.innerHTML=` @@ -1297,7 +1313,7 @@ ${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings. `,_abTopbar.appendChild(_abPageNav);const _abFontCtl=document.createElement("div");_abFontCtl.className="ab-font-ctl",_abFontCtl.innerHTML=` - `,_abTopbar.appendChild(_abFontCtl);const _abApplyFont=()=>{let v=1;try{v=parseFloat(localStorage.getItem("ttsvc_reh_stage_scale"))||1}catch{}feed.style.setProperty("--reh-stage-scale",String(v))},_abFontStep=d=>{let v=1;try{v=parseFloat(localStorage.getItem("ttsvc_reh_stage_scale"))||1}catch{}v=Math.round(Math.min(2,Math.max(.7,v+d))*100)/100;try{localStorage.setItem("ttsvc_reh_stage_scale",String(v))}catch{}_abApplyFont(),typeof rehApplyStageFont=="function"&&rehApplyStageFont()};_abFontCtl.querySelector(".ab-font-dec").addEventListener("click",()=>_abFontStep(-.1)),_abFontCtl.querySelector(".ab-font-inc").addEventListener("click",()=>_abFontStep(.1)),_abApplyFont();let _abCharRows=[],_abNavIdx=0,_abDetailEl=null,_abCurrentPageNum=1;const _abClearHL=()=>{feed.querySelectorAll(".ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-hl","ab-char-focus"))},_abCloseProfile=()=>{_abDetailEl&&(_abDetailEl.remove(),_abDetailEl=null),feed.style.display="",jumpBtn&&typeof _userScrolled!="undefined"&&(jumpBtn.hidden=!_userScrolled),_abCharRows.forEach(r=>r.classList.add("ab-char-hl"));const btn=_abBar.querySelector(".ab-char-bar-profile");btn&&(btn.innerHTML=' Profil')},_abCloseBar=()=>{_abBar.hidden=!0,_abBar.innerHTML="",_abCloseProfile(),_abClearHL(),_abCharRows=[],chars.querySelectorAll(".ab-char-item").forEach(el=>el.classList.remove("is-active")),typeof _abSyncTopbar=="function"&&_abSyncTopbar()},_abNav=(dir,pool)=>{const rows=pool||_abCharRows;if(!rows.length)return;_abNavIdx=(_abNavIdx+dir+rows.length)%rows.length,feed.querySelectorAll(".ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-focus")),rows[_abNavIdx].classList.add("ab-char-focus"),rows[_abNavIdx].scrollIntoView({behavior:"smooth",block:"center"});const pos=_abBar.querySelector(".ab-char-bar-pos");pos&&(pos.textContent=_abNavIdx+1+" / "+rows.length)},_abRefreshCharacterColors=name=>{const names=name?[name]:[...roster.keys()];for(const n of names)colorFor(n);feed.querySelectorAll(".ab-cv-row").forEach(row=>{const seg=row.__seg;if(!seg)return;const speakerName=seg.type!=="dialogue"||!seg.speaker||seg.speaker.toLowerCase()==="narrator"?"Narrator":seg.speaker,c=colorFor(speakerName),spk=row.querySelector(".ab-cv-spk"),txt=row.querySelector(".ab-cv-txt");spk&&(!name||speakerName.toLowerCase()===name.toLowerCase())&&(spk.style.color=c),txt&&(txt.innerHTML=highlightText2(seg.text||""))}),renderRoster();const selected=_abBar.hidden?null:_abBar.dataset.charName;if(selected){const dot=_abBar.querySelector(".ab-char-bar-dot");dot&&(dot.style.background=colorFor(selected))}},_abShowProfile=async name=>{var _a3,_b3,_c3,_d3,_e3,_f3,_g2;_abCloseProfile(),feed.style.display="none",jumpBtn&&(jumpBtn.hidden=!0);const profileBtn=_abBar.querySelector(".ab-char-bar-profile");profileBtn&&(profileBtn.innerHTML=' Skript');const title=((_a3=window.readerState)==null?void 0:_a3.title)||"";let rec=null;try{const all=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(title):typeof clGetAll=="function"?await clGetAll():[];for(const r of all||[])registerCharacterRecord(r);rec=recordForName(name)}catch{}const detail=document.createElement("div");if(detail.className="ab-char-detail-panel",_abDetailEl=detail,!rec){detail.innerHTML='
Kein Charakterblatt \u2013 zuerst \u201ECast Characters" ausf\xFChren.
',feedWrap.appendChild(detail),detail.querySelector(".ab-char-detail-back").addEventListener("click",_abCloseProfile);return}registerCharacterRecord(rec);const sh=rec.sheet||{},charColor=setCharacterColor(rec.name,rec.color||sh.color||colorFor(rec.name,rec)),accent2=clHslToHex((clNameHue(rec.name)+40)%360,58,30),tier=String(sh.tier||"").toLowerCase(),tierLabel=tier==="main"?"Hauptcharakter":tier==="supporting"?"Nebencharakter":"Nebenfigur",voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",pct=((_b3=rec.sheet)==null?void 0:_b3.moral_alignment_score)!=null?Math.max(0,Math.min(100,rec.sheet.moral_alignment_score)):null,arcMap={"good-to-bad":"\u2198 Entwicklung zum B\xF6sen","bad-to-good":"\u2197 Wandel zum Guten",complex:"\u2195 Komplex","stable-good":"\u2192 Stabil gut","stable-bad":"\u2192 Stabil b\xF6se",neutral:"\u2192 Neutral"},avatarHtml=rec.image?`
${escHtml(rec.name)}
`:`
${escHtml((rec.name||"?")[0].toUpperCase())}
`,field=(lbl,val,sk)=>{const v=_abStr(val);return!v&&!sk?"":`
${lbl}
${escHtml(v)}
`},srcItems=Array.isArray(rec.sources)?rec.sources:[],sourcesHtml=srcItems.length?`
+ `,_abTopbar.appendChild(_abFontCtl);const _abApplyFont=()=>{let v=1;try{v=parseFloat(localStorage.getItem("ttsvc_reh_stage_scale"))||1}catch{}feed.style.setProperty("--reh-stage-scale",String(v))},_abFontStep=d=>{let v=1;try{v=parseFloat(localStorage.getItem("ttsvc_reh_stage_scale"))||1}catch{}v=Math.round(Math.min(2,Math.max(.7,v+d))*100)/100;try{localStorage.setItem("ttsvc_reh_stage_scale",String(v))}catch{}_abApplyFont(),typeof rehApplyStageFont=="function"&&rehApplyStageFont()};_abFontCtl.querySelector(".ab-font-dec").addEventListener("click",()=>_abFontStep(-.1)),_abFontCtl.querySelector(".ab-font-inc").addEventListener("click",()=>_abFontStep(.1)),_abApplyFont();let _abCharRows=[],_abNavIdx=0,_abDetailEl=null,_abCurrentPageNum=1;const _abClearHL=()=>{feed.querySelectorAll(".ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-hl","ab-char-focus"))},_abCloseProfile=()=>{_abDetailEl&&(_abDetailEl.remove(),_abDetailEl=null),feed.style.display="",jumpBtn&&typeof _userScrolled!="undefined"&&(jumpBtn.hidden=!_userScrolled),_abCharRows.forEach(r=>r.classList.add("ab-char-hl"));const btn=_abBar.querySelector(".ab-char-bar-profile");btn&&(btn.innerHTML=' Profil')},_abCloseBar=()=>{_abBar.hidden=!0,_abBar.innerHTML="",_abCloseProfile(),_abClearHL(),_abCharRows=[],chars.querySelectorAll(".ab-char-item").forEach(el=>el.classList.remove("is-active")),typeof _abSyncTopbar=="function"&&_abSyncTopbar()},_abNav=(dir,pool)=>{const rows=pool||_abCharRows;if(!rows.length)return;_abNavIdx=(_abNavIdx+dir+rows.length)%rows.length,feed.querySelectorAll(".ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-focus")),rows[_abNavIdx].classList.add("ab-char-focus"),_abScrollIntoView(rows[_abNavIdx],{block:"center"});const pos=_abBar.querySelector(".ab-char-bar-pos");pos&&(pos.textContent=_abNavIdx+1+" / "+rows.length)},_abRefreshCharacterColors=name=>{const names=name?[name]:[...roster.keys()];for(const n of names)colorFor(n);feed.querySelectorAll(".ab-cv-row").forEach(row=>{const seg=row.__seg;if(!seg)return;const speakerName=seg.type!=="dialogue"||!seg.speaker||seg.speaker.toLowerCase()==="narrator"?"Narrator":seg.speaker,c=colorFor(speakerName),spk=row.querySelector(".ab-cv-spk"),txt=row.querySelector(".ab-cv-txt");spk&&(!name||speakerName.toLowerCase()===name.toLowerCase())&&(spk.style.color=c),txt&&(txt.innerHTML=highlightText2(seg.text||""))}),renderRoster();const selected=_abBar.hidden?null:_abBar.dataset.charName;if(selected){const dot=_abBar.querySelector(".ab-char-bar-dot");dot&&(dot.style.background=colorFor(selected))}},_abShowProfile=async name=>{var _a3,_b3,_c3,_d3,_e3,_f3,_g2;_abCloseProfile(),feed.style.display="none",jumpBtn&&(jumpBtn.hidden=!0);const profileBtn=_abBar.querySelector(".ab-char-bar-profile");profileBtn&&(profileBtn.innerHTML=' Skript');const title=((_a3=window.readerState)==null?void 0:_a3.title)||"";let rec=null;try{const all=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(title):typeof clGetAll=="function"?await clGetAll():[];for(const r of all||[])registerCharacterRecord(r);rec=recordForName(name)}catch{}const detail=document.createElement("div");if(detail.className="ab-char-detail-panel",_abDetailEl=detail,!rec){detail.innerHTML='
Kein Charakterblatt \u2013 zuerst \u201ECast all character roles\u201C ausf\xFChren.
',feedWrap.appendChild(detail),detail.querySelector(".ab-char-detail-back").addEventListener("click",_abCloseProfile);return}registerCharacterRecord(rec);const sh=rec.sheet||{},charColor=setCharacterColor(rec.name,rec.color||sh.color||colorFor(rec.name,rec)),accent2=clHslToHex((clNameHue(rec.name)+40)%360,58,30),tier=String(sh.tier||"").toLowerCase(),tierLabel=tier==="main"?"Hauptcharakter":tier==="supporting"?"Nebencharakter":"Nebenfigur",voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",lineCount=_abStr(sh.line_count),pct=((_b3=rec.sheet)==null?void 0:_b3.moral_alignment_score)!=null?Math.max(0,Math.min(100,rec.sheet.moral_alignment_score)):null,arcMap={"good-to-bad":"\u2198 Entwicklung zum B\xF6sen","bad-to-good":"\u2197 Wandel zum Guten",complex:"\u2195 Komplex","stable-good":"\u2192 Stabil gut","stable-bad":"\u2192 Stabil b\xF6se",neutral:"\u2192 Neutral"},avatarHtml=rec.image?`
${escHtml(rec.name)}
`:`
${escHtml((rec.name||"?")[0].toUpperCase())}
`,field=(lbl,val,sk)=>{const v=_abStr(val);return!v&&!sk?"":`
${lbl}
${escHtml(v)}
`},srcItems=Array.isArray(rec.sources)?rec.sources:[],sourcesHtml=srcItems.length?`
${srcItems.map(s=>`Seite ${s.page||"?"}`).join("")}
`:"";detail.innerHTML=` @@ -1305,14 +1321,12 @@ ${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings. ${avatarHtml}
${escHtml(rec.name)}
- ${_abStr(sh.full_name)&&_abStr(sh.full_name).toLowerCase()!==String(rec.name||"").toLowerCase()?`
${escHtml(_abStr(sh.full_name))}
`:""} ${_abStr(sh.title)?`
${escHtml(_abStr(sh.title))}
`:""} - ${_abStr(sh.aliases)?`
auch bekannt als ${escHtml(_abStr(sh.aliases))}
`:""} ${_abStr(sh.archetype)?`
${escHtml(_abStr(sh.archetype))}
`:""} -
${tierLabel}
+
${tierLabel}${lineCount?`${escHtml(lineCount)} lines`:""}
- +
@@ -1325,7 +1339,7 @@ ${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.
${pct!=null?`
B\xF6se
Gut
${arcMap[sh.arc_direction||"neutral"]||"\u2192"} \xB7 ${pct>=70?"Rechtschaffen":pct<=30?"B\xF6se":"Ambivalent"} (${pct}/100)
`:""}
-
${field("Voller Name",sh.full_name,"full_name")}${field("Vorname",sh.first_name,"first_name")}${field("Nachname",sh.last_name,"last_name")}${field("Geschlecht",sh.gender,"gender")}${field("Titel",sh.title,"title")}${field("Beruf / Rolle",sh.profession,"profession")}${field("Auch bekannt als",sh.aliases,"aliases")}
+
${field("Vorname",sh.first_name,"first_name")}${field("Nachname",sh.last_name,"last_name")}${field("Geschlecht",sh.gender,"gender")}${field("Titel",sh.title,"title")}${field("Beruf / Rolle",sh.profession,"profession")}${field("Auch bekannt als",sh.aliases,"aliases")}
${field("K\xF6rperlich",sh.physical,"physical")}${field("Kleidung",sh.clothing,"clothing")}
${field("Eigenheiten",sh.mannerisms,"mannerisms")}${field("Stimme & Sprache",sh.voice_pattern,"voice_pattern")}
${field("Hintergrund",sh.backstory,"backstory")}${field("Motivation",sh.motivation,"motivation")}
@@ -1333,7 +1347,7 @@ ${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.
${field("",sh.relationships,"relationships")}
${sourcesHtml}
- `,feedWrap.appendChild(detail),detail.querySelector(".ab-char-detail-back").addEventListener("click",_abCloseProfile),(_c3=detail.querySelector(".ab-cd-color input"))==null||_c3.addEventListener("input",async e=>{var _a4;const color=setCharacterColor(rec.name,e.target.value);rec.color=color,rec.sheet||(rec.sheet={}),rec.sheet.color=color,(_a4=detail.querySelector(".lcd-header"))==null||_a4.style.setProperty("--lc1",color);const av=detail.querySelector(".lcd-avatar");av&&!av.querySelector("img")&&(av.style.background=color),_abRefreshCharacterColors(rec.name),clearTimeout(_audiobook._colorSaveTimer),_audiobook._colorSaveTimer=setTimeout(async()=>{try{typeof clPut=="function"&&await clPut(rec)}catch{}},400)}),(_d3=detail.querySelector(".ab-cd-pick"))==null||_d3.addEventListener("click",()=>{typeof _openVoicePicker=="function"&&_openVoicePicker(detail.querySelector(".lcd-voice-top"),rec,async()=>{const up=(typeof clGetAll=="function"?await clGetAll():[]).find(r=>r.id===rec.id);up&&(_abCloseProfile(),_abShowProfile(up.name))})}),(_e3=detail.querySelector(".ab-cd-auto"))==null||_e3.addEventListener("click",async()=>{if(typeof _autoAssignVoice=="function"){await _autoAssignVoice(rec);const up=(typeof clGetAll=="function"?await clGetAll():[]).find(r=>r.id===rec.id);up&&(_abCloseProfile(),_abShowProfile(up.name))}}),(_f3=detail.querySelector(".ab-cd-online"))==null||_f3.addEventListener("click",()=>{typeof _charSearchOnline=="function"&&_charSearchOnline(rec)}),(_g2=detail.querySelector(".ab-cd-refine-btn"))==null||_g2.addEventListener("click",async()=>{const name2=String(rec.name||"").trim();if(name2){if(typeof window.audiobookRecastSelectedCharacters=="function")return window.audiobookRecastSelectedCharacters([name2]);if(typeof window.csForReaderSelective=="function")return window.csForReaderSelective([name2]);toast("Character refinement is unavailable right now","error")}});let _saveTimer=null;const editBtn=detail.querySelector(".ab-cd-edit-btn");editBtn==null||editBtn.addEventListener("click",function(){var _a4;const editing=detail.classList.toggle("ab-cd-editing");this.innerHTML=editing?'':'',this.title=editing?"Fertig":"Charakterblatt bearbeiten",detail.querySelectorAll(".ab-editable").forEach(el=>{el.contentEditable=editing?"true":"false",editing&&el.addEventListener("input",function(){clearTimeout(_saveTimer),_saveTimer=setTimeout(async()=>{rec.sheet||(rec.sheet={}),rec.sheet[el.dataset.sk]=el.textContent.trim(),["aliases","first_name","last_name","full_name","title"].includes(el.dataset.sk)&®isterCharacterRecord(rec),typeof clPut=="function"&&await clPut(rec)},900)})}),editing&&((_a4=detail.querySelector(".ab-editable"))==null||_a4.focus())}),detail.querySelectorAll(".ab-cd-src").forEach(el=>{el.addEventListener("click",()=>{const pg=parseInt(el.dataset.page,10);pg&&(typeof navTo=="function"&&navTo("s-reader"),setTimeout(()=>{var _a4,_b4;const pages=(_a4=window.readerState)==null?void 0:_a4.pages;pages&&pages.length>=pg&&((_b4=pages[pg-1])!=null&&_b4.pageDiv)?pages[pg-1].pageDiv.scrollIntoView({behavior:"smooth",block:"start"}):typeof toast=="function"&&toast('Buch im \u201EVorlesen"-Bereich \xF6ffnen, dann nochmal klicken',"info")},300))})})},_abSelectChar=name=>{if(!_abBar.hidden&&_abBar.dataset.charName===name){_abCloseBar();return}_abCloseBar(),chars.querySelectorAll(".ab-char-item").forEach(el=>el.classList.toggle("is-active",el.dataset.name===name)),_abCharRows=Array.from(feed.querySelectorAll(".ab-cv-row")).filter(r=>{var _a3;return((_a3=r.__seg)==null?void 0:_a3.speaker)&&r.__seg.speaker.toLowerCase()===name.toLowerCase()}),_abNavIdx=0;const color=colorFor(name);_abBar.dataset.charName=name,_abBar.hidden=!1,_abSyncTopbar(),_abBar.innerHTML=`${(name||"?")[0].toUpperCase()} + `,feedWrap.appendChild(detail),detail.querySelector(".ab-char-detail-back").addEventListener("click",_abCloseProfile),(_c3=detail.querySelector(".ab-cd-color input"))==null||_c3.addEventListener("input",async e=>{var _a4;const color=setCharacterColor(rec.name,e.target.value);rec.color=color,rec.sheet||(rec.sheet={}),rec.sheet.color=color,(_a4=detail.querySelector(".lcd-header"))==null||_a4.style.setProperty("--lc1",color);const av=detail.querySelector(".lcd-avatar");av&&!av.querySelector("img")&&(av.style.background=color),_abRefreshCharacterColors(rec.name),clearTimeout(_audiobook._colorSaveTimer),_audiobook._colorSaveTimer=setTimeout(async()=>{try{typeof clPut=="function"&&await clPut(rec)}catch{}},400)}),(_d3=detail.querySelector(".ab-cd-pick"))==null||_d3.addEventListener("click",()=>{typeof _openVoicePicker=="function"&&_openVoicePicker(detail.querySelector(".lcd-voice-top"),rec,async()=>{const up=(typeof clGetAll=="function"?await clGetAll():[]).find(r=>r.id===rec.id);up&&(_abCloseProfile(),_abShowProfile(up.name))})}),(_e3=detail.querySelector(".ab-cd-auto"))==null||_e3.addEventListener("click",async()=>{if(typeof _autoAssignVoice=="function"){await _autoAssignVoice(rec);const up=(typeof clGetAll=="function"?await clGetAll():[]).find(r=>r.id===rec.id);up&&(_abCloseProfile(),_abShowProfile(up.name))}}),(_f3=detail.querySelector(".ab-cd-online"))==null||_f3.addEventListener("click",()=>{typeof _charSearchOnline=="function"&&_charSearchOnline(rec)}),(_g2=detail.querySelector(".ab-cd-refine-btn"))==null||_g2.addEventListener("click",async()=>{const name2=String(rec.name||"").trim();if(name2){if(typeof window.audiobookRecastSelectedCharacters=="function")return window.audiobookRecastSelectedCharacters([name2]);if(typeof window.csForReaderSelective=="function")return window.csForReaderSelective([name2]);toast("Character refinement is unavailable right now","error")}});const _saveTimers=new Map,editBtn=detail.querySelector(".ab-cd-edit-btn");editBtn==null||editBtn.addEventListener("click",function(){var _a4;const editing=detail.classList.toggle("ab-cd-editing");this.innerHTML=editing?'':'',this.title=editing?"Fertig":"Charakterblatt bearbeiten",detail.querySelectorAll(".ab-editable").forEach(el=>{el.contentEditable=editing?"true":"false",editing&&el.addEventListener("input",function(){const sk=el.dataset.sk;clearTimeout(_saveTimers.get(sk)),_saveTimers.set(sk,setTimeout(async()=>{rec.sheet||(rec.sheet={}),rec.sheet[sk]=el.textContent.trim(),["aliases","first_name","last_name","full_name","title"].includes(sk)&®isterCharacterRecord(rec),typeof clPut=="function"&&await clPut(rec)},900))})}),editing&&((_a4=detail.querySelector(".ab-editable"))==null||_a4.focus())}),detail.querySelectorAll(".ab-cd-src").forEach(el=>{el.addEventListener("click",()=>{const pg=parseInt(el.dataset.page,10);pg&&(typeof navTo=="function"&&navTo("s-reader"),setTimeout(()=>{var _a4,_b4;const pages=(_a4=window.readerState)==null?void 0:_a4.pages;pages&&pages.length>=pg&&((_b4=pages[pg-1])!=null&&_b4.pageDiv)?_abScrollIntoView(pages[pg-1].pageDiv,{block:"start"}):typeof toast=="function"&&toast('Buch im \u201EVorlesen"-Bereich \xF6ffnen, dann nochmal klicken',"info")},300))})})},_abSelectChar=name=>{if(!_abBar.hidden&&_abBar.dataset.charName===name){_abCloseBar();return}_abCloseBar(),chars.querySelectorAll(".ab-char-item").forEach(el=>el.classList.toggle("is-active",el.dataset.name===name)),_abCharRows=Array.from(feed.querySelectorAll(".ab-cv-row")).filter(r=>{var _a3;return((_a3=r.__seg)==null?void 0:_a3.speaker)&&r.__seg.speaker.toLowerCase()===name.toLowerCase()}),_abNavIdx=0;const color=colorFor(name);_abBar.dataset.charName=name,_abBar.hidden=!1,_abSyncTopbar(),_abBar.innerHTML=`${(name||"?")[0].toUpperCase()}
${escHtml(name)} ${_abCharRows.length} Zeilen @@ -1345,7 +1359,7 @@ ${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.
- `,_abCharRows.forEach(r=>r.classList.add("ab-char-hl")),_abCharRows.length&&(_abCharRows[0].classList.add("ab-char-focus"),_abCharRows[0].scrollIntoView({behavior:"smooth",block:"center"})),_abBar.querySelector(".ab-char-bar-prev").addEventListener("click",()=>_abNav(-1)),_abBar.querySelector(".ab-char-bar-next").addEventListener("click",()=>_abNav(1)),_abBar.querySelector(".ab-char-bar-profile").addEventListener("click",()=>{_abDetailEl?_abCloseProfile():_abShowProfile(name)}),_abBar.querySelector(".ab-char-bar-close").addEventListener("click",_abCloseBar);const inp=_abBar.querySelector(".ab-char-bar-search");let _st=null;const _doSearch=jump=>{const q=inp.value.trim().toLowerCase(),pool=q?_abCharRows.filter(r=>{var _a3,_b3;return(((_a3=r.__seg)==null?void 0:_a3.text)||((_b3=r.querySelector(".ab-cv-txt"))==null?void 0:_b3.textContent)||"").toLowerCase().includes(q)}):_abCharRows;feed.querySelectorAll(".ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-focus")),pool.length?(jump?_abNavIdx=(_abNavIdx+1)%pool.length:_abNavIdx=0,pool[_abNavIdx].classList.add("ab-char-focus"),pool[_abNavIdx].scrollIntoView({behavior:"smooth",block:"center"}),_abBar.querySelector(".ab-char-bar-pos").textContent=_abNavIdx+1+" / "+pool.length):_abBar.querySelector(".ab-char-bar-pos").textContent="0 Treffer"};inp.addEventListener("input",()=>{clearTimeout(_st),_st=setTimeout(()=>_doSearch(!1),200)}),inp.addEventListener("keydown",e=>{e.key==="Enter"&&(e.preventDefault(),_doSearch(!0))})};let _abRosterFilter="",_abRosterSort="count";const renderRoster=()=>{const q=_abRosterFilter.trim().toLowerCase();let items=[...roster.entries()].filter(([n,info])=>info.count>0&&(!q||n.toLowerCase().includes(q)));if(items.sort(_abRosterSort==="alpha"?(a,b)=>a[0].localeCompare(b[0]):(a,b)=>b[1].count-a[1].count),!items.length){chars.innerHTML=`${q?"No matches":"reading\u2026"}`;return}const prev=_abBar.hidden?null:_abBar.dataset.charName;chars.innerHTML=items.map(([n,info])=>{var _a3;const color=info.color||colorFor(n);return`
+ `,_abCharRows.forEach(r=>r.classList.add("ab-char-hl")),_abCharRows.length&&(_abCharRows[0].classList.add("ab-char-focus"),_abScrollIntoView(_abCharRows[0],{block:"center"})),_abBar.querySelector(".ab-char-bar-prev").addEventListener("click",()=>_abNav(-1)),_abBar.querySelector(".ab-char-bar-next").addEventListener("click",()=>_abNav(1)),_abBar.querySelector(".ab-char-bar-profile").addEventListener("click",()=>{_abDetailEl?_abCloseProfile():_abShowProfile(name)}),_abBar.querySelector(".ab-char-bar-close").addEventListener("click",_abCloseBar);const inp=_abBar.querySelector(".ab-char-bar-search");let _st=null;const _doSearch=jump=>{const q=inp.value.trim().toLowerCase(),pool=q?_abCharRows.filter(r=>{var _a3,_b3;return(((_a3=r.__seg)==null?void 0:_a3.text)||((_b3=r.querySelector(".ab-cv-txt"))==null?void 0:_b3.textContent)||"").toLowerCase().includes(q)}):_abCharRows;feed.querySelectorAll(".ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-focus")),pool.length?(jump?_abNavIdx=(_abNavIdx+1)%pool.length:_abNavIdx=0,pool[_abNavIdx].classList.add("ab-char-focus"),_abScrollIntoView(pool[_abNavIdx],{block:"center"}),_abBar.querySelector(".ab-char-bar-pos").textContent=_abNavIdx+1+" / "+pool.length):_abBar.querySelector(".ab-char-bar-pos").textContent="0 Treffer"};inp.addEventListener("input",()=>{clearTimeout(_st),_st=setTimeout(()=>_doSearch(!1),200)}),inp.addEventListener("keydown",e=>{e.key==="Enter"&&(e.preventDefault(),_doSearch(!0))})};let _abRosterFilter="",_abRosterSort="count";const renderRoster=()=>{const q=_abRosterFilter.trim().toLowerCase();let items=[...roster.entries()].filter(([n,info])=>info.count>0&&(!q||n.toLowerCase().includes(q)));if(items.sort(_abRosterSort==="alpha"?(a,b)=>a[0].localeCompare(b[0]):(a,b)=>b[1].count-a[1].count),!items.length){chars.innerHTML=q?'No matches':`
${[38,62,45,28,54,35].map(w=>`
`).join("")}
`;return}const prev=_abBar.hidden?null:_abBar.dataset.charName;chars.innerHTML=items.map(([n,info])=>{var _a3;const color=info.color||colorFor(n);return`
${_abAvatarHtml(n,color)} ${escHtml(n)} ${((_a3=roster.get(n))==null?void 0:_a3.count)||0} @@ -1363,15 +1377,15 @@ ${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.
-
`,document.body.appendChild(el);const rect=anchorEl.getBoundingClientRect();el.style.left=Math.min(rect.left,window.innerWidth-280)+"px",el.style.top=Math.min(rect.bottom+4,window.innerHeight-340)+"px";const inp=el.querySelector(".ab-alias-popup-inp");setTimeout(()=>inp.focus(),30);const otherByLower=new Map(otherNames.map(([n])=>[n.toLowerCase(),n])),doSave=async mergeName=>{var _a3,_b3;const alias=mergeName||inp.value.trim();if(!alias){_abCloseAliasPopup();return}const mergeTarget=otherByLower.get(alias.toLowerCase());if(_abCloseAliasPopup(),mergeTarget){const busy=_abShowBusyOverlay(`Merging "${mergeTarget}" into ${name}\u2026`,!0);await new Promise(r=>requestAnimationFrame(r));try{const book=((_a3=window.readerState)==null?void 0:_a3.title)||"",rec=await clUpsert(book,{name,aliases:alias});rec&&(registerCharacterRecord(rec),_hlCache&&(_hlCache.ver=-1));const{changed:n,arr}=_abMergeCharacters(mergeTarget,name);n&&(await _abRedrawSegmentsChunked(arr,(done,total2)=>busy.setProgress(done,total2)),_abPersistManualEdit()),toast(n?`Merged "${mergeTarget}" into ${name} (${n} line${n!==1?"s":""})`:`"${alias}" added as an alias for ${name}`,"success")}catch(err){toast("Could not merge: "+(err.message||err),"error")}finally{busy.remove()}}else try{const book=((_b3=window.readerState)==null?void 0:_b3.title)||"",rec=await clUpsert(book,{name,aliases:alias});rec&&(registerCharacterRecord(rec),_hlCache&&(_hlCache.ver=-1)),renderRoster(),toast(`"${alias}" added as an alias for ${name}`,"success")}catch(err){toast("Could not save alias: "+(err.message||err),"error")}};el.querySelector(".ab-alias-save").addEventListener("click",()=>doSave()),el.querySelector(".ab-alias-cancel").addEventListener("click",()=>_abCloseAliasPopup()),el.querySelectorAll(".ab-alias-merge-opt").forEach(btn=>{btn.addEventListener("click",()=>doSave(btn.dataset.name))}),inp.addEventListener("keydown",e=>{e.key==="Enter"?(e.preventDefault(),doSave()):e.key==="Escape"&&(e.preventDefault(),_abCloseAliasPopup())}),setTimeout(()=>{document.addEventListener("click",function onDoc(e){!el.contains(e.target)&&e.target!==anchorEl&&(_abCloseAliasPopup(),document.removeEventListener("click",onDoc))})},0),_abAliasPopup=el}function _abMergeCharacters(fromName,intoName){const active=_abActiveSegments();if(!active.arr.length)return{changed:0,arr:active.arr};_abPushEditState(active.key,active.arr);let changed=0;for(const s of active.arr)s.type==="dialogue"&&s.speaker&&s.speaker.toLowerCase()===fromName.toLowerCase()&&(s.speaker=intoName,changed++);return{changed,arr:active.arr}}(async()=>{var _a3;const title=((_a3=window.readerState)==null?void 0:_a3.title)||"";try{const records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(title):typeof clGetAll=="function"?await clGetAll():[];for(const rec of records||[])if(rec!=null&&rec.name){registerCharacterRecord(rec);for(const alias of identityNames(rec)){const rosterName=[...roster.keys()].find(n=>n.toLowerCase()===alias.toLowerCase());rosterName&&setCharacterColor(rosterName,_abRecordColor(rec,rec.name))}}_abRefreshCharacterColors()}catch{}})();const MAXROWS=Number.POSITIVE_INFINITY;let _userScrolled=!1;feed.addEventListener("scroll",()=>{var _a3;const atBottom=feed.scrollTop+feed.clientHeight>=feed.scrollHeight-80;_userScrolled=!atBottom,jumpBtn&&(jumpBtn.hidden=atBottom);const labels=Array.from(feed.querySelectorAll(".ab-cv-page-label[data-page]"));let current=_abCurrentPageNum;for(const label of labels){const r=(_a3=label.closest(".ab-cv-page"))==null?void 0:_a3.getBoundingClientRect(),fr=feed.getBoundingClientRect();if(r&&r.top<=fr.top+80)current=Number(label.dataset.page)||current;else break}current&&_abSetCurrentPage(current)},{passive:!0});const _scrollToBottom=()=>{feed.scrollTop=feed.scrollHeight};jumpBtn&&jumpBtn.addEventListener("click",()=>{_userScrolled=!1,jumpBtn.hidden=!0,_scrollToBottom()});const trim=()=>{if(_userScrolled)return;let rows=feed.querySelectorAll(".ab-cv-row, .ab-cv-note, .ab-cv-divider");for(;rows.length>MAXROWS;){const first=rows[0],page=first.closest(".ab-cv-page");first.remove(),page&&!page.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider")&&page.remove(),rows=feed.querySelectorAll(".ab-cv-row, .ab-cv-note, .ab-cv-divider")}_scrollToBottom()};let _abCurPage=null;const _abJumpToReaderPage=async pageNum=>{const pg=parseInt(pageNum,10);if(pg){if(typeof window.readerJumpToPage=="function"){await window.readerJumpToPage(pg);return}typeof navTo=="function"&&navTo("s-reader"),typeof window.navReaderView=="function"?window.navReaderView("main"):typeof window.showReaderView=="function"&&window.showReaderView("main"),setTimeout(async()=>{var _a3,_b3,_c3;const pageDiv=(_c3=(_b3=(_a3=window.readerState)==null?void 0:_a3.pages)==null?void 0:_b3[pg-1])==null?void 0:_c3.pageDiv;pageDiv?(typeof window.readerRenderPage=="function"&&await window.readerRenderPage(pg-1),pageDiv.scrollIntoView({behavior:"smooth",block:"start",inline:"nearest"})):typeof toast=="function"&&toast("Source page is not loaded in Reader yet","info")},250)}},_abNewPage=(label,pageNum)=>{_abCurPage&&!_abCurPage.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider")&&_abCurPage.remove();const p=document.createElement("div");if(p.className="ab-cv-page",label){const pageAttr=pageNum?` data-page="${escHtml(String(pageNum))}"`:"";p.innerHTML=``}return feed.appendChild(p),_abCurPage=p,pageNum&&(_abSetCurrentPage(pageNum),_abUpdatePageNav()),p},_abPage=()=>_abCurPage||_abNewPage(),_abPageNumbers=()=>{var _a3,_b3,_c3;const set=new Set,readerPages=((_a3=window.readerState)==null?void 0:_a3.mode)==="pdf"&&((_c3=(_b3=window.readerState)==null?void 0:_b3.pages)==null?void 0:_c3.length)||0;if(readerPages>0)for(let i=1;i<=readerPages;i++)set.add(i);else(_audiobook.pageMarks||[]).forEach(m=>set.add((m.page||0)+1));return feed.querySelectorAll(".ab-cv-page-label[data-page]").forEach(el=>{const n=parseInt(el.dataset.page,10);n&&set.add(n)}),[...set].sort((a,b)=>a-b)},_abSetCurrentPage=pageNum=>{const pg=parseInt(pageNum,10);if(!pg)return;_abCurrentPageNum=pg;const sel=_abPageNav.querySelector(".ab-page-select");sel&&[...sel.options].some(o=>Number(o.value)===pg)&&(sel.value=String(pg))},_abUpdatePageNav=()=>{const pages=_abPageNumbers(),sel=_abPageNav.querySelector(".ab-page-select");if(!sel||!pages.length){_abPageNav.hidden=!0,typeof _abSyncTopbar=="function"&&_abSyncTopbar();return}const old=Number(sel.value)||_abCurrentPageNum||pages[0];sel.innerHTML=pages.map(n=>``).join(""),_abPageNav.hidden=pages.length<=1,_abSetCurrentPage(pages.includes(old)?old:pages[0]),typeof _abSyncTopbar=="function"&&_abSyncTopbar()},_abScrollToCastPage=pageNum=>{var _a3;const pg=parseInt(pageNum,10);if(!pg)return;let label=feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`);if(!label){const active=_abActiveSegments();active.arr.length&&_abRedrawSegments(active.arr),label=feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`)}if(pg===1){_userScrolled=!0,jumpBtn&&(jumpBtn.hidden=!1);const firstPage=(label==null?void 0:label.closest(".ab-cv-page"))||feed.firstElementChild;firstPage?firstPage.scrollIntoView({behavior:"smooth",block:"start"}):feed.scrollTo({top:0,behavior:"smooth"}),_abSetCurrentPage(pg);return}if(!label){toast("This cast page is not available yet. Use the book button to jump to the source page.","info"),_abSetCurrentPage(pg);return}_userScrolled=!0,jumpBtn&&(jumpBtn.hidden=!1),(_a3=label.closest(".ab-cv-page"))==null||_a3.scrollIntoView({behavior:"smooth",block:"start"}),_abSetCurrentPage(pg)},_abStepPage=dir=>{var _a3;const pages=_abPageNumbers();if(!pages.length)return;const current=Number((_a3=_abPageNav.querySelector(".ab-page-select"))==null?void 0:_a3.value)||_abCurrentPageNum||pages[0],idx=Math.max(0,pages.indexOf(current)),next=pages[Math.max(0,Math.min(pages.length-1,idx+dir))];_abScrollToCastPage(next)};(_a2=_abPageNav.querySelector(".ab-page-select"))==null||_a2.addEventListener("change",e=>_abScrollToCastPage(e.target.value)),(_b2=_abPageNav.querySelector(".ab-page-prev"))==null||_b2.addEventListener("click",()=>_abStepPage(-1)),(_c2=_abPageNav.querySelector(".ab-page-next"))==null||_c2.addEventListener("click",()=>_abStepPage(1)),(_d2=_abPageNav.querySelector(".ab-page-source"))==null||_d2.addEventListener("click",()=>{var _a3;const pg=Number((_a3=_abPageNav.querySelector(".ab-page-select"))==null?void 0:_a3.value)||_abCurrentPageNum;_abJumpToReaderPage(pg)});const _abUndoStack=[],_abRedoStack=[],_abHistoryLimit=40,_abSyncTopbar=()=>{const hasSelection=!_abBar.hidden,hasHistory=_abUndoStack.length>0||_abRedoStack.length>0;_abEditToolbar.hidden=!hasHistory,_abTopbar.hidden=!1},_abActiveSegments=()=>_audiobook.running&&Array.isArray(_audiobook.liveSegments)?{key:"liveSegments",arr:_audiobook.liveSegments}:Array.isArray(_audiobook.segments)?{key:"segments",arr:_audiobook.segments}:Array.isArray(_audiobook.liveSegments)?{key:"liveSegments",arr:_audiobook.liveSegments}:{key:"segments",arr:[]},_abCloneSegments=segs=>(segs||[]).map(s=>({...s})),_abSyncRosterList=segs=>{const names=[];for(const s of segs||[]){const sp=(s==null?void 0:s.type)==="dialogue"&&s.speaker?s.speaker:"";!sp||/^Narrator$/i.test(sp)||/^Unknown|Unbekannt/i.test(sp)||names.some(n=>n.toLowerCase()===sp.toLowerCase())||names.push(sp)}_audiobook.roster=names},_abPersistManualEdit=()=>{const active=_abActiveSegments();if(_audiobook.lastText){const done=_audiobook.completedChunks||active.arr.length,total2=_audiobook.completedTotal||done;setTimeout(()=>_abSaveDraft(active.arr||[],_audiobook.roster||[],_audiobook.lastText,done,total2),50)}_audiobookDebouncedSave()},_abUpdateEditButtons=()=>{const undo=_abEditToolbar.querySelector(".ab-edit-undo"),redo=_abEditToolbar.querySelector(".ab-edit-redo");undo&&(undo.disabled=!_abUndoStack.length),redo&&(redo.disabled=!_abRedoStack.length),_abSyncTopbar()},_abPushEditState=(keyOverride,arrOverride)=>{const active=arrOverride?{key:keyOverride||"segments",arr:arrOverride}:_abActiveSegments();if(!active.arr.length)return!1;for(_abUndoStack.push({key:active.key,segments:_abCloneSegments(active.arr)});_abUndoStack.length>_abHistoryLimit;)_abUndoStack.shift();return _abRedoStack.length=0,_abUpdateEditButtons(),!0},_abRowSpeaker=s=>{const isNarrator=s.type!=="dialogue"||!s.speaker||String(s.speaker).toLowerCase()==="narrator";return{isNarrator,speakerName:isNarrator?"Narrator":s.speaker}},_abIsHeadingSeg=s=>{if((s==null?void 0:s.type)==="dialogue")return!1;const t=String((s==null?void 0:s.text)||"").trim();return!t||t.length>60?!1:/^(prolog(ue)?|epilog(ue)?|kapitel|chapter|teil|buch|part|book|akt|szene|scene)\b/i.test(t)||/^\d+[\.\)]?\s*(kapitel|chapter)?$/i.test(t)?!0:t.split(/\s+/).length<=7&&!/[.!?;:,«»"]/.test(t)},_abRowFromSegment=(s,extraClass="")=>{const{isNarrator,speakerName}=_abRowSpeaker(s),c=colorFor(speakerName),row=document.createElement("div");return row.className=`ab-cv-row${extraClass?" "+extraClass:""}${isNarrator?" is-narr":""}${_abIsHeadingSeg(s)?" is-heading":""}`,row.__seg=s,row.innerHTML=` +
`,document.body.appendChild(el);const rect=anchorEl.getBoundingClientRect();el.style.left=Math.min(rect.left,window.innerWidth-280)+"px",el.style.top=Math.min(rect.bottom+4,window.innerHeight-340)+"px";const inp=el.querySelector(".ab-alias-popup-inp");setTimeout(()=>inp.focus(),30);const otherByLower=new Map(otherNames.map(([n])=>[n.toLowerCase(),n])),doSave=async mergeName=>{var _a3,_b3;const alias=mergeName||inp.value.trim();if(!alias){_abCloseAliasPopup();return}const mergeTarget=otherByLower.get(alias.toLowerCase());if(_abCloseAliasPopup(),mergeTarget){const busy=_abShowBusyOverlay(`Merging "${mergeTarget}" into ${name}\u2026`,!0);await new Promise(r=>requestAnimationFrame(r));try{const book=((_a3=window.readerState)==null?void 0:_a3.title)||"",rec=await clUpsert(book,{name,aliases:alias});rec&&(registerCharacterRecord(rec),_hlCache&&(_hlCache.ver=-1));const{changed:n,segs}=_abMergeCharacters(mergeTarget,name);n&&(await _abPatchScatteredRows(segs,(done,total2)=>busy.setProgress(done,total2))||await _abRedrawSegmentsChunked(_abActiveSegments().arr,(done,total2)=>busy.setProgress(done,total2)),_abPersistManualEdit()),toast(n?`Merged "${mergeTarget}" into ${name} (${n} line${n!==1?"s":""})`:`"${alias}" added as an alias for ${name}`,"success")}catch(err){toast("Could not merge: "+(err.message||err),"error")}finally{busy.remove()}}else try{const book=((_b3=window.readerState)==null?void 0:_b3.title)||"",rec=await clUpsert(book,{name,aliases:alias});rec&&(registerCharacterRecord(rec),_hlCache&&(_hlCache.ver=-1)),renderRoster(),toast(`"${alias}" added as an alias for ${name}`,"success")}catch(err){toast("Could not save alias: "+(err.message||err),"error")}};el.querySelector(".ab-alias-save").addEventListener("click",()=>doSave()),el.querySelector(".ab-alias-cancel").addEventListener("click",()=>_abCloseAliasPopup()),el.querySelectorAll(".ab-alias-merge-opt").forEach(btn=>{btn.addEventListener("click",()=>doSave(btn.dataset.name))}),inp.addEventListener("keydown",e=>{e.key==="Enter"?(e.preventDefault(),doSave()):e.key==="Escape"&&(e.preventDefault(),_abCloseAliasPopup())}),setTimeout(()=>{document.addEventListener("click",function onDoc(e){!el.contains(e.target)&&e.target!==anchorEl&&(_abCloseAliasPopup(),document.removeEventListener("click",onDoc))})},0),_abAliasPopup=el}function _abMergeCharacters(fromName,intoName){const active=_abActiveSegments();if(!active.arr.length)return{changed:0,arr:active.arr,segs:[]};_abPushEditState(active.key,active.arr);let changed=0;const segs=[];for(const s of active.arr)s.type==="dialogue"&&s.speaker&&s.speaker.toLowerCase()===fromName.toLowerCase()&&(s.speaker=intoName,changed++,segs.push(s));return{changed,arr:active.arr,segs}}(async()=>{var _a3;const title=((_a3=window.readerState)==null?void 0:_a3.title)||"";try{const records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(title):typeof clGetAll=="function"?await clGetAll():[];for(const rec of records||[])if(rec!=null&&rec.name){registerCharacterRecord(rec);for(const alias of identityNames(rec)){const rosterName=[...roster.keys()].find(n=>n.toLowerCase()===alias.toLowerCase());rosterName&&setCharacterColor(rosterName,_abRecordColor(rec,rec.name))}}_abRefreshCharacterColors()}catch{}})();const MAXROWS=Number.POSITIVE_INFINITY;let _userScrolled=!1;feed.addEventListener("scroll",()=>{var _a3;const atBottom=feed.scrollTop+feed.clientHeight>=feed.scrollHeight-80;_userScrolled=!atBottom,jumpBtn&&(jumpBtn.hidden=atBottom);const labels=Array.from(feed.querySelectorAll(".ab-cv-page-label[data-page]"));let current=_abCurrentPageNum;for(const label of labels){const r=(_a3=label.closest(".ab-cv-page"))==null?void 0:_a3.getBoundingClientRect(),fr=feed.getBoundingClientRect();if(r&&r.top<=fr.top+80)current=Number(label.dataset.page)||current;else break}current&&_abSetCurrentPage(current)},{passive:!0});const _scrollToBottom=()=>{feed.scrollTop=feed.scrollHeight};jumpBtn&&jumpBtn.addEventListener("click",()=>{_userScrolled=!1,jumpBtn.hidden=!0,_scrollToBottom()});const trim=()=>{if(!_userScrolled){if(Number.isFinite(MAXROWS)){let rows=feed.querySelectorAll(".ab-cv-row, .ab-cv-note, .ab-cv-divider");for(;rows.length>MAXROWS;){const first=rows[0],page=first.closest(".ab-cv-page");first.remove(),page&&!page.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider")&&page.remove(),rows=feed.querySelectorAll(".ab-cv-row, .ab-cv-note, .ab-cv-divider")}}_scrollToBottom()}};let _abCurPage=null;const _abJumpToReaderPage=async pageNum=>{const pg=parseInt(pageNum,10);if(pg){if(typeof window.readerJumpToPage=="function"){await window.readerJumpToPage(pg);return}typeof navTo=="function"&&navTo("s-reader"),typeof window.navReaderView=="function"?window.navReaderView("main"):typeof window.showReaderView=="function"&&window.showReaderView("main"),setTimeout(async()=>{var _a3,_b3,_c3;const pageDiv=(_c3=(_b3=(_a3=window.readerState)==null?void 0:_a3.pages)==null?void 0:_b3[pg-1])==null?void 0:_c3.pageDiv;pageDiv?(typeof window.readerRenderPage=="function"&&await window.readerRenderPage(pg-1),_abScrollIntoView(pageDiv,{block:"start",inline:"nearest"})):typeof toast=="function"&&toast("Source page is not loaded in Reader yet","info")},250)}},_abNewPage=(label,pageNum)=>{_abCurPage&&!_abCurPage.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider")&&_abCurPage.remove();const p=document.createElement("div");if(p.className="ab-cv-page",label){const pageAttr=pageNum?` data-page="${escHtml(String(pageNum))}"`:"";p.innerHTML=``}return feed.appendChild(p),_abCurPage=p,pageNum&&(_abSetCurrentPage(pageNum),_abUpdatePageNav()),p},_abPage=()=>_abCurPage||_abNewPage(),_abPageNumbers=()=>{var _a3,_b3,_c3;const set=new Set,readerPages=((_a3=window.readerState)==null?void 0:_a3.mode)==="pdf"&&((_c3=(_b3=window.readerState)==null?void 0:_b3.pages)==null?void 0:_c3.length)||0;if(readerPages>0)for(let i=1;i<=readerPages;i++)set.add(i);else(_audiobook.pageMarks||[]).forEach(m=>set.add((m.page||0)+1));return feed.querySelectorAll(".ab-cv-page-label[data-page]").forEach(el=>{const n=parseInt(el.dataset.page,10);n&&set.add(n)}),[...set].sort((a,b)=>a-b)},_abSetCurrentPage=pageNum=>{const pg=parseInt(pageNum,10);if(!pg)return;_abCurrentPageNum=pg;const sel=_abPageNav.querySelector(".ab-page-select");sel&&[...sel.options].some(o=>Number(o.value)===pg)&&(sel.value=String(pg))},_abUpdatePageNav=()=>{const pages=_abPageNumbers(),sel=_abPageNav.querySelector(".ab-page-select");if(!sel||!pages.length){_abPageNav.hidden=!0,typeof _abSyncTopbar=="function"&&_abSyncTopbar();return}const old=Number(sel.value)||_abCurrentPageNum||pages[0];sel.innerHTML=pages.map(n=>``).join(""),_abPageNav.hidden=pages.length<=1,_abSetCurrentPage(pages.includes(old)?old:pages[0]),typeof _abSyncTopbar=="function"&&_abSyncTopbar()},_abScrollToCastPage=pageNum=>{const pg=parseInt(pageNum,10);if(!pg)return;let label=feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`);if(!label){const active=_abActiveSegments();active.arr.length&&_abRedrawSegments(active.arr),label=feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`)}if(pg===1){_userScrolled=!0,jumpBtn&&(jumpBtn.hidden=!1);const firstPage=(label==null?void 0:label.closest(".ab-cv-page"))||feed.firstElementChild;firstPage?_abScrollIntoView(firstPage,{block:"start"}):feed.scrollTo({top:0,behavior:"smooth"}),_abSetCurrentPage(pg);return}if(!label){toast("This cast page is not available yet. Use the book button to jump to the source page.","info"),_abSetCurrentPage(pg);return}_userScrolled=!0,jumpBtn&&(jumpBtn.hidden=!1),_abScrollIntoView(label.closest(".ab-cv-page"),{block:"start"}),_abSetCurrentPage(pg)},_abStepPage=dir=>{var _a3;const pages=_abPageNumbers();if(!pages.length)return;const current=Number((_a3=_abPageNav.querySelector(".ab-page-select"))==null?void 0:_a3.value)||_abCurrentPageNum||pages[0],idx=Math.max(0,pages.indexOf(current)),next=pages[Math.max(0,Math.min(pages.length-1,idx+dir))];_abScrollToCastPage(next)};(_a2=_abPageNav.querySelector(".ab-page-select"))==null||_a2.addEventListener("change",e=>_abScrollToCastPage(e.target.value)),(_b2=_abPageNav.querySelector(".ab-page-prev"))==null||_b2.addEventListener("click",()=>_abStepPage(-1)),(_c2=_abPageNav.querySelector(".ab-page-next"))==null||_c2.addEventListener("click",()=>_abStepPage(1)),(_d2=_abPageNav.querySelector(".ab-page-source"))==null||_d2.addEventListener("click",()=>{var _a3;const pg=Number((_a3=_abPageNav.querySelector(".ab-page-select"))==null?void 0:_a3.value)||_abCurrentPageNum;_abJumpToReaderPage(pg)});const _abUndoStack=[],_abRedoStack=[],_abHistoryLimit=40,_abSyncTopbar=()=>{const hasSelection=!_abBar.hidden,hasHistory=_abUndoStack.length>0||_abRedoStack.length>0;_abEditToolbar.hidden=!hasHistory,_abTopbar.hidden=!1},_abActiveSegments=()=>_audiobook.running&&Array.isArray(_audiobook.liveSegments)?{key:"liveSegments",arr:_audiobook.liveSegments}:Array.isArray(_audiobook.segments)?{key:"segments",arr:_audiobook.segments}:Array.isArray(_audiobook.liveSegments)?{key:"liveSegments",arr:_audiobook.liveSegments}:{key:"segments",arr:[]},_abCloneSegments=segs=>(segs||[]).map(s=>({...s})),_abSyncRosterList=segs=>{const names=[];for(const s of segs||[]){const sp=(s==null?void 0:s.type)==="dialogue"&&s.speaker?s.speaker:"";!sp||/^Narrator$/i.test(sp)||/^Unknown|Unbekannt/i.test(sp)||names.some(n=>n.toLowerCase()===sp.toLowerCase())||names.push(sp)}_audiobook.roster=names},_abPersistManualEdit=()=>{const active=_abActiveSegments();if(_audiobook.lastText){const done=_audiobook.completedChunks||active.arr.length,total2=_audiobook.completedTotal||done;setTimeout(()=>_abSaveDraft(active.arr||[],_audiobook.roster||[],_audiobook.lastText,done,total2),50)}_audiobookDebouncedSave()},_abUpdateEditButtons=()=>{const undo=_abEditToolbar.querySelector(".ab-edit-undo"),redo=_abEditToolbar.querySelector(".ab-edit-redo");undo&&(undo.disabled=!_abUndoStack.length),redo&&(redo.disabled=!_abRedoStack.length),_abSyncTopbar()},_abPushEditState=(keyOverride,arrOverride)=>{const active=arrOverride?{key:keyOverride||"segments",arr:arrOverride}:_abActiveSegments();if(!active.arr.length)return!1;for(_abUndoStack.push({key:active.key,segments:_abCloneSegments(active.arr)});_abUndoStack.length>_abHistoryLimit;)_abUndoStack.shift();return _abRedoStack.length=0,_abUpdateEditButtons(),!0},_abRowSpeaker=s=>{const isNarrator=s.type!=="dialogue"||!s.speaker||String(s.speaker).toLowerCase()==="narrator";return{isNarrator,speakerName:isNarrator?"Narrator":s.speaker}},_abIsHeadingSeg=s=>{if((s==null?void 0:s.type)==="dialogue")return!1;const t=String((s==null?void 0:s.text)||"").trim();return!t||t.length>60?!1:/^(prolog(ue)?|epilog(ue)?|kapitel|chapter|teil|buch|part|book|akt|szene|scene)\b/i.test(t)||/^\d+[\.\)]?\s*(kapitel|chapter)?$/i.test(t)?!0:t.split(/\s+/).length<=7&&!/[.!?;:,«»"]/.test(t)},_abRowFromSegment=(s,extraClass="")=>{const{isNarrator,speakerName}=_abRowSpeaker(s),c=colorFor(speakerName),row=document.createElement("div");return row.className=`ab-cv-row${extraClass?" "+extraClass:""}${isNarrator?" is-narr":""}${_abIsHeadingSeg(s)?" is-heading":""}`,row.__seg=s,row.innerHTML=` ${escHtml(speakerName)}${s.emotion?' ('+escHtml(s.emotion)+")":""} - ${highlightText2(s.text||"")}`,row},_abStartRowEdit=row=>{if(!row||!row.__seg||row.querySelector(".ab-cv-edit-ta"))return;const s=row.__seg,txtEl=row.querySelector(".ab-cv-txt");if(!txtEl)return;const ta=document.createElement("textarea");ta.className="ab-cv-edit-ta",ta.value=s.text||"",txtEl.replaceWith(ta),ta.focus(),ta.setSelectionRange(ta.value.length,ta.value.length);const commit=save=>{if(save){const val=ta.value;val!==s.text&&(_abPushEditState(),s.text=val,_abPersistManualEdit())}ta.replaceWith(txtEl),txtEl.innerHTML=highlightText2(s.text||"")};ta.addEventListener("keydown",e=>{e.key==="Enter"&&(e.ctrlKey||e.metaKey)?(e.preventDefault(),commit(!0)):e.key==="Escape"&&(e.preventDefault(),commit(!1))}),ta.addEventListener("blur",()=>commit(!0))},_abRecountRoster=segs=>{for(const info of roster.values())info.count=0;for(const s of segs||[]){const{speakerName}=_abRowSpeaker(s||{}),c=colorFor(speakerName);roster.has(speakerName)||roster.set(speakerName,{count:0,color:c}),roster.get(speakerName).count++}_abSyncRosterList(segs||[]),renderRoster()},_abRedrawSegments=segs=>{const selectedChar=!_abBar.hidden&&_abBar.dataset.charName?_abBar.dataset.charName:"";feed.innerHTML="",_abCurPage=null;let lastPage=null;for(const s of segs||[])s.page!=null&&s.page!==lastPage&&(_abNewPage("Page "+s.page,s.page),lastPage=s.page),_abPage().appendChild(_abRowFromSegment(s));_abRecountRoster(segs||[]),_abClearHL(),_abUpdatePageNav(),selectedChar&&(_abBar.hidden=!0,_abSelectChar(selectedChar))},_abRedrawSegmentsChunked=(segs,onProgress,batchSize=150)=>new Promise(resolve=>{const selectedChar=!_abBar.hidden&&_abBar.dataset.charName?_abBar.dataset.charName:"";feed.innerHTML="",_abCurPage=null;const list=segs||[];let i=0,lastPage=null;const step=()=>{const end=Math.min(i+batchSize,list.length);for(;i{if(!snap)return;const restored=_abCloneSegments(snap.segments);snap.key==="liveSegments"?_audiobook.liveSegments=restored:_audiobook.segments=restored,_abRedrawSegments(restored),_abPersistManualEdit()},_abUndoEdit=()=>{const active=_abActiveSegments();!_abUndoStack.length||!active.arr.length||(_abRedoStack.push({key:active.key,segments:_abCloneSegments(active.arr)}),_abRestoreEditState(_abUndoStack.pop()),_abUpdateEditButtons())},_abRedoEdit=()=>{const active=_abActiveSegments();!_abRedoStack.length||!active.arr.length||(_abUndoStack.push({key:active.key,segments:_abCloneSegments(active.arr)}),_abRestoreEditState(_abRedoStack.pop()),_abUpdateEditButtons())};(_e2=_abEditToolbar.querySelector(".ab-edit-undo"))==null||_e2.addEventListener("click",_abUndoEdit),(_f2=_abEditToolbar.querySelector(".ab-edit-redo"))==null||_f2.addEventListener("click",_abRedoEdit),_abSyncTopbar();let assignModeSeg=null,assignModeRow=null,assignPopup=document.getElementById("ab-cv-assign-popup");if(!assignPopup){assignPopup=document.createElement("div"),assignPopup.id="ab-cv-assign-popup",assignPopup.style.cssText="position:fixed; z-index:2001; display:none; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.4); padding:10px; width:220px; max-height:320px; flex-direction:column; gap:8px;";const header=document.createElement("div");header.style.cssText="display:flex; justify-content:space-between; align-items:center; margin-bottom:-4px; margin-top:-4px;",header.innerHTML='Assign to',assignPopup.appendChild(header);const inp=document.createElement("input");inp.type="text",inp.placeholder="Search or add character...",inp.style.cssText="width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);",assignPopup.appendChild(inp);const list=document.createElement("div");list.className="ab-cv-popup-list",list.style.cssText="display:flex; flex-direction:column; gap:2px; overflow-y:auto; max-height:220px; margin-right:-4px; padding-right:4px;",assignPopup.appendChild(list),document.body.appendChild(assignPopup),header.querySelector(".mdi-close").addEventListener("click",()=>{var _a3;return(_a3=assignPopup._close)==null?void 0:_a3.call(assignPopup)}),document.addEventListener("mousedown",e=>{var _a3;assignPopup.style.display!=="none"&&!assignPopup.contains(e.target)&&!e.target.closest(".ab-cv-spk")&&!e.target.closest(".ab-cv-txt")&&((_a3=assignPopup._close)==null||_a3.call(assignPopup))}),document.addEventListener("keydown",e=>{var _a3;e.key==="Escape"&&assignPopup.style.display!=="none"&&((_a3=assignPopup._close)==null||_a3.call(assignPopup))}),inp.addEventListener("keydown",e=>{var _a3,_b3;if(e.key==="Enter"){const addBtn=assignPopup.querySelector(".ab-cv-popup-add-btn"),visibleBtns=Array.from(assignPopup.querySelectorAll(".ab-cv-popup-list > div[data-name]")).filter(b=>b.style.display!=="none");if(addBtn&&addBtn.style.display!=="none")addBtn.click();else if(visibleBtns.length===1)visibleBtns[0].click();else{const val=inp.value.trim();val&&((_a3=assignPopup._assignName)==null||_a3.call(assignPopup,val))}}else e.key==="Escape"&&((_b3=assignPopup._close)==null||_b3.call(assignPopup))}),inp.addEventListener("input",()=>{const val=inp.value.trim().toLowerCase(),listItems=assignPopup.querySelectorAll(".ab-cv-popup-list > div[data-name]");let exactMatch=!1;listItems.forEach(item=>{const name=item.dataset.name.toLowerCase();name===val&&(exactMatch=!0),name.includes(val)?item.style.display="flex":item.style.display="none"});let addBtn=assignPopup.querySelector(".ab-cv-popup-add-btn");val&&!exactMatch&&val!=="narrator"?(addBtn||(addBtn=document.createElement("div"),addBtn.className="ab-cv-popup-add-btn",addBtn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--primary); border-top:1px solid var(--border); margin-top:4px; padding-top:8px;",addBtn.onmouseover=()=>addBtn.style.background="var(--panel)",addBtn.onmouseout=()=>addBtn.style.background="transparent"),addBtn.innerHTML=`+ Add "${escHtml(inp.value.trim())}"`,addBtn.onclick=()=>{var _a3;return(_a3=assignPopup._assignName)==null?void 0:_a3.call(assignPopup,inp.value.trim())},addBtn.style.display="flex",assignPopup.querySelector(".ab-cv-popup-list").appendChild(addBtn)):addBtn&&(addBtn.style.display="none")})}function closeAssignPopup(){assignPopup.style.display="none",assignModeRow&&assignModeRow.classList.remove("is-assigning"),assignModeSeg=null,assignModeRow=null}function assignName(name){if(!assignModeSeg)return;const active=_abActiveSegments();_abPushEditState(active.key,active.arr);const isNarrator=name.toLowerCase()==="narrator",_oldSpk=assignModeSeg.speaker;if(_oldSpk&&roster.has(_oldSpk)){const _old=roster.get(_oldSpk);_old.count>0&&_old.count--}assignModeSeg.speaker=isNarrator?"Narrator":name,assignModeSeg.type=isNarrator?"narration":"dialogue",isNarrator&&(assignModeSeg.emotion=""),assignModeRow.classList.remove("is-assigning");const speakerName=isNarrator?"Narrator":name,c=colorFor(speakerName);roster.has(speakerName)||roster.set(speakerName,{count:0,color:c}),roster.get(speakerName).count++,assignModeRow.className="ab-cv-row"+(isNarrator?" is-narr":""),assignModeRow.querySelector(".ab-cv-spk").innerHTML=`${escHtml(speakerName)}${assignModeSeg.emotion?' ('+escHtml(assignModeSeg.emotion)+")":""}`,assignModeRow.querySelector(".ab-cv-spk").style.color=c,assignModeRow.querySelector(".ab-cv-spk").title="Click to assign character",_abRecountRoster(_abActiveSegments().arr),toast(`Assigned to ${isNarrator?"Narrator":name}`,"success"),closeAssignPopup(),_abPersistManualEdit()}function _abOpenAssignPopup(row,anchorEl,prefill){var _a3;if(!row||!row.__seg||row.classList.contains("is-processing"))return;assignModeRow&&assignModeRow.classList.remove("is-assigning"),assignModeSeg=row.__seg,assignModeRow=row,row.classList.add("is-assigning"),assignPopup._assignName=assignName,assignPopup._close=closeAssignPopup,window.getSelection().removeAllRanges(),assignPopup.style.display="flex";const rect=anchorEl.getBoundingClientRect(),top=Math.min(rect.bottom+4,window.innerHeight-300);assignPopup.style.top=top+"px",assignPopup.style.left=rect.left+"px";const list=assignPopup.querySelector(".ab-cv-popup-list");list.innerHTML="";const narrBtn=document.createElement("div");narrBtn.dataset.name="Narrator",narrBtn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;",narrBtn.innerHTML='\u{1F4D6} Narrator',narrBtn.onmouseover=()=>narrBtn.style.background="var(--panel)",narrBtn.onmouseout=()=>narrBtn.style.background="transparent",narrBtn.onclick=()=>assignName("Narrator"),list.appendChild(narrBtn);const items=[...roster.entries()].sort((a,b)=>b[1].count-a[1].count);for(const[n,info]of items){const btn=document.createElement("div");btn.dataset.name=n,btn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);";const img=(_a3=recordForName(n))==null?void 0:_a3.image;btn.innerHTML=(img?``:``)+` ${escHtml(n)}`,btn.onmouseover=()=>btn.style.background="var(--panel)",btn.onmouseout=()=>btn.style.background="transparent",btn.onclick=()=>assignName(n),list.appendChild(btn)}const inp=assignPopup.querySelector("input");inp.value=prefill||"",setTimeout(()=>{inp.focus(),prefill&&inp.dispatchEvent(new Event("input"))},50)}const _abIsUnknownSeg=s=>!(s!=null&&s.speaker)||/^Unknown|Unbekannt/i.test(s.speaker),_abIsNarrSeg=s=>(s==null?void 0:s.type)!=="dialogue"||!s.speaker||/^Narrator$/i.test(s.speaker),_abMergedSegment=(a,b)=>{var _a3;let base=a;(_abIsUnknownSeg(a)&&!_abIsUnknownSeg(b)||_abIsNarrSeg(a)&&!_abIsNarrSeg(b))&&(base=b);const type=_abIsNarrSeg(base)?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":base.speaker||"Unknown",type,emotion:type==="dialogue"&&(base.emotion||a.emotion||b.emotion)||"",text:audiobookJoinSegmentText(a.text||"",b.text||""),page:(_a3=a.page)!=null?_a3:b.page}},_abMergeByRow=(row,dir)=>{const active=_abActiveSegments(),idx=active.arr.indexOf(row==null?void 0:row.__seg);if(idx<0)return;const leftIdx=dir<0?idx-1:idx,rightIdx=dir<0?idx:idx+1;if(leftIdx<0||rightIdx>=active.arr.length){toast("No adjacent segment to merge","info");return}_abPushEditState(active.key,active.arr);const merged=_abMergedSegment(active.arr[leftIdx],active.arr[rightIdx]);active.arr.splice(leftIdx,2,merged),active.key==="segments"?_audiobook.segments=active.arr:_audiobook.liveSegments=active.arr,_abRedrawSegments(active.arr),_abPersistManualEdit(),toast("Segments merged","success")};feed.addEventListener("click",e=>{var _a3;const pageLabel=e.target.closest(".ab-cv-page-label[data-page]");if(pageLabel){e.preventDefault(),e.stopPropagation(),_abJumpToReaderPage(pageLabel.dataset.page);return}const editBtn=e.target.closest(".ab-cv-edit-text");if(editBtn){e.preventDefault(),e.stopPropagation(),_abStartRowEdit(editBtn.closest(".ab-cv-row"));return}const mergeBtn=e.target.closest(".ab-cv-row-tool");if(mergeBtn){e.preventDefault(),e.stopPropagation(),_abMergeByRow(mergeBtn.closest(".ab-cv-row"),mergeBtn.classList.contains("ab-cv-merge-prev")?-1:1);return}const spk=e.target.closest(".ab-cv-spk");if(spk){_abOpenAssignPopup(spk.closest(".ab-cv-row"),spk,"");return}if(_abJustHandledSelection){_abJustHandledSelection=!1;return}const txtEl=e.target.closest(".ab-cv-txt");if(txtEl&&window.getSelection().isCollapsed){if(!assignModeRow)return;const row=txtEl.closest(".ab-cv-row"),nameHit=e.target.closest(".ab-name-hit"),clickedWord=nameHit?nameHit.dataset.name||nameHit.textContent.trim():(_a3=_abWordRangeAtPoint(e.clientX,e.clientY))==null?void 0:_a3.word;if(assignModeRow!==row){if(clickedWord){const inp=assignPopup.querySelector("input");inp.value=clickedWord,inp.dispatchEvent(new Event("input")),inp.focus()}return}if(nameHit)_abOpenAssignPopup(row,nameHit,clickedWord);else{const hit=_abWordRangeAtPoint(e.clientX,e.clientY);if(hit){const rect=hit.range.getBoundingClientRect();_abOpenAssignPopup(row,{getBoundingClientRect:()=>rect},hit.word)}}}});let _abJustHandledSelection=!1,_abHoverHL=document.getElementById("ab-word-hover");_abHoverHL||(_abHoverHL=document.createElement("div"),_abHoverHL.id="ab-word-hover",_abHoverHL.className="ab-word-hover",_abHoverHL.hidden=!0,document.body.appendChild(_abHoverHL));let _abHoverRaf=0,_abHoverPt=null;const _abHideHoverHL=()=>{_abHoverHL.hidden=!0};feed.addEventListener("mousemove",e=>{_abHoverPt={x:e.clientX,y:e.clientY,target:e.target},!_abHoverRaf&&(_abHoverRaf=requestAnimationFrame(()=>{var _a3;_abHoverRaf=0;const pt=_abHoverPt;if(!pt||!((_a3=pt.target)!=null&&_a3.closest))return;if(!pt.target.closest(".ab-cv-txt")||pt.target.closest(".ab-name-hit")||feed.querySelector(".ab-cv-edit-ta")){_abHideHoverHL();return}const hit=_abWordRangeAtPoint(pt.x,pt.y);if(!hit){_abHideHoverHL();return}const r=hit.range.getBoundingClientRect();if(!r.width){_abHideHoverHL();return}_abHoverHL.style.left=r.left-2+"px",_abHoverHL.style.top=r.top-1+"px",_abHoverHL.style.width=r.width+4+"px",_abHoverHL.style.height=r.height+2+"px",_abHoverHL.hidden=!1}))}),feed.addEventListener("mouseleave",_abHideHoverHL),feed.addEventListener("scroll",_abHideHoverHL,{passive:!0}),feed.addEventListener("dblclick",e=>{const hit=e.target.closest(".ab-name-hit");if(!hit)return;const row=hit.closest(".ab-cv-row"),name=hit.dataset.name;row&&name&&(_abOpenAssignPopup(row,hit,""),assignName(name))});let splitBtn=document.getElementById("ab-cv-split-btn");splitBtn||(splitBtn=document.createElement("button"),splitBtn.id="ab-cv-split-btn",splitBtn.className="btn-secondary btn-sm",splitBtn.innerHTML=' Split text to Unknown Speaker',splitBtn.style.cssText="position:fixed; z-index:2000; display:none; background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px rgba(0,0,0,0.3);",document.body.appendChild(splitBtn));let currentSplitState=null;const _abOffsetInEl=(el,node,offset)=>{const r=document.createRange();return r.selectNodeContents(el),r.setEnd(node,offset),r.toString().length};return document.addEventListener("selectionchange",()=>{if(!feed)return;const sel=window.getSelection();if(sel.isCollapsed||!feed.contains(sel.anchorNode)){splitBtn&&(splitBtn.style.display="none"),currentSplitState=null;return}const txtSpan=sel.anchorNode.nodeType===3?sel.anchorNode.parentNode.closest(".ab-cv-txt"):sel.anchorNode.closest(".ab-cv-txt");if(!txtSpan){splitBtn.style.display="none";return}const row=txtSpan.closest(".ab-cv-row");if(!row||!row.__seg)return;const rawText=sel.toString(),text=rawText.trim();if(text.length>0){if(assignModeSeg&&text.length<40&&!text.includes(` -`)){splitBtn.style.display="none";return}const range=sel.getRangeAt(0),rect=range.getBoundingClientRect();splitBtn.style.display="block",splitBtn.style.top=rect.bottom+8+"px",splitBtn.style.left=Math.max(10,rect.left+rect.width/2-100)+"px";const startOffset=_abOffsetInEl(txtSpan,range.startContainer,range.startOffset),endOffset=_abOffsetInEl(txtSpan,range.endContainer,range.endOffset);currentSplitState={row,text,rawText,seg:row.__seg,startOffset,endOffset}}else splitBtn.style.display="none",currentSplitState=null}),splitBtn.addEventListener("click",()=>{if(!currentSplitState)return;const{row,seg,startOffset,endOffset}=currentSplitState,fullText=seg.text||"",before=fullText.slice(0,startOffset),selectedText=fullText.slice(startOffset,endOffset),after=fullText.slice(endOffset);if(!selectedText)return;let arr=_audiobook.segments||[],globalIdx=arr.indexOf(seg);globalIdx===-1&&Array.isArray(_audiobook.liveSegments)&&(arr=_audiobook.liveSegments,globalIdx=arr.indexOf(seg)),globalIdx!==-1&&_abPushEditState(arr===_audiobook.liveSegments?"liveSegments":"segments",arr);const newSegs=[];if(before.length&&newSegs.push({speaker:seg.speaker,type:seg.type,emotion:seg.emotion,text:before,page:seg.page}),newSegs.push({speaker:"Unknown",type:"dialogue",emotion:"",text:selectedText,page:seg.page}),after.length&&newSegs.push({speaker:seg.speaker,type:seg.type,emotion:seg.emotion,text:after,page:seg.page}),globalIdx!==-1)arr.splice(globalIdx,1,...newSegs),_abRedrawSegments(arr),_abPersistManualEdit();else{console.warn("[audiobook] split during casting: DOM-only, segment not yet in array");const frag=document.createDocumentFragment();for(const s of newSegs)frag.appendChild(_abRowFromSegment(s));row.parentNode.insertBefore(frag,row),row.remove()}splitBtn.style.display="none",window.getSelection().removeAllRanges(),toast("Segment split! You can now assign a character to the Unknown block.","success")}),feed.addEventListener("mouseup",()=>{const sel=window.getSelection();if(sel.isCollapsed||!feed.contains(sel.anchorNode))return;const text=sel.toString().trim();!text||text.length>=40||text.includes(` -`)||/^[»„“"'‘]/.test(text)||assignModeSeg&&(_abJustHandledSelection=!0,assignName(text),sel.removeAllRanges())}),chars.addEventListener("click",e=>{const chip=e.target.closest(".ab-chip");if(chip){let name="";for(const n of chip.childNodes)n.nodeType===3&&(name+=n.nodeValue);if(name=name.trim(),assignModeSeg)name&&assignName(name);else if(name){const rows=Array.from(feed.querySelectorAll(".ab-cv-row")).filter(r=>r.__seg&&r.__seg.speaker===name);if(!rows.length)return;const currentY=feed.scrollTop;let target=rows.find(r=>r.offsetTop-feed.offsetTop>currentY+10);target||(target=rows[0]),target.scrollIntoView({behavior:"smooth",block:"center"}),target.style.transition="background 0.3s",target.style.background="var(--accent-hover, rgba(100, 150, 255, 0.2))",setTimeout(()=>{target.parentNode&&(target.style.background="")},1e3)}}}),{recountRoster(allSegs){_abRecountRoster(allSegs||[])},rebuild(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),_abRedrawSegments(segs||[])},update(done){const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!1);const pct=Math.round(done/total*100);fill&&(fill.style.width=pct+"%"),count&&(count.hidden=!1,count.textContent=`passage ${done} / ${total}`);const fillText=panel.querySelector("#ab-cv-fill-text");fillText&&(fillText.textContent=`${pct}% (Passage ${done} of ${total})`)},processing(text){this._procRow&&this._procRow.remove(),this._thinkRaw="",this._thinkDone=!1,this._procRow=document.createElement("div"),this._procRow.className="ab-cv-row is-processing ab-cv-llm-row";const isLong=text.length>160,preview=escHtml(text.slice(0,160))+(isLong?"\u2026":"");this._procRow.innerHTML=` + ${highlightText2(s.text||"")}`,row},_abPatchScatteredRows=(segs,onProgress)=>new Promise(resolve=>{if(!segs||!segs.length){resolve(!0);return}const bySeg=new Set(segs),targets=[...feed.querySelectorAll(".ab-cv-row")].filter(r=>r.__seg&&bySeg.has(r.__seg));if(!targets.length){resolve(!1);return}let i=0;const step=()=>{const end=Math.min(i+150,targets.length);for(;i{const rows=(oldRows||[]).filter(Boolean);if(!rows.length)return!1;const parent=rows[0].parentNode;if(!parent||rows.some(r=>r.parentNode!==parent))return!1;const beforeCounts=_abRosterCountsFromSegs(rows.map(r=>r.__seg).filter(Boolean)),afterCounts=_abRosterCountsFromSegs(newSegs||[]),frag=document.createDocumentFragment();for(const seg of newSegs||[])frag.appendChild(_abRowFromSegment(seg));parent.insertBefore(frag,rows[0]);for(const row of rows)row.remove();return _abApplyRosterDelta(beforeCounts,afterCounts),_abQueueRosterRender(),_abClearHL(),_abUpdatePageNav(),!0},_abAdjacentRow=(row,dir)=>{let cur=dir<0?row==null?void 0:row.previousElementSibling:row==null?void 0:row.nextElementSibling;for(;cur&&!(cur.classList&&cur.classList.contains("ab-cv-row"));)cur=dir<0?cur.previousElementSibling:cur.nextElementSibling;return cur||null},_abStartRowEdit=row=>{if(!row||!row.__seg||row.querySelector(".ab-cv-edit-ta"))return;const s=row.__seg,txtEl=row.querySelector(".ab-cv-txt");if(!txtEl)return;const ta=document.createElement("textarea");ta.className="ab-cv-edit-ta",ta.value=s.text||"",txtEl.replaceWith(ta),ta.focus(),ta.setSelectionRange(ta.value.length,ta.value.length);const commit=save=>{if(save){const val=ta.value;val!==s.text&&(_abPushEditState(),s.text=val,_abPersistManualEdit())}ta.replaceWith(txtEl),txtEl.innerHTML=highlightText2(s.text||"")};ta.addEventListener("keydown",e=>{e.key==="Enter"&&(e.ctrlKey||e.metaKey)?(e.preventDefault(),commit(!0)):e.key==="Escape"&&(e.preventDefault(),commit(!1))}),ta.addEventListener("blur",()=>commit(!0))},_abRosterCountsFromSegs=segs=>{const counts=new Map;for(const s of segs||[]){const{speakerName}=_abRowSpeaker(s||{});counts.set(speakerName,(counts.get(speakerName)||0)+1)}return counts},_abApplyRosterCounts=counts=>{for(const info of roster.values())info.count=0;for(const[name,count2]of counts||[]){const info=roster.get(name)||{count:0,color:colorFor(name)};info.count=count2,roster.set(name,info)}_audiobook.roster=[...roster.entries()].filter(([n,info])=>info.count>0&&!/^Narrator$/i.test(n)&&!/^Unknown|Unbekannt/i.test(n)).map(([n])=>n)},_abApplyRosterDelta=(beforeCounts,afterCounts)=>{const names=new Set([...(beforeCounts||new Map).keys(),...(afterCounts||new Map).keys()]);for(const name of names){const before=(beforeCounts==null?void 0:beforeCounts.get(name))||0,after=(afterCounts==null?void 0:afterCounts.get(name))||0;if(before===after)continue;const info=roster.get(name)||{count:0,color:colorFor(name)};info.count=Math.max(0,(info.count||0)+(after-before)),roster.set(name,info)}_audiobook.roster=[...roster.entries()].filter(([n,info])=>info.count>0&&!/^Narrator$/i.test(n)&&!/^Unknown|Unbekannt/i.test(n)).map(([n])=>n)};let _abRosterRenderRaf=0;const _abQueueRosterRender=()=>{_abRosterRenderRaf||(_abRosterRenderRaf=requestAnimationFrame(()=>{_abRosterRenderRaf=0,renderRoster()}))},_abRecountRoster=segs=>{_abApplyRosterCounts(_abRosterCountsFromSegs(segs||[])),_abQueueRosterRender()},_abRedrawSegments=segs=>{const selectedChar=!_abBar.hidden&&_abBar.dataset.charName?_abBar.dataset.charName:"";feed.innerHTML="",_abCurPage=null;let lastPage=null;for(const s of segs||[])s.page!=null&&s.page!==lastPage&&(_abNewPage("Page "+s.page,s.page),lastPage=s.page),_abPage().appendChild(_abRowFromSegment(s));_abRecountRoster(segs||[]),_abClearHL(),_abUpdatePageNav(),selectedChar&&(_abBar.hidden=!0,_abSelectChar(selectedChar))};let _abRedrawGen=0;const _abRedrawSegmentsChunked=(segs,onProgress,batchSize=150)=>new Promise(resolve=>{const selectedChar=!_abBar.hidden&&_abBar.dataset.charName?_abBar.dataset.charName:"",myGen=++_abRedrawGen;feed.innerHTML="",_abCurPage=null;const list=segs||[];let i=0,lastPage=null;const step=()=>{if(myGen!==_abRedrawGen){resolve();return}const end=Math.min(i+batchSize,list.length);for(;i{if(!snap)return;const restored=_abCloneSegments(snap.segments);snap.key==="liveSegments"?_audiobook.liveSegments=restored:_audiobook.segments=restored,_abRedrawSegments(restored),_abPersistManualEdit()},_abUndoEdit=()=>{const active=_abActiveSegments();!_abUndoStack.length||!active.arr.length||(_abRedoStack.push({key:active.key,segments:_abCloneSegments(active.arr)}),_abRestoreEditState(_abUndoStack.pop()),_abUpdateEditButtons())},_abRedoEdit=()=>{const active=_abActiveSegments();!_abRedoStack.length||!active.arr.length||(_abUndoStack.push({key:active.key,segments:_abCloneSegments(active.arr)}),_abRestoreEditState(_abRedoStack.pop()),_abUpdateEditButtons())};(_e2=_abEditToolbar.querySelector(".ab-edit-undo"))==null||_e2.addEventListener("click",_abUndoEdit),(_f2=_abEditToolbar.querySelector(".ab-edit-redo"))==null||_f2.addEventListener("click",_abRedoEdit),_abSyncTopbar();let assignModeSeg=null,assignModeRow=null,assignPopup=document.getElementById("ab-cv-assign-popup");if(!assignPopup){assignPopup=document.createElement("div"),assignPopup.id="ab-cv-assign-popup",assignPopup.style.cssText="position:fixed; z-index:2001; display:none; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.4); padding:10px; width:220px; max-height:320px; flex-direction:column; gap:8px;";const header=document.createElement("div");header.style.cssText="display:flex; justify-content:space-between; align-items:center; margin-bottom:-4px; margin-top:-4px;",header.innerHTML='Assign to',assignPopup.appendChild(header);const inp=document.createElement("input");inp.type="text",inp.placeholder="Search or add character...",inp.style.cssText="width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);",assignPopup.appendChild(inp);const list=document.createElement("div");list.className="ab-cv-popup-list",list.style.cssText="display:flex; flex-direction:column; gap:2px; overflow-y:auto; max-height:220px; margin-right:-4px; padding-right:4px;",assignPopup.appendChild(list),document.body.appendChild(assignPopup),header.querySelector(".mdi-close").addEventListener("click",()=>{var _a3;return(_a3=assignPopup._close)==null?void 0:_a3.call(assignPopup)}),document.addEventListener("mousedown",e=>{var _a3;assignPopup.style.display!=="none"&&!assignPopup.contains(e.target)&&!e.target.closest(".ab-cv-spk")&&!e.target.closest(".ab-cv-txt")&&((_a3=assignPopup._close)==null||_a3.call(assignPopup))}),document.addEventListener("keydown",e=>{var _a3;e.key==="Escape"&&assignPopup.style.display!=="none"&&((_a3=assignPopup._close)==null||_a3.call(assignPopup))}),inp.addEventListener("keydown",e=>{var _a3,_b3;if(e.key==="Enter"){const addBtn=assignPopup.querySelector(".ab-cv-popup-add-btn"),visibleBtns=Array.from(assignPopup.querySelectorAll(".ab-cv-popup-list > div[data-name]")).filter(b=>b.style.display!=="none");if(addBtn&&addBtn.style.display!=="none")addBtn.click();else if(visibleBtns.length===1)visibleBtns[0].click();else{const val=inp.value.trim();val&&((_a3=assignPopup._assignName)==null||_a3.call(assignPopup,val))}}else e.key==="Escape"&&((_b3=assignPopup._close)==null||_b3.call(assignPopup))}),inp.addEventListener("input",()=>{const val=inp.value.trim().toLowerCase(),listItems=assignPopup.querySelectorAll(".ab-cv-popup-list > div[data-name]");let exactMatch=!1;listItems.forEach(item=>{const name=item.dataset.name.toLowerCase();name===val&&(exactMatch=!0),name.includes(val)?item.style.display="flex":item.style.display="none"});let addBtn=assignPopup.querySelector(".ab-cv-popup-add-btn");val&&!exactMatch&&val!=="narrator"?(addBtn||(addBtn=document.createElement("div"),addBtn.className="ab-cv-popup-add-btn",addBtn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--primary); border-top:1px solid var(--border); margin-top:4px; padding-top:8px;",addBtn.onmouseover=()=>addBtn.style.background="var(--panel)",addBtn.onmouseout=()=>addBtn.style.background="transparent"),addBtn.innerHTML=`+ Add "${escHtml(inp.value.trim())}"`,addBtn.onclick=()=>{var _a3;return(_a3=assignPopup._assignName)==null?void 0:_a3.call(assignPopup,inp.value.trim())},addBtn.style.display="flex",assignPopup.querySelector(".ab-cv-popup-list").appendChild(addBtn)):addBtn&&(addBtn.style.display="none")})}function closeAssignPopup(){assignPopup.style.display="none",assignModeRow&&assignModeRow.classList.remove("is-assigning"),assignModeSeg=null,assignModeRow=null}function assignName(name){if(!assignModeSeg)return;const active=_abActiveSegments();_abPushEditState(active.key,active.arr);const isNarrator=name.toLowerCase()==="narrator",_oldSpk=assignModeSeg.speaker;if(_oldSpk&&roster.has(_oldSpk)){const _old=roster.get(_oldSpk);_old.count>0&&_old.count--}assignModeSeg.speaker=isNarrator?"Narrator":name,assignModeSeg.type=isNarrator?"narration":"dialogue",isNarrator&&(assignModeSeg.emotion=""),assignModeRow.classList.remove("is-assigning");const speakerName=isNarrator?"Narrator":name,c=colorFor(speakerName);roster.has(speakerName)||roster.set(speakerName,{count:0,color:c}),roster.get(speakerName).count++,assignModeRow.className="ab-cv-row"+(isNarrator?" is-narr":""),assignModeRow.querySelector(".ab-cv-spk").innerHTML=`${escHtml(speakerName)}${assignModeSeg.emotion?' ('+escHtml(assignModeSeg.emotion)+")":""}`,assignModeRow.querySelector(".ab-cv-spk").style.color=c,assignModeRow.querySelector(".ab-cv-spk").title="Click to assign character",_abRecountRoster(_abActiveSegments().arr),toast(`Assigned to ${isNarrator?"Narrator":name}`,"success"),closeAssignPopup(),_abPersistManualEdit()}function _abOpenAssignPopup(row,anchorEl,prefill){var _a3;if(!row||!row.__seg||row.classList.contains("is-processing"))return;assignModeRow&&assignModeRow.classList.remove("is-assigning"),assignModeSeg=row.__seg,assignModeRow=row,row.classList.add("is-assigning"),assignPopup._assignName=assignName,assignPopup._close=closeAssignPopup,window.getSelection().removeAllRanges(),assignPopup.style.display="flex";const rect=anchorEl.getBoundingClientRect(),top=Math.min(rect.bottom+4,window.innerHeight-300);assignPopup.style.top=top+"px",assignPopup.style.left=rect.left+"px";const list=assignPopup.querySelector(".ab-cv-popup-list");list.innerHTML="";const narrBtn=document.createElement("div");narrBtn.dataset.name="Narrator",narrBtn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;",narrBtn.innerHTML='\u{1F4D6} Narrator',narrBtn.onmouseover=()=>narrBtn.style.background="var(--panel)",narrBtn.onmouseout=()=>narrBtn.style.background="transparent",narrBtn.onclick=()=>assignName("Narrator"),list.appendChild(narrBtn);const items=[...roster.entries()].sort((a,b)=>b[1].count-a[1].count);for(const[n,info]of items){const btn=document.createElement("div");btn.dataset.name=n,btn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);";const img=(_a3=recordForName(n))==null?void 0:_a3.image;btn.innerHTML=(img?``:``)+` ${escHtml(n)}`,btn.onmouseover=()=>btn.style.background="var(--panel)",btn.onmouseout=()=>btn.style.background="transparent",btn.onclick=()=>assignName(n),list.appendChild(btn)}const inp=assignPopup.querySelector("input");inp.value=prefill||"",setTimeout(()=>{inp.focus(),prefill&&inp.dispatchEvent(new Event("input"))},50)}const _abIsUnknownSeg=s=>!(s!=null&&s.speaker)||/^Unknown|Unbekannt/i.test(s.speaker),_abIsNarrSeg=s=>(s==null?void 0:s.type)!=="dialogue"||!s.speaker||/^Narrator$/i.test(s.speaker),_abMergedSegment=(a,b)=>{var _a3;let base=a;(_abIsUnknownSeg(a)&&!_abIsUnknownSeg(b)||_abIsNarrSeg(a)&&!_abIsNarrSeg(b))&&(base=b);const type=_abIsNarrSeg(base)?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":base.speaker||"Unknown",type,emotion:type==="dialogue"&&(base.emotion||a.emotion||b.emotion)||"",text:audiobookJoinSegmentText(a.text||"",b.text||""),page:(_a3=a.page)!=null?_a3:b.page}},_abMergeByRow=(row,dir)=>{const active=_abActiveSegments(),idx=active.arr.indexOf(row==null?void 0:row.__seg);if(idx<0)return;const leftIdx=dir<0?idx-1:idx,rightIdx=dir<0?idx:idx+1;if(leftIdx<0||rightIdx>=active.arr.length){toast("No adjacent segment to merge","info");return}const leftRow=dir<0?_abAdjacentRow(row,-1):row,rightRow=dir<0?row:_abAdjacentRow(row,1);_abPushEditState(active.key,active.arr);const merged=_abMergedSegment(active.arr[leftIdx],active.arr[rightIdx]);active.arr.splice(leftIdx,2,merged),active.key==="segments"?_audiobook.segments=active.arr:_audiobook.liveSegments=active.arr,!leftRow||!rightRow||leftRow.parentNode!==rightRow.parentNode?_abRedrawSegmentsChunked(active.arr):_abPatchRowRange([leftRow,rightRow],[merged],active.arr)||_abRedrawSegmentsChunked(active.arr),_abPersistManualEdit(),toast("Segments merged","success")};feed.addEventListener("click",e=>{var _a3;const pageLabel=e.target.closest(".ab-cv-page-label[data-page]");if(pageLabel){e.preventDefault(),e.stopPropagation(),_abJumpToReaderPage(pageLabel.dataset.page);return}const editBtn=e.target.closest(".ab-cv-edit-text");if(editBtn){e.preventDefault(),e.stopPropagation(),_abStartRowEdit(editBtn.closest(".ab-cv-row"));return}const mergeBtn=e.target.closest(".ab-cv-row-tool");if(mergeBtn){e.preventDefault(),e.stopPropagation(),_abMergeByRow(mergeBtn.closest(".ab-cv-row"),mergeBtn.classList.contains("ab-cv-merge-prev")?-1:1);return}const spk=e.target.closest(".ab-cv-spk");if(spk){_abOpenAssignPopup(spk.closest(".ab-cv-row"),spk,"");return}if(_abJustHandledSelection){_abJustHandledSelection=!1;return}const txtEl=e.target.closest(".ab-cv-txt");if(txtEl&&window.getSelection().isCollapsed){if(!assignModeRow)return;const row=txtEl.closest(".ab-cv-row"),nameHit=e.target.closest(".ab-name-hit"),clickedWord=nameHit?nameHit.dataset.name||nameHit.textContent.trim():(_a3=_abWordRangeAtPoint(e.clientX,e.clientY))==null?void 0:_a3.word;if(assignModeRow!==row){if(clickedWord){const inp=assignPopup.querySelector("input");inp.value=clickedWord,inp.dispatchEvent(new Event("input")),inp.focus()}return}if(nameHit)_abOpenAssignPopup(row,nameHit,clickedWord);else{const hit=_abWordRangeAtPoint(e.clientX,e.clientY);if(hit){const rect=hit.range.getBoundingClientRect();_abOpenAssignPopup(row,{getBoundingClientRect:()=>rect},hit.word)}}}});let _abJustHandledSelection=!1,_abHoverHL=document.getElementById("ab-word-hover");_abHoverHL||(_abHoverHL=document.createElement("div"),_abHoverHL.id="ab-word-hover",_abHoverHL.className="ab-word-hover",_abHoverHL.hidden=!0,document.body.appendChild(_abHoverHL));let _abHoverRaf=0,_abHoverPt=null;const _abHideHoverHL=()=>{_abHoverHL.hidden=!0};feed.addEventListener("mousemove",e=>{_abHoverPt={x:e.clientX,y:e.clientY,target:e.target},!_abHoverRaf&&(_abHoverRaf=requestAnimationFrame(()=>{var _a3;_abHoverRaf=0;const pt=_abHoverPt;if(!pt||!((_a3=pt.target)!=null&&_a3.closest))return;if(!pt.target.closest(".ab-cv-txt")||pt.target.closest(".ab-name-hit")||feed.querySelector(".ab-cv-edit-ta")){_abHideHoverHL();return}const hit=_abWordRangeAtPoint(pt.x,pt.y);if(!hit){_abHideHoverHL();return}const r=hit.range.getBoundingClientRect();if(!r.width){_abHideHoverHL();return}_abHoverHL.style.left=r.left-2+"px",_abHoverHL.style.top=r.top-1+"px",_abHoverHL.style.width=r.width+4+"px",_abHoverHL.style.height=r.height+2+"px",_abHoverHL.hidden=!1}))}),feed.addEventListener("mouseleave",_abHideHoverHL),feed.addEventListener("scroll",_abHideHoverHL,{passive:!0}),feed.addEventListener("dblclick",e=>{const hit=e.target.closest(".ab-name-hit");if(!hit)return;const row=hit.closest(".ab-cv-row"),name=hit.dataset.name;row&&name&&(_abOpenAssignPopup(row,hit,""),assignName(name))});let splitBtn=document.getElementById("ab-cv-split-btn");splitBtn||(splitBtn=document.createElement("button"),splitBtn.id="ab-cv-split-btn",splitBtn.className="btn-secondary btn-sm",splitBtn.innerHTML=' Split text to Unknown Speaker',splitBtn.style.cssText="position:fixed; z-index:2000; display:none; background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px rgba(0,0,0,0.3);",document.body.appendChild(splitBtn));let currentSplitState=null;const _abOffsetInEl=(el,node,offset)=>{const r=document.createRange();return r.selectNodeContents(el),r.setEnd(node,offset),r.toString().length};return document.addEventListener("selectionchange",()=>{if(!feed)return;const sel=window.getSelection();if(sel.isCollapsed||!feed.contains(sel.anchorNode)){splitBtn&&(splitBtn.style.display="none"),currentSplitState=null;return}const txtSpan=sel.anchorNode.nodeType===3?sel.anchorNode.parentNode.closest(".ab-cv-txt"):sel.anchorNode.closest(".ab-cv-txt");if(!txtSpan){splitBtn.style.display="none";return}const row=txtSpan.closest(".ab-cv-row");if(!row||!row.__seg)return;const rawText=sel.toString(),text=rawText.trim();if(text.length>0){if(assignModeSeg&&text.length<40&&!text.includes(` +`)){splitBtn.style.display="none";return}const range=sel.getRangeAt(0),rect=range.getBoundingClientRect();splitBtn.style.display="block",splitBtn.style.top=rect.bottom+8+"px",splitBtn.style.left=Math.max(10,rect.left+rect.width/2-100)+"px";const startOffset=_abOffsetInEl(txtSpan,range.startContainer,range.startOffset),endOffset=_abOffsetInEl(txtSpan,range.endContainer,range.endOffset);currentSplitState={row,text,rawText,seg:row.__seg,startOffset,endOffset}}else splitBtn.style.display="none",currentSplitState=null}),splitBtn.addEventListener("click",()=>{if(!currentSplitState)return;const{row,seg,startOffset,endOffset}=currentSplitState,fullText=seg.text||"",before=fullText.slice(0,startOffset),selectedText=fullText.slice(startOffset,endOffset),after=fullText.slice(endOffset);if(!selectedText)return;let arr=_audiobook.segments||[],globalIdx=arr.indexOf(seg);globalIdx===-1&&Array.isArray(_audiobook.liveSegments)&&(arr=_audiobook.liveSegments,globalIdx=arr.indexOf(seg)),globalIdx!==-1&&_abPushEditState(arr===_audiobook.liveSegments?"liveSegments":"segments",arr);const newSegs=[];if(before.length&&newSegs.push({speaker:seg.speaker,type:seg.type,emotion:seg.emotion,text:before,page:seg.page}),newSegs.push({speaker:"Unknown",type:"dialogue",emotion:"",text:selectedText,page:seg.page}),after.length&&newSegs.push({speaker:seg.speaker,type:seg.type,emotion:seg.emotion,text:after,page:seg.page}),globalIdx!==-1)arr.splice(globalIdx,1,...newSegs),_abPatchRowRange([row],newSegs,arr)||_abRedrawSegments(arr),_abPersistManualEdit();else{console.warn("[audiobook] split during casting: DOM-only, segment not yet in array");const frag=document.createDocumentFragment();for(const s of newSegs)frag.appendChild(_abRowFromSegment(s));row.parentNode.insertBefore(frag,row),row.remove()}splitBtn.style.display="none",window.getSelection().removeAllRanges(),toast("Segment split! You can now assign a character to the Unknown block.","success")}),feed.addEventListener("mouseup",()=>{const sel=window.getSelection();if(sel.isCollapsed||!feed.contains(sel.anchorNode))return;const text=sel.toString().trim();!text||text.length>=40||text.includes(` +`)||/^[»„“"'‘]/.test(text)||assignModeSeg&&(_abJustHandledSelection=!0,assignName(text),sel.removeAllRanges())}),chars.addEventListener("click",e=>{const chip=e.target.closest(".ab-chip");if(chip){let name="";for(const n of chip.childNodes)n.nodeType===3&&(name+=n.nodeValue);if(name=name.trim(),assignModeSeg)name&&assignName(name);else if(name){const rows=Array.from(feed.querySelectorAll(".ab-cv-row")).filter(r=>r.__seg&&r.__seg.speaker===name);if(!rows.length)return;const currentY=feed.scrollTop;let target=rows.find(r=>r.offsetTop-feed.offsetTop>currentY+10);target||(target=rows[0]),_abScrollIntoView(target,{block:"center"}),target.style.transition="background 0.3s",target.style.background="var(--accent-hover, rgba(100, 150, 255, 0.2))",setTimeout(()=>{target.parentNode&&(target.style.background="")},1e3)}}}),{recountRoster(allSegs){_abRecountRoster(allSegs||[])},rebuild(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),_abRedrawSegmentsChunked(segs||[])},update(done){const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!1);const pct=Math.round(done/total*100);fill&&(fill.style.width=pct+"%"),count&&(count.hidden=!1,count.textContent=`passage ${done} / ${total}`);const fillText=panel.querySelector("#ab-cv-fill-text");fillText&&(fillText.textContent=`${pct}% (Passage ${done} of ${total})`)},processing(text){this._procRow&&this._procRow.remove(),this._thinkRaw="",this._thinkDone=!1,this._procRow=document.createElement("div"),this._procRow.className="ab-cv-row is-processing ab-cv-llm-row";const isLong=text.length>160,preview=escHtml(text.slice(0,160))+(isLong?"\u2026":"");this._procRow.innerHTML=`
LLM Reading\u2026 @@ -1387,66 +1401,73 @@ ${newRules}`}return p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.

           
- `,this._procRow.querySelector(".ab-cv-expand-btn").addEventListener("click",function(){const split=this.closest(".ab-cv-llm-row").querySelector(".ab-cv-llm-split"),open=this.getAttribute("aria-expanded")==="true";this.setAttribute("aria-expanded",String(!open)),split.hidden=open;const icon=this.querySelector("span");icon&&(icon.className=open?"mdi mdi-chevron-right":"mdi mdi-chevron-down")}),_abPage().appendChild(this._procRow),trim()},thinking(delta){if(!this._procRow||!delta||this._thinkDone)return;this._thinkRaw=(this._thinkRaw||"")+delta;const think=this._procRow.querySelector(".ab-cv-think"),label=this._procRow.querySelector(".ab-cv-think-label");if(!think)return;const openAt=this._thinkRaw.indexOf("");let shown,isReal=!1,closeAt=-1;if(openAt>=0){const afterOpen=this._thinkRaw.slice(openAt+7);closeAt=afterOpen.indexOf(""),shown=closeAt>=0?afterOpen.slice(0,closeAt):afterOpen,isReal=!0}else shown=this._thinkRaw;label&&(label.innerHTML=isReal?' LLM Thinking\u2026':' Live Output (raw \u2014 no reasoning exposed)'),think.textContent=shown.trim().slice(-2e4),think.scrollTop=think.scrollHeight,closeAt>=0&&(this._thinkDone=!0)},clearProcessing(){this._procRow&&(this._procRow.remove(),this._procRow=null)},addSegments(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const makeRow=s=>{const{speakerName}=_abRowSpeaker(s);return colorFor(speakerName),roster.get(speakerName).count++,_abRowFromSegment(s)},CHUNK=80,first=segs.slice(0,CHUNK),frag0=document.createDocumentFragment();if(first.forEach(s=>frag0.appendChild(makeRow(s))),_abPage().appendChild(frag0),renderRoster(),segs.length>CHUNK){let i=CHUNK;const next=()=>{if(i>=segs.length){trim(),renderRoster();return}const batch=document.createDocumentFragment(),end=Math.min(i+CHUNK,segs.length);for(;i=0?prevIdx+1:0),segsAll=_audiobook.segments||[],gapTo=Math.min(segsAll.length-1,nextIdx>=0?nextIdx-1:segsAll.length-1),gapCount=Math.max(0,gapTo-gapFrom+1);r.innerHTML=`\u22EF ${gapCount>0?gapCount+" line"+(gapCount!==1?"s":"")+" hidden \u2014 ":""}click to expand`,r.title="Click to reveal the text between these passages",r.addEventListener("click",()=>{const gap=segsAll.slice(gapFrom,gapTo+1);if(!gap.length){r.remove();return}const PEEK=20,show=gap.slice(0,PEEK),rest=gap.slice(PEEK),frag=document.createDocumentFragment();for(const s of show){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,frag.appendChild(row)}if(rest.length){const next=document.createElement("div");next.className="ab-cv-divider ab-cv-divider-expand",next.innerHTML=`\u22EF ${rest.length} line${rest.length!==1?"s":""} hidden \u2014 click to expand`,next.title="Click to reveal more";const restSegs=rest;next.addEventListener("click",function(){const f2=document.createDocumentFragment();for(const s of restSegs){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,f2.appendChild(row)}next.replaceWith(f2)}),frag.appendChild(next)}r.replaceWith(frag)}),_abPage().appendChild(r),trim()},pagemark(pageNum){this.clearProcessing(),_abNewPage("Page "+pageNum,pageNum),trim()},complete(summary,onOpen,onRecast,onRecastUnknown){var _a3,_b3;count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0);const completedSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length?_audiobook.segments:typeof allSegments!="undefined"&&allSegments.length?allSegments:null;completedSegments&&_abRedrawSegments(completedSegments);const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.hidden=!0);const foot=panel.querySelector("#ab-cv-foot");foot.hidden=!1;const sideEl=panel.querySelector(".ab-cv-side");if(sideEl){let doneEl=sideEl.querySelector(".ab-cv-side-done");doneEl||(doneEl=document.createElement("div"),doneEl.className="ab-cv-side-done",sideEl.appendChild(doneEl)),doneEl.innerHTML=` ${escHtml(summary)}`}const continueBtnHtml=_audiobook.completedChunks>0&&_audiobook.completedTotal>0&&_audiobook.completedChunks<_audiobook.completedTotal?``:"";foot.innerHTML=`${continueBtnHtml}`;const runVerificationPass=()=>{const verificationPrompt=`Du bist ein Qualit\xE4tspr\xFCfer f\xFCr die Analyse eines deutschen H\xF6rbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu l\xF6sen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unn\xF6tig zu ver\xE4ndern. + `,this._procRow.querySelector(".ab-cv-expand-btn").addEventListener("click",function(){const split=this.closest(".ab-cv-llm-row").querySelector(".ab-cv-llm-split"),open=this.getAttribute("aria-expanded")==="true";this.setAttribute("aria-expanded",String(!open)),split.hidden=open;const icon=this.querySelector("span");icon&&(icon.className=open?"mdi mdi-chevron-right":"mdi mdi-chevron-down")}),_abPage().appendChild(this._procRow),trim()},thinking(delta){if(!this._procRow||!delta||this._thinkDone)return;this._thinkRaw=(this._thinkRaw||"")+delta;const think=this._procRow.querySelector(".ab-cv-think"),label=this._procRow.querySelector(".ab-cv-think-label");if(!think)return;const openAt=this._thinkRaw.indexOf("");let shown,isReal=!1,closeAt=-1;if(openAt>=0){const afterOpen=this._thinkRaw.slice(openAt+7);closeAt=afterOpen.indexOf(""),shown=closeAt>=0?afterOpen.slice(0,closeAt):afterOpen,isReal=!0}else shown=this._thinkRaw;label&&(label.innerHTML=isReal?' LLM Thinking\u2026':' Live Output (raw \u2014 no reasoning exposed)'),think.textContent=shown.trim().slice(-2e4),think.scrollTop=think.scrollHeight,closeAt>=0&&(this._thinkDone=!0)},clearProcessing(){this._procRow&&(this._procRow.remove(),this._procRow=null)},addSegments(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const makeRow=s=>{const{speakerName}=_abRowSpeaker(s);return colorFor(speakerName),roster.get(speakerName).count++,_abRowFromSegment(s)},CHUNK=80,first=segs.slice(0,CHUNK),frag0=document.createDocumentFragment();if(first.forEach(s=>frag0.appendChild(makeRow(s))),_abPage().appendChild(frag0),renderRoster(),segs.length>CHUNK){let i=CHUNK;const next=()=>{if(i>=segs.length){trim(),renderRoster();return}const batch=document.createDocumentFragment(),end=Math.min(i+CHUNK,segs.length);for(;i=0?prevIdx+1:0),segsAll=_audiobook.segments||[],gapTo=Math.min(segsAll.length-1,nextIdx>=0?nextIdx-1:segsAll.length-1),gapCount=Math.max(0,gapTo-gapFrom+1);r.innerHTML=`\u22EF ${gapCount>0?gapCount+" line"+(gapCount!==1?"s":"")+" hidden \u2014 ":""}click to expand`,r.title="Click to reveal the text between these passages",r.addEventListener("click",()=>{const gap=segsAll.slice(gapFrom,gapTo+1);if(!gap.length){r.remove();return}const PEEK=20,show=gap.slice(0,PEEK),rest=gap.slice(PEEK),frag=document.createDocumentFragment();for(const s of show){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,frag.appendChild(row)}if(rest.length){const next=document.createElement("div");next.className="ab-cv-divider ab-cv-divider-expand",next.innerHTML=`\u22EF ${rest.length} line${rest.length!==1?"s":""} hidden \u2014 click to expand`,next.title="Click to reveal more";const restSegs=rest;next.addEventListener("click",function(){const f2=document.createDocumentFragment();for(const s of restSegs){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,f2.appendChild(row)}next.replaceWith(f2)}),frag.appendChild(next)}r.replaceWith(frag)}),_abPage().appendChild(r),trim()},pagemark(pageNum){this.clearProcessing(),_abNewPage("Page "+pageNum,pageNum),trim()},complete(summary,onOpen,onRecast,onRecastUnknown){var _a3,_b3;count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0);const completedSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length?_audiobook.segments:typeof allSegments!="undefined"&&allSegments.length?allSegments:null;completedSegments&&_abRedrawSegmentsChunked(completedSegments);const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.hidden=!0);const foot=panel.querySelector("#ab-cv-foot");foot.hidden=!1;const sideEl=panel.querySelector(".ab-cv-side");if(sideEl){let doneEl=sideEl.querySelector(".ab-cv-side-done");doneEl||(doneEl=document.createElement("div"),doneEl.className="ab-cv-side-done",sideEl.appendChild(doneEl)),doneEl.innerHTML=` ${escHtml(summary)}`}const continueBtnHtml=_audiobook.completedChunks>0&&_audiobook.completedTotal>0&&_audiobook.completedChunks<_audiobook.completedTotal?``:"";foot.innerHTML=`${continueBtnHtml}`;const runVerificationPass=()=>{const verificationPrompt=`Du bist ein Qualit\xE4tspr\xFCfer f\xFCr die Sprecherzuordnung eines bereits analysierten deutschen H\xF6rbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT \u2014 du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters geh\xF6rt, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer k\xF6nnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erz\xE4hltext hineingezogen?". -AUFGABE (2. Qualit\xE4tslauf): -1. L\xD6SE 'Unknown'-Segmente auf: Nutze umgebenden Kontext, Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, Reihenfolge der Sprecher, Ping-Pong-Wechsel in Dialogen und bekannte Figuren. -2. KORRIGIERE ein Segment zu 'Narrator' nur dann, wenn es eindeutig Erz\xE4hlertext, Handlung, Beschreibung oder ein Sprecher-Tag ist. -3. BEHALTE vorhandene klare Sprecher-Zuweisungen im Kontext bei. Nutze sie als Anker f\xFCr die Unknown-Zeilen. -4. 'Unknown' ist NUR erlaubt, wenn der Sprecher trotz Kontext absolut nicht bestimmbar ist. +WORAUF DU PR\xDCFST \u2014 IN DIESER REIHENFOLGE (h\xF6chste Priorit\xE4t zuerst): +1. ZUERST alle 'Unknown'-Zeilen: L\xF6se den Sprecher \xFCber Kontext auf (Inquit-Formeln, Ping-Pong-Wechsel in Zwiegespr\xE4chen, Adressaten-Bezug, zuletzt genannte Person). 'Unknown' bleibt nur, wenn wirklich keine Ableitung m\xF6glich ist. Das ist die dringendste Kategorie \u2014 eine Zeile ohne jeden Sprecher ist schlimmer als eine falsch zugeordnete. +2. DANACH Zeilenketten mit demselben Sprecher in Folge (2 oder mehr aufeinanderfolgende Zeilen \u2014 egal ob 'Narrator' oder ein Charakter): Das ist die zweith\xE4ufigste Fehlerquelle. Pr\xFCfe jede Zeile in einer solchen Kette einzeln: Ist das wirklich durchgehend derselbe Sprecher, oder wurde eine Sprecherwechsel-Zeile (z.B. eine Antwort einer anderen Figur, oder gesprochene Rede ohne erhaltene Anf\xFChrungszeichen) f\xE4lschlich in die Kette hineingezogen? Pr\xFCfe besonders bei 'Narrator'-Ketten: Ist wirklich jede Zeile neutrale Erz\xE4hlerbeschreibung, oder ist tats\xE4chlich eine Aussage dabei, die ein Charakter so gesagt haben k\xF6nnte \u2014 nur ohne erhaltene Anf\xFChrungszeichen (typisch bei PDF/OCR-Extraktion)? Pr\xFCfe Tonfall, Wortwahl und Perspektive: Klingt eine der Zeilen wie eine direkte Aussage/Reaktion einer Person in der Szene (Ich-Form, Anrede, Ausruf, Frage), nicht wie neutrale Erz\xE4hlerbeschreibung? Dann ist SIE Dialog, nicht Narration \u2014 auch wenn keine \xBB \xAB vorhanden sind, und auch wenn die Zeilen davor/danach in derselben Kette echt narrativ sind. +3. ZULETZT ein allgemeiner Plausibilit\xE4ts-Check bei allen \xFCbrigen, bereits vermuteten Sprechern: Passt Tonfall/Wortwahl der Zeile zu dem, was \xFCber diese Figur im bisherigen Text bekannt ist (Sprechweise, Haltung, Beziehung zu anderen Figuren)? Wenn eine Zeile im Kontext eindeutig zu einer ANDEREN, im Text erkennbaren Person passt, korrigiere sie dorthin. +4. Echte Narration NICHT anfassen: Reine Beschreibung, Handlung, \xDCberg\xE4nge, Kapitelanf\xE4nge bleiben 'Narrator'. Nur weil eine Figur erw\xE4hnt wird, wird der Satz nicht zu ihrem Dialog. -DEDUKTIONS-WERKZEUGE (wende sie in dieser Reihenfolge an): -- Doppelpunkt-Regel: Endet der Erz\xE4hlersatz vor dem Zitat mit ":", spricht dessen Subjekt das Zitat ("Dann richtete er sich auf und rief in die Runde:" \u2192 der zuvor genannte Charakter; "Ein anderer fragte verschlafen:" \u2192 dieser andere). -- Nachgestellte Zuordnung: Der Erz\xE4hlersatz NACH dem Zitat verr\xE4t den Sprecher \u2014 auch bei unpers\xF6nlicher Formel ("\xBBWas machst du denn da?\xAB ert\xF6nte es \xFCber ihm. Karyla hatte ihren Hammer weggelegt und war her\xFCbergekommen." \u2192 Karyla sprach). -- Pronomen-Aufl\xF6sung: er/sie/es in Inquit-Formeln und Action Beats meint die zuletzt genannte Person passenden Geschlechts ("Mit einem Sto\xDF schob sie Uriens zur Seite" \u2192 sie = die zuletzt genannte Frau, und die umliegenden Zitate sind ihre). -- Adressaten-Regel: "X wandte sich an Y" \u2192 X spricht das n\xE4chste Zitat, Y ist der wahrscheinlichste Antwortende. -- Ping-Pong-Prinzip: Zwei Personen im Gespr\xE4ch wechseln sich strikt ab \u2014 auch \xFCber viele Zitate ohne Tags hinweg. Verfolge die Kette zur letzten eindeutigen Nennung zur\xFCck und f\xFChre sie fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch. -- Rollenbezeichnungen sind g\xFCltige Sprecher \u2014 nutze sie statt 'Unknown' (z.B. 'Ork', 'Der Fremde', 'Nachbar', 'W\xE4chter', 'Junge'). - -DIALOG-ERKENNUNG BEI PDF/OCR-TEXTEN: -Viele PDF-Extraktionen verlieren Anf\xFChrungszeichen oder Guillemets. Ein kurzer Satz kann also trotzdem Dialog sein, auch wenn \xBB...\xAB oder \u201E..." im \xFCbergebenen Segment fehlen. Entscheide nach Satzform, Antwortstruktur, Sprecherwechsel, Inquit-Formeln und Szene. Markiere eine Zeile NICHT allein deshalb als Narration, weil sichtbare Anf\xFChrungszeichen fehlen. - -ABSATZ- UND KAPITELSTRUKTUR: -Eine Leerzeile markiert einen Absatzwechsel oder Kapitel-/Szenenanfang. Eine sehr kurze, alleinstehende Zeile vor einer Leerzeile ist eine Kapitel\xFCberschrift \u2014 'narration'/'Narrator', niemals Dialog. - -GRAMMATIK-CHECK F\xDCR NARRATION: -- Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, fl\xFCsterte, schrie, antwortete + Subjekt/Pronomen/Name. -- Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration. -- Action Beats sind narration: blickte, ging, schwieg, lachte, hob die Hand, wandte sich ab, usw. -- Nur die tats\xE4chlich gesprochenen W\xF6rter innerhalb der Anf\xFChrungszeichen bleiben dialogue; alle grammatischen Rahmen- und Berichtss\xE4tze sind narration. +DEDUKTIONS-WERKZEUGE f\xFCr die Sprecherzuordnung: +- Doppelpunkt-Regel: Endet der Satz davor mit ":", spricht dessen Subjekt das Folgende. +- Nachgestellte Zuordnung: Folgt einer als 'Narrator' markierten Zeile direkt eine kurze Inquit-Formel (Sprechverb + Name/Pronomen, z.B. "entgegnete Oberst von Blautann.", ", meldete sich Lysandra zu Wort.", "murmelte er."), dann war die VORHERIGE Zeile in Wahrheit das Zitat dieser Person \u2014 auch ohne Anf\xFChrungszeichen. Das ist der h\xE4ufigste Fehler der ersten Zuordnung: gesprochene Rede wird f\xE4lschlich als Narration markiert, weil die Anf\xFChrungszeichen bei der Extraktion verloren gingen. +- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile handelt (aufblickt, sich umdreht, den Kopf sch\xFCttelt), ist der wahrscheinlichste Sprecher dieser Zeile. +- Ping-Pong-Prinzip: Zwei Personen im Gespr\xE4ch wechseln sich strikt ab, auch ohne Tags \u2014 verfolge die Kette zur letzten eindeutigen Nennung zur\xFCck. +- Pronomen-Aufl\xF6sung: er/sie/es meint die zuletzt genannte Person passenden Geschlechts. +- Rollenbezeichnungen sind g\xFCltige Sprecher (z.B. 'Ork', 'Der Fremde', 'W\xE4chter') statt 'Unknown'. F\xDCR JEDES SEGMENT AUSGABE: -- speaker: 'Narrator' f\xFCr Narration, oder EXAKT der Name des Charakters. +- speaker: 'Narrator' f\xFCr Narration, oder EXAKT der Name des Charakters (nutze den in der bekannten Charakterliste gef\xFChrten Namen, nicht einen Spitznamen/Alias, falls der Kontext eindeutig zuordnet). - type: 'narration' oder 'dialogue' - text: EXAKT der WORTW\xD6RTLICHE Originaltext \u2014 KEINE \xC4nderungen, KEINE Auslassungen, KEINE Erg\xE4nzungen. - emotion: Bei Dialogen 1-2 deutsche W\xF6rter f\xFCr den Tonfall. Bei Narration leer (''). ABSOLUTE REGELN: - Alle Segmente zusammen M\xDCSSEN den Originaltext exakt, l\xFCckenlos und wortgetreu rekonstruieren. -- PDF-/OCR-SCHUTZ: Ein fehlendes \xBB oder \xAB darf niemals bewirken, dass die restliche Passage als Dialog markiert wird. Bei einem offenen \xBB-Zitat vor einer Inquit-Formel oder Erz\xE4hlerhandlung endet der Dialog am ersten plausiblen Satzende (? ! .). Bei einem einzelnen schlie\xDFenden \xAB nach einem kurzen Satz ist dieser Satz davor der Dialog. - Erfinde NIEMALS Text. Lasse NIEMALS W\xF6rter weg. F\xFCge NIEMALS etwas hinzu. -- Mische NIEMALS Narration und Dialog in einem Segment.`,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);closePanel(),audiobookRecastUnknown(savedChoice.url,savedChoice.model,{prompt:verificationPrompt})};foot.querySelector("#ab-cv-verify").addEventListener("click",runVerificationPass),foot.querySelector("#ab-cv-open-reh").addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()}),(_a3=foot.querySelector("#ab-cv-export-md"))==null||_a3.addEventListener("click",audiobookExportCastMd);const applyPromptAndRun2=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{closePanel(),callback&&callback(savedChoice.url,savedChoice.model)})};foot.querySelector("#ab-cv-recast").addEventListener("click",()=>applyPromptAndRun2(audiobookCast)),foot.querySelector("#ab-cv-recast-unk").addEventListener("click",()=>applyPromptAndRun2(audiobookRecastUnknown)),(_b3=foot.querySelector("#ab-cv-continue"))==null||_b3.addEventListener("click",()=>applyPromptAndRun2((u,m)=>audiobookCast(u,m,{startIndex:_audiobook.completedChunks,segments:_audiobook.segments,roster:_audiobook.roster,narrationOnly:_audiobook.narratedPassages,degraded:_audiobook.degraded}))),foot.querySelector("#ab-cv-cast-chars").addEventListener("click",()=>{typeof window.csForReader=="function"?window.csForReader():typeof csForReader=="function"?csForReader():toast("Character sheets not loaded yet","error")}),(async()=>{var _a4;const bookTitle=((_a4=window.readerState)==null?void 0:_a4.title)||"";if(!bookTitle||typeof clGetAllByTagOrBook!="function")return;let existing=[];try{existing=await clGetAllByTagOrBook(bookTitle)}catch{}if(!existing.length)return;const castBtn=foot.querySelector("#ab-cv-cast-chars");if(!castBtn)return;const wrap=document.createElement("span");wrap.className="ab-cv-castchars-group",wrap.innerHTML=``,castBtn.replaceWith(wrap),wrap.querySelector(".ab-cv-castchars-view").addEventListener("click",()=>{typeof navTo=="function"&&navTo("s-library"),typeof navLibraryView=="function"&&navLibraryView("characters")}),wrap.querySelector(".ab-cv-castchars-menu").addEventListener("click",e=>{e.stopPropagation(),_abOpenRecastCharsMenu(e.currentTarget,bookTitle,existing)})})(),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},done(options={}){var _a3,_b3,_c3,_d3,_e3;if(!(options.stopped||_audiobook.cancel)){closePanel();return}this.clearProcessing(),count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const message=options.message||"Casting stopped before any passage completed.";if(feed.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page")){if(options.message){const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}}else{feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No completed characters yet.');const foot=panel.querySelector("#ab-cv-foot"),hasSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length>0;foot.hidden=!1,foot.innerHTML=` ${escHtml(message)}${hasSegments?'':""}`,(_c3=foot.querySelector("#ab-cv-stopped-back"))==null||_c3.addEventListener("click",closePanel),(_d3=foot.querySelector("#ab-cv-stopped-review"))==null||_d3.addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()}),(_e3=foot.querySelector("#ab-cv-stopped-recast"))==null||_e3.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt||""})}).finally(()=>{closePanel(),audiobookCast(choice.url,choice.model)})}),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},loadingRestore(){var _a3,_b3;(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent="Loading saved cast\u2026",feed.appendChild(r),chars.innerHTML='restoring\u2026';const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent="Checking saved cast before starting a new one.");const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="none")},setFreshState(message="Ready to cast."){var _a3,_b3;if((_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),!feed.querySelector(".ab-cv-row, .ab-cv-divider, .ab-cv-page")){feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No saved cast found.');const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent=message);const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),audiobookCast(choice.url,choice.model)}))}}}async function audiobookRecastUnknown(overrideUrl,overrideModel,options={}){var _a2,_b2,_c2,_d2,_e2;if(_audiobook.running)return;const segs=_audiobook.segments;if(!segs||!segs.length)return;const originalSegments=segs.map(s=>({...s})),countUnknownDialogue=arr=>(arr||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length,beforeUnknownCount=countUnknownDialogue(segs),preResolved=audiobookResolveUnknowns(segs,[],_audiobook.roster||[]);preResolved.length&&toast(`${preResolved.length} Unknown line${preResolved.length!==1?"s":""} resolved by grammar rules`,"success");const unknownIdxs=[];for(let i=0;iac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang();let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const promptOverride=typeof options.prompt=="string"?options.prompt:null,groups=audiobookRecastGroups(unknownIdxs,segs),view=audiobookCastView(unknownIdxs.length,llm_url,model);view.recountRoster(segs),view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_a2=document.getElementById("ab-cv-llm-url"))==null?void 0:_a2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b2=document.getElementById("ab-cv-llm-select"))==null?void 0:_b2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Character definition stopped before any lines were updated."}),toast("Character definition stopped. Existing cast preserved.","info");return}}let done=0,prevIdx=-2,groupsSinceSave=0;const pendingReplacements=new Map,normalizeRecastSegment=(seg,fallback)=>{const type=(seg==null?void 0:seg.type)==="narration"?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":(seg==null?void 0:seg.speaker)||(fallback==null?void 0:fallback.speaker)||"Unknown",type,emotion:type==="dialogue"&&((seg==null?void 0:seg.emotion)||(fallback==null?void 0:fallback.emotion))||"",text:(seg==null?void 0:seg.text)||(fallback==null?void 0:fallback.text)||""}};try{for(let group of groups){if(_audiobook.cancel)break;const unresolvedGroup=[];for(const idx of group)audiobookIsSpeechTagOnly((_c2=segs[idx])==null?void 0:_c2.text)?(segs[idx].type="narration",segs[idx].speaker="Narrator",segs[idx].emotion=""):unresolvedGroup.push(idx);if(!unresolvedGroup.length){group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1],view.update(done,`Correcting narration tags ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}group=unresolvedGroup,group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1];const recastCtx=audiobookRecastContext(segs,group),passageText=recastCtx.text,lineLabel=group.length>1?`Attributing lines ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`:`Attributing line ${done+1} / ${unknownIdxs.length}\u2026`;view.update(done,lineLabel),view.processing(passageText.trim());let data=null;const recastBody=audiobookAttributeBody({text:passageText.trim(),known_characters:_audiobook.roster.slice(-40),recent:recastCtx.recent||"",language,llm_url:((_d2=document.getElementById("ab-cv-llm-url"))==null?void 0:_d2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_e2=document.getElementById("ab-cv-llm-select"))==null?void 0:_e2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)},promptOverride);try{try{data=await audiobookAttributeStream(recastBody,view,ac.signal,AUDIOBOOK_RECAST_TIMEOUT_MS)}catch(streamErr){if(streamErr.name==="AbortError")throw streamErr;const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(recastBody)},AUDIOBOOK_RECAST_TIMEOUT_MS+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}data=await r.json()}}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0;break}view.note(`API Error while checking ${group.length>1?"a group of Unknown lines":"Unknown line"}: ${err.message||err}`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}if(data&&data.segments){const used=new Set;for(const idx of group){const targetSeg=segs[idx],seqMatch=audiobookFindReturnedSegmentSequence(targetSeg,data.segments,used);if(seqMatch&&seqMatch.segs.length>1){const repl=seqMatch.segs.map(s=>normalizeRecastSegment(s,targetSeg)).filter(s=>(s.text||"").trim()),hasResolved=repl.some(s=>s.type==="narration"||s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)),hasUnresolvedDialogue=repl.some(s=>s.type==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker)));if(repl.length&&hasResolved&&!hasUnresolvedDialogue){seqMatch.idxs.forEach(i=>used.add(i)),pendingReplacements.set(idx,repl),repl.forEach(s=>{s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)&&!_audiobook.roster.includes(s.speaker)&&_audiobook.roster.push(s.speaker)});continue}}const match=audiobookFindReturnedSegment(targetSeg,data.segments,used);if(match&&match.seg.speaker){const speaker=match.seg.type==="narration"?"Narrator":match.seg.speaker;if(!speaker||/^Unknown|Unbekannt/i.test(speaker))continue;used.add(match.idx),targetSeg.type=match.seg.type==="narration"?"narration":"dialogue",targetSeg.speaker=speaker,targetSeg.emotion=targetSeg.type==="dialogue"&&(match.seg.emotion||targetSeg.emotion)||"",targetSeg.type==="dialogue"&&!_audiobook.roster.includes(speaker)&&_audiobook.roster.push(speaker)}}}for(const idx of group)view.addSegments(pendingReplacements.get(idx)||[segs[idx]]),done++;groupsSinceSave++,groupsSinceSave>=10&&_audiobook.lastText&&(groupsSinceSave=0,_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0),view.recountRoster(segs))}view.update(_audiobook.cancel?done:unknownIdxs.length)}catch(e){e.name!=="AbortError"&&view.note("Error defining unknowns: "+e.message)}finally{_audiobook.running=!1,_audiobook.abort=null}pendingReplacements.size&&[...pendingReplacements.entries()].sort((a,b)=>b[0]-a[0]).forEach(([idx,repl])=>segs.splice(idx,1,...repl));const afterUnknownCount=countUnknownDialogue(segs);!_audiobook.cancel&&afterUnknownCount>beforeUnknownCount&&(segs.splice(0,segs.length,...originalSegments),view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`),toast("Quality run rolled back because it increased Unknown speakers.","error"));const _rcDone=_audiobook.completedChunks||segs.length,_rcTotal=_audiobook.completedTotal||_rcDone;if(_audiobook.cancel){_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal),view.done({stopped:!0,message:"Character definition stopped. Existing cast preserved."}),toast("Character definition stopped. Existing cast preserved.","info");return}_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal);const speakers=new Set(segs.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${segs.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)}async function audiobookOpenCastView(){var _a2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}const _curBook=window.readerState&&readerState.savedId||null;_curBook&&_audiobook.bookId&&_audiobook.bookId!==_curBook&&(_audiobook.segments=null,_audiobook.lastText=null,_audiobook.roster=null),_audiobook.bookId=_curBook;const llm_url=audiobookLlmUrl(),model=audiobookLlmModel(),chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text];if(_audiobook.segments&&_audiobook.segments.length>0&&_audiobook.lastText===text){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.rebuild(_audiobook.segments);const speakers=new Set(_audiobook.segments.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${_audiobook.segments.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown);return}const _applyDraft=(draft,view,source)=>{const draftDone=Number.isFinite(Number(draft.done))?Number(draft.done):0,draftTotal=Number.isFinite(Number(draft.total))?Number(draft.total):0;_abStampSegmentPages(draft.segments,draft.pageMarks,text),_audiobook.segments=draft.segments,_audiobook.roster=draft.roster||[],_audiobook.lastText=text,_audiobook.pageMarks=draft.pageMarks||[],_audiobook.rehId=draft.rehId||null,_audiobook.completedChunks=draftDone>0?draftDone:0,_audiobook.completedTotal=draftTotal>0?draftTotal:0,view.rebuild(draft.segments);const ageMs=Date.now()-(draft.savedAt||0),ageMins=Math.round(ageMs/6e4),ageStr=ageMins<1?"gerade eben":ageMins<60?`vor ${ageMins} Min.`:`vor ${Math.round(ageMins/60)} Std.`,pct=draftTotal>0?Math.max(0,Math.min(100,Math.round(draftDone/draftTotal*100))):100,wasDone=draftTotal>0&&draftDone>=draftTotal,canContinue=draftDone>0&&draftTotal>0&&draftDones.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} Charakter${speakers.size!==1?"e":""} \xB7 ${draft.segments.length} Segmente`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)},_localDraft=_abLoadDraft(text);if(_localDraft&&_localDraft.segments&&_localDraft.segments.length>0){const view=audiobookCastView(chunks.length,llm_url,model,!1);if(_applyDraft(_localDraft,view,"local"),_abBookId()){const serverCopy={..._localDraft,bookId:_abBookId(),title:((_a2=window.readerState)==null?void 0:_a2.title)||_localDraft.title||""};fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(serverCopy)}).catch(()=>{})}return}const bookId=_abBookId();if(bookId){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.loadingRestore();try{const serverDraft=await _abLoadDraftServer(bookId);if(serverDraft&&serverDraft.segments&&serverDraft.segments.length>0){try{localStorage.setItem(_abDraftKey(bookId),JSON.stringify(serverDraft))}catch{}_applyDraft(serverDraft,view,"server")}else view.setFreshState("No saved cast was found for this saved book. Starting a new cast will create a new draft.")}catch{view.setFreshState("Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.")}return}audiobookCastView(chunks.length,llm_url,model,!0)}async function audiobookCast(overrideUrl,overrideModel,resume){var _a2,_b2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}if(typeof parseScript!="function"){toast("Rehearser not loaded yet \u2014 try again in a moment","error");return}_audiobook.running=!0,!!_abBookId()||!await _abEnsureLibraryBook()&&typeof toast=="function"&&toast("Autosave will use this browser only until the book is saved to the library.","info");const chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text],startIndex=resume&&resume.startIndex>0&&resume.startIndex0;_audiobook.cancel=!1,isResume||_abClearDraft(),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!0);const ac=new AbortController;_audiobook.abort=()=>ac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang(text);let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const view=audiobookCastView(chunks.length,llm_url,model,!1);isResume&&resume.segments&&resume.segments.length&&view.rebuild(resume.segments),view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_a2=document.getElementById("ab-cv-llm-url"))==null?void 0:_a2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b2=document.getElementById("ab-cv-llm-select"))==null?void 0:_b2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}}const allSegments2=isResume?resume.segments.slice():[];_audiobook.liveSegments=allSegments2;const roster=isResume?(resume.roster||[]).slice():[];let narrationOnly=isResume&&resume.narrationOnly||0,degraded=isResume&&resume.degraded||0,completedChunks=startIndex;_abStartDraftAutosave(()=>{allSegments2.length&&_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)});const _pgMarks=(_audiobook.pageMarks||[]).slice();_pgMarks.length&&_pgMarks[0].offset<=2&&_pgMarks.shift();let _pgMarkIdx=0,_pgCharPos=0,_curPageNum=1;if(isResume){for(let k=0;k0&&(_curPageNum=_pgMarks[_pgMarkIdx-1].page+1)}try{for(let i=startIndex;i=_pgMarks[_pgMarkIdx].offset;)_curPageNum=_pgMarks[_pgMarkIdx].page+1,view.pagemark(_curPageNum),_pgMarkIdx++;if(_pgCharPos+=chunks[i].length+1,!audiobookHasDialogue(chunks[i])){const seg={speaker:"Narrator",type:"narration",text:chunks[i],emotion:"",page:_curPageNum};allSegments2.push(seg),narrationOnly++,view.addSegments([seg]),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length);continue}const recent=allSegments2.filter(s=>s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)).slice(-6).map(s=>`${s.speaker}: ${(s.text||"").slice(0,80)}`).join(` +- Mische NIEMALS Narration und Dialog in einem Segment. +- Sei konservativ: \xE4ndere eine Zeile nur, wenn du nach dieser Pr\xFCfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend w\xE4re \u2014 nicht jede Zeile muss sich \xE4ndern.`,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);closePanel(),audiobookRecastUnknown(savedChoice.url,savedChoice.model,{prompt:verificationPrompt,includeNarrator:!0})},runConsistencyPass=async()=>{const active=_abActiveSegments(),segs=active.arr;if(!segs.length){toast("Nothing cast yet to check","error");return}const byName=new Map;segs.forEach((s,idx)=>{s.type!=="dialogue"||!s.speaker||/^Narrator$/i.test(s.speaker)||/^Unknown|Unbekannt/i.test(s.speaker)||(byName.has(s.speaker)||byName.set(s.speaker,[]),byName.get(s.speaker).push(idx))});const MIN_LINES=4,MAX_LINES_PER_CALL=60,candidates=[...byName.entries()].filter(([,idxs])=>idxs.length>=MIN_LINES);if(!candidates.length){toast("No characters with enough lines yet to check for consistency","error");return}const choice2=audiobookCurrentCastLlm(panel),savedChoice2=audiobookSaveLlmChoice(choice2.url,choice2.model);closePanel(),_abPushEditState(active.key,segs);const busy=_abShowBusyOverlay(`Checking voice consistency for ${candidates.length} character${candidates.length!==1?"s":""}\u2026`,!0);let checked=0,flaggedTotal=0,failedNames=[];const touchedSegs=[],knownNames=[...roster.keys()];try{for(const[name,idxs]of candidates){busy.setProgress(checked,candidates.length);let sampleIdxs=idxs;if(idxs.length>MAX_LINES_PER_CALL){const step=idxs.length/MAX_LINES_PER_CALL;sampleIdxs=Array.from({length:MAX_LINES_PER_CALL},(_,i)=>idxs[Math.floor(i*step)])}const lines=sampleIdxs.map(i=>({index:i,text:segs[i].text}));try{const r=await fetch("/api/audiobook-consistency-check",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({character:name,lines,known_characters:knownNames.filter(n=>n.toLowerCase()!==name.toLowerCase()),llm_url:savedChoice2.url,model:savedChoice2.model})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const data=await r.json(),outliers=Array.isArray(data==null?void 0:data.outliers)?data.outliers:[];for(const o of outliers){let idx=o.index;const quote=String(o.quote||"").trim().toLowerCase(),matchesQuote=i=>{var _a4;return!quote||(((_a4=segs[i])==null?void 0:_a4.text)||"").toLowerCase().includes(quote.slice(0,40))};if((typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name||!matchesQuote(idx))&&(idx=quote?sampleIdxs.find(i=>segs[i].speaker===name&&matchesQuote(i)):void 0),typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name)continue;const suggested=String(o.suggested_speaker||"").trim();let newSpeaker=null;!suggested||/^unknown|unbekannt$/i.test(suggested)?newSpeaker="Unknown":newSpeaker=knownNames.find(n=>n.toLowerCase()===suggested.toLowerCase())||null,!(!newSpeaker||newSpeaker===name)&&(segs[idx].speaker=newSpeaker,newSpeaker==="Unknown"&&(segs[idx].type="dialogue"),touchedSegs.push(segs[idx]),flaggedTotal++)}}catch(err){failedNames.push(name),console.error("[consistency check]",name,err)}checked++,busy.setProgress(checked,candidates.length)}}finally{busy.remove()}flaggedTotal&&(await _abPatchScatteredRows(touchedSegs,()=>{})||await _abRedrawSegmentsChunked(active.arr),_abPersistManualEdit());const failSuffix=failedNames.length?` (${failedNames.length} character${failedNames.length!==1?"s":""} failed to check: ${failedNames.slice(0,5).join(", ")})`:"";toast((flaggedTotal?`Consistency check: ${flaggedTotal} line${flaggedTotal!==1?"s":""} reassigned across ${checked} character${checked!==1?"s":""}`:`Consistency check: no mismatches found across ${checked} character${checked!==1?"s":""}`)+failSuffix,failedNames.length&&!flaggedTotal?"error":"success")};foot.querySelector("#ab-cv-open-reh").addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()});const applyPromptAndRun2=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{closePanel(),callback&&callback(savedChoice.url,savedChoice.model)})},runIdentifyAll=async()=>{await confirmDialog("Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.",{title:"Discard current cast?",okLabel:"Start from scratch",danger:!0})&&applyPromptAndRun2(audiobookCast)},runIdentifyUnknown=()=>applyPromptAndRun2(audiobookRecastUnknown),runCastAll=()=>{typeof window.csForReader=="function"?window.csForReader():typeof csForReader=="function"?csForReader():toast("Character sheets not loaded yet","error")},runCastFresh=async()=>{await confirmDialog("Discard every generated character sheet for this book and rebuild every profile from a blank slate? This cannot be undone.",{title:"Discard all character sheets?",okLabel:"Discard & rebuild",danger:!0})&&(typeof window.csForReader=="function"?window.csForReader({fresh:!0}):typeof csForReader=="function"?csForReader({fresh:!0}):toast("Character sheets not loaded yet","error"))},runCastSelected=()=>{if(!existing.length){toast("No character sheets found yet for this book","error");return}_abOpenRecastSelectPopup(existing)},runCastContinueUncasted=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof csForReaderSelective!="function"||typeof CS_DETAIL_FIELDS=="undefined"){toast("Character sheets not loaded yet","error");return}let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const recByName=new Map;recs.forEach(r=>{var _a4;const name=String(r.name||"").trim().toLowerCase();if(name&&recByName.set(name,r),typeof clSplitIdentityTokens=="function")for(const a of clSplitIdentityTokens((_a4=r==null?void 0:r.sheet)==null?void 0:_a4.aliases,{aliases:!0})){const key=a.toLowerCase();recByName.has(key)||recByName.set(key,r)}});const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim())),MIN_LINES_TO_TRY=10,lineCounts=new Map;(_audiobook.segments||[]).forEach(s=>{if((s==null?void 0:s.type)!=="dialogue"||!s.speaker)return;const k=String(s.speaker).trim().toLowerCase();lineCounts.set(k,(lineCounts.get(k)||0)+1)});const tooSparse=[],incomplete=roster2.filter(n=>{const rec=recByName.get(String(n).trim().toLowerCase());return rec&&CS_DETAIL_FIELDS.some(f=>String((rec.sheet||{})[f]||"").trim())?!1:(lineCounts.get(String(n).trim().toLowerCase())||0){var _a4;if((_a4=document.getElementById("s-caststudio"))!=null&&_a4.classList.contains("is-active")&&typeof window.showStudioPhase=="function"){window.showStudioPhase(3);return}try{sessionStorage.setItem("ttsvc_cast_return","reader")}catch{}typeof navTo=="function"&&navTo("s-library"),typeof navLibraryView=="function"&&navLibraryView("characters")},runLibraryCleanup=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof clDelete!="function"){toast("Character library is not available right now","error");return}const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim()));if(!roster2.length){toast("No current roster to clean up against","error");return}const rosterSet=new Set(roster2.map(n=>n.trim().toLowerCase()));let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const toDelete=recs.filter(r=>!rosterSet.has(String(r.name||"").trim().toLowerCase()));if(!toDelete.length){toast(`Library already matches the current ${roster2.length}-name roster \u2014 nothing to remove`,"success");return}const preview=toDelete.slice(0,12).map(r=>r.name).join(", ")+(toDelete.length>12?`, +${toDelete.length-12} more`:"");if(!await confirmDialog(`Remove ${toDelete.length} character record${toDelete.length!==1?"s":""} that aren't in the current ${roster2.length}-name roster? This cannot be undone. + +${preview}`,{title:"Clean up character library?",okLabel:`Remove ${toDelete.length}`,danger:!0}))return;let removed=0;for(const r of toDelete)try{await clDelete(r.id),removed++}catch{}toast(`Removed ${removed} character record${removed!==1?"s":""} not in the current roster`,"success")},bookTitle=((_a3=window.readerState)==null?void 0:_a3.title)||"";let existing=[];(async()=>{if(!(!bookTitle||typeof clGetAllByTagOrBook!="function"))try{existing=await clGetAllByTagOrBook(bookTitle)}catch{}})();const identifyMenu=foot.querySelector("#ab-cv-menu-identify"),castMenu=foot.querySelector("#ab-cv-menu-cast"),viewCastMenu=foot.querySelector("#ab-cv-menu-viewcast");identifyMenu==null||identifyMenu.addEventListener("click",()=>_abToggleFootMenu(identifyMenu,[{icon:"mdi-refresh",label:"Identify all characters",title:"Scan the text and build the cast list from scratch",onClick:runIdentifyAll,danger:!0},{icon:"mdi-account-question-outline",label:"Identify unknown characters",title:"Re-scan only the unknown segments with the current prompt",onClick:runIdentifyUnknown},{icon:"mdi-shield-check-outline",label:"Verify all characters",title:"Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches",onClick:runVerificationPass},{icon:"mdi-account-search-outline",label:"Check voice consistency",title:"Third-pass check: gathers every line already credited to each character across the whole book and flags any that don\u2019t match their established voice",onClick:runConsistencyPass}])),castMenu==null||castMenu.addEventListener("click",()=>_abToggleFootMenu(castMenu,[{icon:"mdi-account-multiple-plus-outline",label:"Cast all character roles",title:"Generate / refresh the character sheets for every cast character",onClick:runCastAll},{icon:"mdi-account-arrow-right-outline",label:"Continue uncasted characters",title:"Only generate profiles for characters with no detail yet \u2014 skips anyone already fully cast",onClick:runCastContinueUncasted},{icon:"mdi-account-check-outline",label:"Cast selected character roles",title:existing.length?"Generate / refresh the character sheets for selected cast characters":"No character sheets found yet for this book",disabled:!existing.length,onClick:runCastSelected},{divider:!0},{icon:"mdi-refresh",label:"New recast (discard & rebuild all)",title:"Discard every generated character sheet and rebuild every profile from scratch",onClick:runCastFresh,danger:!0}])),viewCastMenu==null||viewCastMenu.addEventListener("click",()=>_abToggleFootMenu(viewCastMenu,[{icon:"mdi-eye-outline",label:"View cast",title:"View the cast overview in the library",onClick:runViewCast},{icon:"mdi-folder-zip-outline",label:"Export cast archive (.zip)",title:"Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character",onClick:audiobookExportCastMd},{divider:!0},{icon:"mdi-broom",label:"Clean up library to current roster",title:"Remove saved character records that aren't in the current roster \u2014 old spelling-variant duplicates and superseded names from past casting runs",onClick:runLibraryCleanup,danger:!0}])),(_b3=foot.querySelector("#ab-cv-continue"))==null||_b3.addEventListener("click",()=>applyPromptAndRun2((u,m)=>audiobookCast(u,m,{startIndex:_audiobook.completedChunks,segments:_audiobook.segments,roster:_audiobook.roster,narrationOnly:_audiobook.narratedPassages,degraded:_audiobook.degraded}))),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},done(options={}){var _a3,_b3,_c3,_d3,_e3;if(!(options.stopped||_audiobook.cancel)){closePanel();return}this.clearProcessing(),count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const message=options.message||"Casting stopped before any passage completed.";if(feed.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page")){if(options.message){const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}}else{feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No completed characters yet.');const foot=panel.querySelector("#ab-cv-foot"),hasSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length>0;foot.hidden=!1,foot.innerHTML=` ${escHtml(message)}${hasSegments?'':""}`,(_c3=foot.querySelector("#ab-cv-stopped-back"))==null||_c3.addEventListener("click",closePanel),(_d3=foot.querySelector("#ab-cv-stopped-review"))==null||_d3.addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()}),(_e3=foot.querySelector("#ab-cv-stopped-recast"))==null||_e3.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt||""})}).finally(()=>{closePanel(),audiobookCast(choice.url,choice.model)})}),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},loadingRestore(){var _a3,_b3;(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent="Loading saved cast\u2026",feed.appendChild(r),chars.innerHTML='restoring\u2026';const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent="Checking saved cast before starting a new one.");const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="none")},setFreshState(message="Ready to identify characters."){var _a3,_b3;if((_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),!feed.querySelector(".ab-cv-row, .ab-cv-divider, .ab-cv-page")){feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No saved cast found.');const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent=message);const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),audiobookCast(choice.url,choice.model)}))}}}async function audiobookRecastUnknown(overrideUrl,overrideModel,options={}){var _a2,_b2,_c2,_d2,_e2,_f2;if(_audiobook.running)return;const segs=_audiobook.segments;if(!segs||!segs.length)return;const originalSegments=segs.map(s=>({...s})),countUnknownDialogue=arr=>(arr||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length,beforeUnknownCount=countUnknownDialogue(segs),preResolved=audiobookResolveUnknowns(segs,[],_audiobook.roster||[]);preResolved.length&&toast(`${preResolved.length} Unknown line${preResolved.length!==1?"s":""} resolved by grammar rules`,"success");const unknownIdxs=[];for(let i=0;iac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang();let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const promptOverride=typeof options.prompt=="string"?options.prompt:null,groups=audiobookRecastGroups(unknownIdxs,segs),view=audiobookCastView(unknownIdxs.length,llm_url,model);view.recountRoster(segs);const _rcAliasMap=new Map;try{const bookTitle=((_a2=window.readerState)==null?void 0:_a2.title)||"",records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(bookTitle):[];for(const rec of records||[])if(rec!=null&&rec.name&&(_rcAliasMap.set(rec.name.toLowerCase(),rec.name),typeof clSplitIdentityTokens=="function"))for(const a of clSplitIdentityTokens(rec.aliases,{aliases:!0}))_rcAliasMap.set(a.toLowerCase(),rec.name)}catch{}const canonicalizeSpeaker=name=>name&&_rcAliasMap.get(name.toLowerCase())||name;view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_b2=document.getElementById("ab-cv-llm-url"))==null?void 0:_b2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_c2=document.getElementById("ab-cv-llm-select"))==null?void 0:_c2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Character definition stopped before any lines were updated."}),toast("Character definition stopped. Existing cast preserved.","info");return}}let done=0,prevIdx=-2,groupsSinceSave=0;const pendingReplacements=new Map,normalizeRecastSegment=(seg,fallback)=>{const type=(seg==null?void 0:seg.type)==="narration"?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":canonicalizeSpeaker((seg==null?void 0:seg.speaker)||(fallback==null?void 0:fallback.speaker)||"Unknown"),type,emotion:type==="dialogue"&&((seg==null?void 0:seg.emotion)||(fallback==null?void 0:fallback.emotion))||"",text:(seg==null?void 0:seg.text)||(fallback==null?void 0:fallback.text)||""}};try{for(let group of groups){if(_audiobook.cancel)break;const unresolvedGroup=[];for(const idx of group)audiobookIsSpeechTagOnly((_d2=segs[idx])==null?void 0:_d2.text)?(segs[idx].type="narration",segs[idx].speaker="Narrator",segs[idx].emotion=""):unresolvedGroup.push(idx);if(!unresolvedGroup.length){group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1],view.update(done,`Correcting narration tags ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}group=unresolvedGroup,group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1];const recastCtx=audiobookRecastContext(segs,group),passageText=recastCtx.text,lineLabel=group.length>1?`Attributing lines ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`:`Attributing line ${done+1} / ${unknownIdxs.length}\u2026`;view.update(done,lineLabel),view.processing(passageText.trim());let data=null;const recastBody=audiobookAttributeBody({text:passageText.trim(),known_characters:_audiobook.roster.slice(-40),recent:recastCtx.recent||"",language,llm_url:((_e2=document.getElementById("ab-cv-llm-url"))==null?void 0:_e2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_f2=document.getElementById("ab-cv-llm-select"))==null?void 0:_f2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)},promptOverride);try{try{data=await audiobookAttributeStream(recastBody,view,ac.signal,AUDIOBOOK_RECAST_TIMEOUT_MS)}catch(streamErr){if(streamErr.name==="AbortError")throw streamErr;const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(recastBody)},AUDIOBOOK_RECAST_TIMEOUT_MS+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}data=await r.json()}}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0;break}view.note(`API Error while checking ${group.length>1?"a group of Unknown lines":"Unknown line"}: ${err.message||err}`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}if(data&&data.segments){const used=new Set;for(const idx of group){const targetSeg=segs[idx],seqMatch=audiobookFindReturnedSegmentSequence(targetSeg,data.segments,used);if(seqMatch&&seqMatch.segs.length>1){const repl=seqMatch.segs.map(s=>normalizeRecastSegment(s,targetSeg)).filter(s=>(s.text||"").trim()),hasResolved=repl.some(s=>s.type==="narration"||s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)),hasUnresolvedDialogue=repl.some(s=>s.type==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker)));if(repl.length&&hasResolved&&!hasUnresolvedDialogue){seqMatch.idxs.forEach(i=>used.add(i)),pendingReplacements.set(idx,repl),repl.forEach(s=>{s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)&&!_audiobook.roster.includes(s.speaker)&&_audiobook.roster.push(s.speaker)});continue}}const match=audiobookFindReturnedSegment(targetSeg,data.segments,used);if(match&&match.seg.speaker){const speaker=match.seg.type==="narration"?"Narrator":canonicalizeSpeaker(match.seg.speaker);if(!speaker||/^Unknown|Unbekannt/i.test(speaker))continue;used.add(match.idx),targetSeg.type=match.seg.type==="narration"?"narration":"dialogue",targetSeg.speaker=speaker,targetSeg.emotion=targetSeg.type==="dialogue"&&(match.seg.emotion||targetSeg.emotion)||"",targetSeg.type==="dialogue"&&!_audiobook.roster.includes(speaker)&&_audiobook.roster.push(speaker)}}}for(const idx of group)view.addSegments(pendingReplacements.get(idx)||[segs[idx]]),done++;groupsSinceSave++,groupsSinceSave>=10&&_audiobook.lastText&&(groupsSinceSave=0,_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0),view.recountRoster(segs))}view.update(_audiobook.cancel?done:unknownIdxs.length)}catch(e){e.name!=="AbortError"&&view.note("Error defining unknowns: "+e.message)}finally{_audiobook.running=!1,_audiobook.abort=null}pendingReplacements.size&&[...pendingReplacements.entries()].sort((a,b)=>b[0]-a[0]).forEach(([idx,repl])=>segs.splice(idx,1,...repl));const{segments:deduped,removed:dupRemoved}=_audiobookDedupNearbyDuplicates(segs);dupRemoved&&(segs.splice(0,segs.length,...deduped),view.note(`Removed ${dupRemoved} duplicated line${dupRemoved!==1?"s":""} introduced by this verification pass.`));const afterUnknownCount=countUnknownDialogue(segs);!_audiobook.cancel&&afterUnknownCount>beforeUnknownCount&&(segs.splice(0,segs.length,...originalSegments),view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`),toast("Quality run rolled back because it increased Unknown speakers.","error"));const _rcDone=_audiobook.completedChunks||segs.length,_rcTotal=_audiobook.completedTotal||_rcDone;if(_audiobook.cancel){_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal),view.done({stopped:!0,message:"Character definition stopped. Existing cast preserved."}),toast("Character definition stopped. Existing cast preserved.","info");return}_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal);const speakers=new Set(segs.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${segs.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)}async function audiobookOpenCastView(){var _a2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}const _curBook=window.readerState&&readerState.savedId||null;_curBook&&_audiobook.bookId&&_audiobook.bookId!==_curBook&&(_audiobook.segments=null,_audiobook.lastText=null,_audiobook.roster=null),_audiobook.bookId=_curBook;const llm_url=audiobookLlmUrl(),model=audiobookLlmModel(),chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text];if(_audiobook.segments&&_audiobook.segments.length>0&&_audiobook.lastText===text){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.rebuild(_audiobook.segments);const speakers=new Set(_audiobook.segments.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${_audiobook.segments.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown);return}const _applyDraft=(draft,view,source)=>{const draftDone=Number.isFinite(Number(draft.done))?Number(draft.done):0,draftTotal=Number.isFinite(Number(draft.total))?Number(draft.total):0;_abStampSegmentPages(draft.segments,draft.pageMarks,text),_audiobook.segments=draft.segments,_audiobook.roster=draft.roster||[],_audiobook.lastText=text,_audiobook.pageMarks=draft.pageMarks||[],_audiobook.rehId=draft.rehId||null,_audiobook.completedChunks=draftDone>0?draftDone:0,_audiobook.completedTotal=draftTotal>0?draftTotal:0,view.rebuild(draft.segments);const ageMs=Date.now()-(draft.savedAt||0),ageMins=Math.round(ageMs/6e4),ageStr=ageMins<1?"gerade eben":ageMins<60?`vor ${ageMins} Min.`:`vor ${Math.round(ageMins/60)} Std.`,pct=draftTotal>0?Math.max(0,Math.min(100,Math.round(draftDone/draftTotal*100))):100,wasDone=draftTotal>0&&draftDone>=draftTotal,canContinue=draftDone>0&&draftTotal>0&&draftDones.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} Charakter${speakers.size!==1?"e":""} \xB7 ${draft.segments.length} Segmente`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)},_localDraft=_abLoadDraft(text);if(_localDraft&&_localDraft.segments&&_localDraft.segments.length>0){const view=audiobookCastView(chunks.length,llm_url,model,!1);if(_applyDraft(_localDraft,view,"local"),_abBookId()){const serverCopy={..._localDraft,bookId:_abBookId(),title:((_a2=window.readerState)==null?void 0:_a2.title)||_localDraft.title||""};fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(serverCopy)}).catch(()=>{})}return}const bookId=_abBookId();if(bookId){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.loadingRestore();try{const serverDraft=await _abLoadDraftServer(bookId);if(serverDraft&&serverDraft.segments&&serverDraft.segments.length>0){try{localStorage.setItem(_abDraftKey(bookId),JSON.stringify(serverDraft))}catch{}_applyDraft(serverDraft,view,"server")}else view.setFreshState("No saved cast was found for this saved book. Starting a new cast will create a new draft.")}catch{view.setFreshState("Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.")}return}audiobookCastView(chunks.length,llm_url,model,!0)}async function audiobookCast(overrideUrl,overrideModel,resume){var _a2,_b2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}if(typeof parseScript!="function"){toast("Rehearser not loaded yet \u2014 try again in a moment","error");return}_audiobook.running=!0,!!_abBookId()||!await _abEnsureLibraryBook()&&typeof toast=="function"&&toast("Autosave will use this browser only until the book is saved to the library.","info");const chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text],startIndex=resume&&resume.startIndex>0&&resume.startIndex0;_audiobook.cancel=!1,isResume||_abClearDraft(),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!0);const ac=new AbortController;_audiobook.abort=()=>ac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang(text);let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const view=audiobookCastView(chunks.length,llm_url,model,!1);isResume&&resume.segments&&resume.segments.length&&view.rebuild(resume.segments),view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_a2=document.getElementById("ab-cv-llm-url"))==null?void 0:_a2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b2=document.getElementById("ab-cv-llm-select"))==null?void 0:_b2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}}const allSegments2=isResume?resume.segments.slice():[];_audiobook.liveSegments=allSegments2;const roster=isResume?(resume.roster||[]).slice():[];let narrationOnly=isResume&&resume.narrationOnly||0,degraded=isResume&&resume.degraded||0,completedChunks=startIndex;_abStartDraftAutosave(()=>{allSegments2.length&&_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)});const _pgMarks=(_audiobook.pageMarks||[]).slice();_pgMarks.length&&_pgMarks[0].offset<=2&&_pgMarks.shift();let _pgMarkIdx=0,_pgCharPos=0,_curPageNum=1;if(isResume){for(let k=0;k0&&(_curPageNum=_pgMarks[_pgMarkIdx-1].page+1)}try{for(let i=startIndex;i=_pgMarks[_pgMarkIdx].offset;)_curPageNum=_pgMarks[_pgMarkIdx].page+1,view.pagemark(_curPageNum),_pgMarkIdx++;if(_pgCharPos+=chunks[i].length+1,!audiobookHasDialogue(chunks[i])){const seg={speaker:"Narrator",type:"narration",text:chunks[i],emotion:"",page:_curPageNum};allSegments2.push(seg),narrationOnly++,view.addSegments([seg]),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length);continue}const recent=allSegments2.filter(s=>s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)).slice(-6).map(s=>`${s.speaker}: ${(s.text||"").slice(0,80)}`).join(` `);view.processing(chunks[i]);const attributeChunk=async(chunkText,recentCtx,timeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS)=>{var _a3,_b3;const body={text:chunkText,known_characters:roster.slice(-40),recent:recentCtx,language,llm_url:((_a3=document.getElementById("ab-cv-llm-url"))==null?void 0:_a3.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b3=document.getElementById("ab-cv-llm-select"))==null?void 0:_b3.value)||model),timeout_seconds:audiobookTimeoutSeconds(timeoutMs)};try{const d=await audiobookAttributeStream(body,view,ac.signal,timeoutMs);return Array.isArray(d.segments)?d.segments:null}catch(err){if(err.name==="AbortError")throw err}try{const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(body)},timeoutMs+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}const d=await r.json();return Array.isArray(d.segments)?d.segments:null}catch(err){if(err.name==="AbortError")throw err;return{error:err.message}}};let segs;try{let result=await attributeChunk(chunks[i],recent),data=null;if(result&&!result.error)data=result;else if(result&&result.error){view.note(`\u26A0\uFE0F Passage ${i+1} timed out \u2014 retrying in two halves\u2026`);const half=Math.floor(chunks[i].length/2),splitAt=chunks[i].lastIndexOf(" ",half)||half,chunkA=chunks[i].slice(0,splitAt).trim(),chunkB=chunks[i].slice(splitAt).trim(),resA=await attributeChunk(chunkA,recent,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS),resB=await attributeChunk(chunkB,recent,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS),segsA=Array.isArray(resA)?resA:null,segsB=Array.isArray(resB)?resB:null;if(segsA||segsB){const arrA=segsA||audiobookSplitByQuotes(chunkA),arrB=segsB||audiobookSplitByQuotes(chunkB);data=[...arrA,...arrB],segsA||(degraded++,view.note(`\u26A0\uFE0F Passage ${i+1} (first half) \u2014 auto-detected (retry also failed)`)),segsB||(degraded++,view.note(`\u26A0\uFE0F Passage ${i+1} (second half) \u2014 auto-detected (retry also failed)`))}else view.note(`\u274C Passage ${i+1} \u2014 LLM error: ${result.error}`),data=null}segs=Array.isArray(data)?data:data&&Array.isArray(data.segments)?data.segments:[];const hasDialogue=segs.some(s=>s.type==="dialogue");if(!segs.length||!hasDialogue&&audiobookHasDialogue(chunks[i])){segs=audiobookSplitByQuotes(chunks[i]),degraded++;const named=segs.filter(s=>s.type==="dialogue"&&!/^Unknown|Unbekannt/i.test(s.speaker)).length;view.note(`Passage ${i+1} \u2014 auto-detected dialogue${named?` (${named} speaker${named!==1?"s":""} from tags)`:" (set speakers in review)"}`)}else{let cleanedSegs=[];for(let s of segs){if(s.text=s.text||"",s.type==="dialogue"&&audiobookIsSpeechTagOnly(s.text)&&(s.type="narration",s.speaker="Narrator",s.emotion=""),s.type==="dialogue"&&s.text.trim().length>10){const tText=s.text.trim(),idx=chunks[i].indexOf(tText);if(idx!==-1){const surround=chunks[i].slice(Math.max(0,idx-8),idx)+chunks[i].slice(idx+tText.length,idx+tText.length+8);/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)||(s.type="narration",s.speaker="Narrator",s.emotion="")}}if(cleanedSegs.length>0){let last=cleanedSegs[cleanedSegs.length-1];if(s.text.trim()===last.text.trim())if(s.type==="dialogue"&&last.type!=="dialogue"){cleanedSegs[cleanedSegs.length-1]=s;continue}else{if(s.type!=="dialogue"&&last.type==="dialogue")continue;continue}if(s.type==="dialogue"&&last.type==="narration"){const tS=s.text.trim(),tL=last.text.trim();tL.endsWith(tS)&&(last.text=tL.slice(0,-tS.length).trim(),last.text||cleanedSegs.pop())}}s.text.trim()&&cleanedSegs.push(s)}let mergedSegs=[];for(let s of cleanedSegs){if(mergedSegs.length>0){let last=mergedSegs[mergedSegs.length-1];if(s.type==="narration"&&last.type==="narration"&&(s.speaker||"Narrator").toLowerCase()==="narrator"&&(last.speaker||"Narrator").toLowerCase()==="narrator"){/[.!?]$/.test(last.text.trim())?last.text=last.text.trimEnd()+` -`+s.text.trimStart():last.text=last.text.trimEnd()+" "+s.text.trimStart();continue}}mergedSegs.push(s)}segs=mergedSegs}}catch(chunkErr){if(chunkErr.name==="AbortError")throw chunkErr;degraded++,segs=audiobookSplitByQuotes(chunks[i]),view.note(`\u274C Passage ${i+1} \u2014 unexpected error (${chunkErr.message}) \u2014 auto-detected dialogue instead`)}audiobookResolveUnknowns(segs,allSegments2.slice(-2),roster),segs.forEach(s=>{const speakerName=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator"?"Narrator":s.speaker;!/^Unknown|Unbekannt/i.test(speakerName)&&!roster.includes(speakerName)&&roster.push(speakerName)}),segs.forEach(s=>{s.page=_curPageNum,allSegments2.push(s)}),view.addSegments(segs),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)}view.update(_audiobook.cancel?completedChunks:chunks.length)}catch(err){err.name==="AbortError"?(_audiobook.cancel=!0,view.note("Casting stopped. Completed passages were preserved.")):(_audiobook.cancel=!0,view.note(`\u274C Casting stopped \u2014 unexpected error: ${err.message}`))}finally{_abStopDraftAutosave(),_audiobook.running=!1,_audiobook.abort=null}if(!allSegments2.length){if(_audiobook.cancel){view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}view.done(),toast("No segments produced","error");return}_audiobook.segments=allSegments2,_audiobook.lastText=text,_audiobook.roster=roster,_audiobook.narratedPassages=narrationOnly,_audiobook.degraded=degraded,_audiobook.completedChunks=_audiobook.cancel?completedChunks:chunks.length,_audiobook.completedTotal=chunks.length,_abSaveDraft(allSegments2,roster,text,_audiobook.completedChunks,chunks.length),audiobookSaveAsRehearsal({silent:!0}),_audiobook.cancel&&toast("Casting stopped early. Progress preserved.","info");const speakers=new Set(allSegments2.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${_audiobook.cancel&&completedChunks{const i=+inp.dataset.i,v=inp.value.trim()||"Narrator";segs[i].speaker=v,segs[i].type=v.toLowerCase()==="narrator"?"narration":"dialogue"}),document.querySelectorAll("#audiobook-seglist .audiobook-seg-emo").forEach(inp=>{const i=+inp.dataset.i;segs[i].emotion=inp.value.trim()});const{script,emotions}=audiobookBuildScript(segs);audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions)}function audiobookBuildScript(segments){let script="";const emotions=[],marks=_audiobook.pageMarks||[],src=_audiobook.lastText||"";let markIdx=0,searchPos=0;marks.length&&marks[0].offset<=2&&(markIdx=1);for(const s of segments){const t=(s.text||"").trim();if(!t)continue;if(src&&markIdx=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)script.trim()&&(script+=` +`+s.text.trimStart():last.text=last.text.trimEnd()+" "+s.text.trimStart();continue}}mergedSegs.push(s)}let respiltSegs=[];for(const s of mergedSegs)s.type==="narration"&&audiobookHasDialogue(s.text)?respiltSegs.push(...audiobookSplitByQuotes(s.text)):respiltSegs.push(s);segs=respiltSegs}}catch(chunkErr){if(chunkErr.name==="AbortError")throw chunkErr;degraded++,segs=audiobookSplitByQuotes(chunks[i]),view.note(`\u274C Passage ${i+1} \u2014 unexpected error (${chunkErr.message}) \u2014 auto-detected dialogue instead`)}audiobookResolveUnknowns(segs,allSegments2.slice(-2),roster),segs.forEach(s=>{const speakerName=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator"?"Narrator":s.speaker;!/^Unknown|Unbekannt/i.test(speakerName)&&!roster.includes(speakerName)&&roster.push(speakerName)}),segs.forEach(s=>{s.page=_curPageNum,allSegments2.push(s)}),view.addSegments(segs),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)}view.update(_audiobook.cancel?completedChunks:chunks.length)}catch(err){err.name==="AbortError"?(_audiobook.cancel=!0,view.note("Casting stopped. Completed passages were preserved.")):(_audiobook.cancel=!0,view.note(`\u274C Casting stopped \u2014 unexpected error: ${err.message}`))}finally{_abStopDraftAutosave(),_audiobook.running=!1,_audiobook.abort=null}if(!allSegments2.length){if(_audiobook.cancel){view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}view.done(),toast("No segments produced","error");return}const _quoteFixedSegs=_audiobookFixOrphanedQuoteMarks(allSegments2),_mergedSegs=_audiobookMergeAdjacentSameSpeaker(_quoteFixedSegs);allSegments2.length=0,allSegments2.push(..._mergedSegs),_audiobook.segments=allSegments2,_audiobook.lastText=text,_audiobook.roster=roster,_audiobook.narratedPassages=narrationOnly,_audiobook.degraded=degraded,_audiobook.completedChunks=_audiobook.cancel?completedChunks:chunks.length,_audiobook.completedTotal=chunks.length,_abSaveDraft(allSegments2,roster,text,_audiobook.completedChunks,chunks.length),audiobookSaveAsRehearsal({silent:!0}),_audiobook.cancel&&toast("Casting stopped early. Progress preserved.","info");const speakers=new Set(allSegments2.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${_audiobook.cancel&&completedChunks{}),!0}catch(err){console.warn("[audiobook] failed to build rehearser record directly, falling back:",err)}await audiobookSaveAsRehearsal({silent:!0});const{script,emotions}=audiobookBuildScript(segs);return await audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions),rehState.lines.length&&typeof buildScriptPage=="function"?(buildScriptPage(),typeof showPhase=="function"&&showPhase(3),typeof highlightCurrentLine=="function"&&highlightCurrentLine()):typeof showPhase=="function"&&showPhase(3),!0}function audiobookShowPreview(){var _a2;audiobookOpenCurrentInRehearser()}function audiobookApplyPreviewAndOpen(){const segs=_audiobook.segments;document.querySelectorAll("#audiobook-seglist .audiobook-seg-sp").forEach(inp=>{const i=+inp.dataset.i,v=inp.value.trim()||"Narrator";segs[i].speaker=v,segs[i].type=v.toLowerCase()==="narrator"?"narration":"dialogue"}),document.querySelectorAll("#audiobook-seglist .audiobook-seg-emo").forEach(inp=>{const i=+inp.dataset.i;segs[i].emotion=inp.value.trim()});const{script,emotions}=audiobookBuildScript(segs);audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions)}function audiobookBuildScript(segments){let script="";const emotions=[],marks=_audiobook.pageMarks||[],src=_audiobook.lastText||"";let markIdx=0,searchPos=0;marks.length&&marks[0].offset<=2&&(markIdx=1);for(const s of segments){const t=(s.text||"").trim();if(!t)continue;if(src&&markIdx=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)script.trim()&&(script+=` \f${marks[markIdx].page+1} `),markIdx++}s.type==="dialogue"&&s.speaker&&s.speaker.toLowerCase()!=="narrator"?(script+=` `+s.speaker.toUpperCase()+` `+t+` `,emotions.push(s.emotion||"")):script+=` `+t+` -`}return{script:script.trim(),emotions}}async function audiobookOpenInRehearser(script,title,dialogueEmotions){$("reh-script-text")&&($("reh-script-text").value=script),$("reh-script-title")&&($("reh-script-title").value=title),typeof navTo=="function"&&navTo("s-rehearser");const btn=$("reh-parse-btn");btn?btn.click():typeof parseScript=="function"&&(rehState.lines=parseScript(script));let speakers=0,lines=0;if(window.rehState&&Array.isArray(rehState.lines)){let k=0;rehState.lines.forEach(l=>{if(l.type==="dialog"){const e=dialogueEmotions[k++];e&&(l.emotion=e),lines++}}),speakers=Object.keys(rehState.cast||{}).filter(s=>!String(s).includes("NARRATOR")).length}let saved=!1;if(typeof saveToLibrary=="function")try{rehState.savedId=null,await saveToLibrary(),saved=!0}catch{}toast(`Cast ${speakers} character${speakers!==1?"s":""} \xB7 ${lines} lines`+(saved?" \u2014 saved to Rehearser \u2192 Bibliothek":""),"success")}function audiobookIsChapter(line){if(line.type==="act"||line.type==="scene")return!0;const t=(typeof stripMarkdown=="function"?stripMarkdown(line.text||""):line.text||"").trim();return!t||t.length>60?!1:/^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t)}function audiobookLineVoice(l){if(l.type==="dialog"){const c=rehState.cast[l.speaker]||{};return{voice:c.voice,instruct:typeof _buildInstruct=="function"?_buildInstruct(c.instruct,l.emotion):""}}return{voice:rehState.narratorVoice,instruct:""}}async function audiobookExport(){var _a2,_b2;if(_audiobook.running)return;if(!window.rehState||!(rehState.lines||[]).length){toast("Open a script in the rehearser first","error");return}if(!rehState.backend){toast("Select a TTS backend in the rehearser first","error");return}typeof _ensureNarrator=="function"&&_ensureNarrator();const speakable=i=>{const l=rehState.lines[i];if(!l||l.ignored||l.hidden)return!1;if(l.type==="dialog"){const c=rehState.cast[l.speaker];return!!(c&&c.voice&&c.voice!=="me")}return!!(rehState.narratorVoice&&(l.text||"").trim())},buckets=[];let cur=null;rehState.lines.forEach((l,i)=>{audiobookIsChapter(l)&&(cur={title:(typeof stripMarkdown=="function"?stripMarkdown(l.text):l.text).trim().slice(0,50),idx:[]},buckets.push(cur)),speakable(i)&&(cur||(cur={title:"",idx:[]},buckets.push(cur)),cur.idx.push(i))});const allIdx=buckets.flatMap(b=>b.idx);if(!allIdx.length){toast("Nothing to synthesise \u2014 cast voices first","error");return}_audiobook.running=!0,_audiobook.cancel=!1;const prog=audiobookProgress(allIdx.length),mp3=new Map;let done=0;const queue=allIdx.slice(),worker=async()=>{for(;queue.length&&!_audiobook.cancel;){const i=queue.shift(),l=rehState.lines[i],{voice,instruct}=audiobookLineVoice(l),text=typeof _rehInlineTone=="function"?_rehInlineTone(stripMarkdown(l.text),l.emotion):typeof stripMarkdown=="function"?stripMarkdown(l.text):l.text;try{mp3.set(i,await fetchTtsPreviewBlob(voice,text,"mp3",instruct,rehState.backend))}catch{}prog.update(++done,`Synthesising line ${done} / ${allIdx.length}\u2026`)}};try{await Promise.all(Array.from({length:Math.min(2,allIdx.length)},worker))}finally{prog.done(),_audiobook.running=!1}if(_audiobook.cancel){toast("Export cancelled","error");return}const title=typeof readerSafeName=="function"?readerSafeName(((_a2=$("reh-script-title"))==null?void 0:_a2.value)||"Audiobook"):((_b2=$("reh-script-title"))==null?void 0:_b2.value)||"Audiobook",realChapters=buckets.filter(b=>b.title).length>0;let files=0;for(let c=0;cmp3.get(i)).filter(Boolean);if(!blobs.length)continue;const blob=new Blob(blobs,{type:"audio/mpeg"}),ch=buckets[c].title?" "+readerSafeName(buckets[c].title):"",name=realChapters||buckets.length>1?`${title} - ${String(c+1).padStart(2,"0")}${ch}.mp3`:`${title}.mp3`;typeof readerDownload=="function"&&readerDownload(blob,name),files++,await new Promise(r=>setTimeout(r,400))}toast("Exported audiobook \xB7 "+files+(realChapters?" chapter MP3 file(s)":" MP3 file(s)"),"success")}(_lc=$("reader-audiobook-btn"))==null||_lc.addEventListener("click",audiobookOpenCastView),(_mc=$("reh-tb-audiobook"))==null||_mc.addEventListener("click",audiobookExport);async function _audiobookBuildRehRecord(segs){const{script,emotions}=audiobookBuildScript(segs),title=typeof readerState!="undefined"&&readerState.title?readerState.title:"Audiobook",lines=typeof parseScript=="function"?parseScript(script):[];if(emotions&&emotions.length){let eIdx=0;lines.forEach(l=>{l.type==="dialog"&&eIdx{cast[sp]={voice:def.voice,color:def.color,instruct:"",lang:"",gender:"",tags:"",soul:"",ignored:!1,hidden:!1,voiceData:null}})}let existing=null;if(_audiobook.rehId&&typeof window.rehDbGetById=="function")try{existing=await window.rehDbGetById(_audiobook.rehId)}catch{}existing&&existing.cast&&Object.entries(existing.cast).forEach(([sp,info])=>{cast[sp]?cast[sp]={...cast[sp],...info}:cast[sp]=info});const emotions_map={};return lines.forEach((l,i)=>{l.type==="dialog"&&l.emotion&&(emotions_map[i]=l.emotion)}),{title,script,cast,emotions:emotions_map,notes:existing?existing.notes||{}:{},ignored:existing?existing.ignored||{}:{},hidden:existing?existing.hidden||{}:{},backend:existing&&existing.backend||"",narratorVoice:existing&&existing.narratorVoice||"",lineIndex:existing&&existing.lineIndex||0,clips:existing?existing.clips||[]:[],created:existing?existing.created:new Date,updated:new Date}}let _abRehSaveTimer=null;async function audiobookSaveAsRehearsal(opts){const silent=opts&&opts.silent,segs=_audiobook.segments;if(!segs||!segs.length){silent||toast("No segments to save","error");return}if(typeof rehDbAdd!="function"){silent||toast("Rehearser DB not available","error");return}try{const rec=await _audiobookBuildRehRecord(segs);_audiobook.rehId?(rec.id=_audiobook.rehId,await rehDbPut(rec)):_audiobook.rehId=await rehDbAdd(rec),typeof renderLibraryList=="function"&&renderLibraryList(),silent||(_abClearDraft(),toast("Saved as Script Rehearsal","success"))}catch(e){silent?console.warn("[audiobook] auto-save failed:",e):toast("Failed to save rehearsal: "+e.message,"error")}}function _audiobookDebouncedSave(){clearTimeout(_abRehSaveTimer),_abRehSaveTimer=setTimeout(()=>audiobookSaveAsRehearsal({silent:!0}),1500)}const CS_CHUNK_CHARS=4e3,_cs={running:!1,cancel:!1,cache:{}},CS_DEFAULT_PROMPT=`You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich character sheets passage by passage as a book is read. Extract playable, action-oriented sheets an actor can use to immediately know how to PLAY the character. +`}return{script:script.trim(),emotions}}async function audiobookOpenInRehearser(script,title,dialogueEmotions){$("reh-script-text")&&($("reh-script-text").value=script),$("reh-script-title")&&($("reh-script-title").value=title),typeof navTo=="function"&&navTo("s-rehearser");const btn=$("reh-parse-btn");btn?btn.click():typeof parseScript=="function"&&(rehState.lines=parseScript(script));let speakers=0,lines=0;if(window.rehState&&Array.isArray(rehState.lines)){let k=0;rehState.lines.forEach(l=>{if(l.type==="dialog"){const e=dialogueEmotions[k++];e&&(l.emotion=e),lines++}}),speakers=Object.keys(rehState.cast||{}).filter(s=>!String(s).includes("NARRATOR")).length}let saved=!1;if(typeof saveToLibrary=="function")try{rehState.savedId=null,await saveToLibrary(),saved=!0}catch{}toast(`Cast ${speakers} character${speakers!==1?"s":""} \xB7 ${lines} lines`+(saved?" \u2014 saved to Rehearser \u2192 Bibliothek":""),"success")}function audiobookIsChapter(line){if(line.type==="act"||line.type==="scene")return!0;const t=(typeof stripMarkdown=="function"?stripMarkdown(line.text||""):line.text||"").trim();return!t||t.length>60?!1:/^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t)}function audiobookLineVoice(l){if(l.type==="dialog"){const c=rehState.cast[l.speaker]||{};return{voice:c.voice,instruct:typeof _buildInstruct=="function"?_buildInstruct(c.instruct,l.emotion):""}}return{voice:rehState.narratorVoice,instruct:""}}async function audiobookExport(){var _a2,_b2;if(_audiobook.running)return;if(!window.rehState||!(rehState.lines||[]).length){toast("Open a script in the rehearser first","error");return}if(!rehState.backend){toast("Select a TTS backend in the rehearser first","error");return}typeof _ensureNarrator=="function"&&_ensureNarrator();const speakable=i=>{const l=rehState.lines[i];if(!l||l.ignored||l.hidden)return!1;if(l.type==="dialog"){const c=rehState.cast[l.speaker];return!!(c&&c.voice&&c.voice!=="me")}return!!(rehState.narratorVoice&&(l.text||"").trim())},buckets=[];let cur=null;rehState.lines.forEach((l,i)=>{audiobookIsChapter(l)&&(cur={title:(typeof stripMarkdown=="function"?stripMarkdown(l.text):l.text).trim().slice(0,50),idx:[]},buckets.push(cur)),speakable(i)&&(cur||(cur={title:"",idx:[]},buckets.push(cur)),cur.idx.push(i))});const allIdx=buckets.flatMap(b=>b.idx);if(!allIdx.length){toast("Nothing to synthesise \u2014 cast voices first","error");return}_audiobook.running=!0,_audiobook.cancel=!1;const prog=audiobookProgress(allIdx.length),wavClips=new Map,failedLines=[];let done=0;const queue=allIdx.slice(),worker=async()=>{for(;queue.length&&!_audiobook.cancel;){const i=queue.shift();if(rehState.synthCache.has(i)&&!rehState.staleLines.has(i)){wavClips.set(i,rehState.synthCache.get(i)),prog.update(++done,`Synthesising line ${done} / ${allIdx.length}\u2026`);continue}const l=rehState.lines[i],{voice,instruct}=audiobookLineVoice(l),text=typeof _rehInlineTone=="function"?_rehInlineTone(stripMarkdown(l.text),l.emotion):typeof stripMarkdown=="function"?stripMarkdown(l.text):l.text;try{let blob=null,cacheKey=null,book=null;typeof _lineAudioCacheKey=="function"&&(book=_lineAudioBookName(),cacheKey=await _lineAudioCacheKey(text,voice,instruct),blob=await _lineAudioCacheGet(book,cacheKey)),blob||(blob=await fetchTtsPreviewBlob(voice,text,"wav",instruct,rehState.backend),cacheKey&&_lineAudioCachePut(book,cacheKey,blob)),wavClips.set(i,blob),rehState.synthCache.set(i,blob)}catch(e){failedLines.push(i),console.error("[audiobook export] synth failed for line",i,e)}prog.update(++done,`Synthesising line ${done} / ${allIdx.length}\u2026`)}};try{await Promise.all(Array.from({length:Math.min(2,allIdx.length)},worker))}finally{prog.done(),_audiobook.running=!1}if(_audiobook.cancel){toast("Export cancelled","error");return}if(failedLines.length){toast(failedLines.length+" line(s) failed to synthesise \u2014 retry them (Script Rehearser \u2192 re-synthesise stale) before exporting, or they will silently drop from the audiobook","error");return}const title=typeof readerSafeName=="function"?readerSafeName(((_a2=$("reh-script-title"))==null?void 0:_a2.value)||"Audiobook"):((_b2=$("reh-script-title"))==null?void 0:_b2.value)||"Audiobook",realChapters=buckets.filter(b=>b.title).length>0;let files=0;const savedFiles=[],bookForExport=_lineAudioBookName(),encMsg=$("audiobook-msg");for(let c=0;cwavClips.get(i)).filter(Boolean);if(!blobs.length)continue;encMsg&&(encMsg.textContent=`Merging chapter ${c+1} / ${buckets.length}\u2026`);const mergedWav=await mergeWavBlobs(blobs);encMsg&&(encMsg.textContent=`Encoding chapter ${c+1} / ${buckets.length}\u2026`);let blob=mergedWav;try{const encResp=await fetch("/api/audio/encode-mp3",{method:"POST",body:mergedWav});encResp.ok?blob=await encResp.blob():console.error("[audiobook export] mp3 encode failed, shipping wav instead:",encResp.status)}catch(e){console.error("[audiobook export] mp3 encode request failed, shipping wav instead:",e)}const ext=blob===mergedWav?"wav":"mp3",ch=buckets[c].title?" "+readerSafeName(buckets[c].title):"",name=realChapters||buckets.length>1?`${title} - ${String(c+1).padStart(2,"0")}${ch}.${ext}`:`${title}.${ext}`;typeof readerDownload=="function"&&readerDownload(blob,name);try{(await fetch(`/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}`,{method:"POST",body:blob})).ok&&savedFiles.push({name,url:`/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}`})}catch{}files++,await new Promise(r=>setTimeout(r,400))}toast("Exported audiobook \xB7 "+files+(realChapters?" chapter file(s)":" file(s)"),"success"),savedFiles.length&&typeof _abShowExportResults=="function"&&_abShowExportResults(bookForExport,savedFiles)}function _abShowExportResults(book,files,opts={}){var _a2;(_a2=document.getElementById("ab-export-results"))==null||_a2.remove();const ov=document.createElement("div");ov.id="ab-export-results",ov.className="audiobook-overlay";const zipUrl=`/api/audiobook-export/${encodeURIComponent(book)}/zip`;ov.innerHTML=`
+
${opts.browsing?"Saved audiobook exports":"Audiobook exported"}
+

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.

+
+ ${files.map(f=>` +
+ + ${escHtml(f.name)} + Download +
`).join("")} +
+ ${files.length>1?``:""} +
`,document.body.appendChild(ov);const close=()=>ov.remove();ov.querySelector("#ab-export-close").addEventListener("click",close),ov.addEventListener("click",e=>{e.target===ov&&close()})}async function audiobookBrowseExports(){var _a2;const book=typeof _lineAudioBookName=="function"?_lineAudioBookName():((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Untitled";try{const r=await fetch(`/api/audiobook-export/${encodeURIComponent(book)}`);if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const d=await r.json();if(!d.files||!d.files.length){toast('No saved exports yet for this book \u2014 run "Audiobook" first',"info");return}const files=d.files.map(f=>({name:f.name,url:`/api/audiobook-export/${encodeURIComponent(book)}/${encodeURIComponent(f.name)}`}));_abShowExportResults(book,files,{browsing:!0})}catch(e){toast("Could not load saved exports: "+(e.message||e),"error")}}(_mc=$("reh-tb-browse-exports"))==null||_mc.addEventListener("click",audiobookBrowseExports),(_nc=$("reader-audiobook-btn"))==null||_nc.addEventListener("click",audiobookOpenCastView),(_oc=$("reh-tb-audiobook"))==null||_oc.addEventListener("click",audiobookExport);async function _audiobookBuildRehRecord(segs){const{script,emotions}=audiobookBuildScript(segs),title=typeof readerState!="undefined"&&readerState.title?readerState.title:"Audiobook",lines=typeof parseScript=="function"?parseScript(script):[];if(emotions&&emotions.length){let eIdx=0;lines.forEach(l=>{l.type==="dialog"&&eIdx{cast[sp]={voice:def.voice,color:def.color,instruct:"",lang:"",gender:"",tags:"",soul:"",ignored:!1,hidden:!1,voiceData:null}})}let existing=null;if(_audiobook.rehId&&typeof window.rehDbGetById=="function")try{existing=await window.rehDbGetById(_audiobook.rehId)}catch{}existing&&existing.cast&&Object.entries(existing.cast).forEach(([sp,info])=>{cast[sp]?cast[sp]={...cast[sp],...info}:cast[sp]=info});const emotions_map={};return lines.forEach((l,i)=>{l.type==="dialog"&&l.emotion&&(emotions_map[i]=l.emotion)}),{title,script,cast,emotions:emotions_map,notes:existing?existing.notes||{}:{},ignored:existing?existing.ignored||{}:{},hidden:existing?existing.hidden||{}:{},backend:existing&&existing.backend||"",narratorVoice:existing&&existing.narratorVoice||"",lineIndex:existing&&existing.lineIndex||0,clips:existing?existing.clips||[]:[],created:existing?existing.created:new Date,updated:new Date}}let _abRehSaveTimer=null;async function audiobookSaveAsRehearsal(opts){const silent=opts&&opts.silent,segs=_audiobook.segments;if(!segs||!segs.length){silent||toast("No segments to save","error");return}if(typeof rehDbAdd!="function"){silent||toast("Rehearser DB not available","error");return}try{const rec=await _audiobookBuildRehRecord(segs);_audiobook.rehId?(rec.id=_audiobook.rehId,await rehDbPut(rec)):_audiobook.rehId=await rehDbAdd(rec),typeof renderLibraryList=="function"&&renderLibraryList(),silent||(_abClearDraft(),toast("Saved as Script Rehearsal","success"))}catch(e){silent?console.warn("[audiobook] auto-save failed:",e):toast("Failed to save rehearsal: "+e.message,"error")}}function _audiobookDebouncedSave(){clearTimeout(_abRehSaveTimer),_abRehSaveTimer=setTimeout(()=>audiobookSaveAsRehearsal({silent:!0}),1500)}const CS_CHUNK_CHARS=4e3,_cs={running:!1,cancel:!1,cache:{}},CS_DEFAULT_PROMPT=`You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich character sheets passage by passage as a book is read. Extract playable, action-oriented sheets an actor can use to immediately know how to PLAY the character. PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or blank out a field you cannot improve. In this cast-character pass, ONLY refine the already-casted roster and do NOT invent new profiles, places, or institutions. The text may be a focused evidence window around a mention, so use the nearby paragraphs as context. Leave a field empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'. For each character output these fields: - name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title. -- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain. - - first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty. +- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain. + - first_name, last_name: split the character identity when known. Leave unknown parts empty. - title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter'). - profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest'). +- age_estimate: estimated age or age range, if inferable +- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant +- languages: spoken languages / dialects / tongues, comma-separated if multiple +- nationality_background: homeland, culture, origin, or social background if known +- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.) - archetype: a two-word role summary (e.g. 'Ruthless Scholar') - gender: 'male', 'female', or 'nonbinary' \u2014 as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable. -- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system. -- clothing: distinctive clothing, armour, accessories \u2014 as observed in the text +- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known. +- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look - alignment: strict moral code + the one line they will never cross - moral_alignment_score: integer 0\u2013100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous - arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex' @@ -1459,6 +1480,7 @@ For each character output these fields: - motivation: the inner drive \u2014 WHY they pursue what they pursue (distinct from the win condition) - fears: their deepest fears, phobias or dread - mannerisms: habitual gestures, tics, body language, habits and quirks +- communication_style: how they communicate socially \u2014 blunt, formal, warm, guarded, sarcastic, etc. - voice_pattern: speech style \u2014 accent, pacing, vocabulary, register and verbal tics (for voice casting) - voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers. - image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'. @@ -1466,22 +1488,25 @@ For each character output these fields: - secret: dark secret or fatal flaw - conflict_style: fight, flight, or manipulate \u2014 how they act when cornered - win_condition: the specific event that would make them feel they have won +- reputation: how other characters or society see them +- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it +- notes: brief catch-all notes for useful details that do not fit elsewhere - tier: 'main' or 'supporting' - sources: array of {page, quote, line_hint} \u2014 the page number from the nearest [p.N] marker, a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition. -IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name. +IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Marcus', 'the Executioner', and 'Bloodfang' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title and keep one canonical name. Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships. Respond with STRICT JSON only: -{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]} +{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]} /no-think`;function csLlmUrl(){var _a2;const explicit=(_a2=$("reh-llm-url"))==null?void 0:_a2.value.trim();if(explicit)return explicit;const fromSettings=typeof _appSettings!="undefined"&&_appSettings&&_appSettings.llm_url?_appSettings.llm_url:"";return fromSettings&&!/localhost:11434|127\.0\.0\.1:11434/.test(fromSettings)?fromSettings:""}function csLlmModel(){var _a2;return((_a2=$("reh-llm-model"))==null?void 0:_a2.value)||""}function csLang(){var _a2;return((_a2=$("reh-design-lang"))==null?void 0:_a2.value)||""}function csReaderPageHost(){return $("reader-charsheets-panel")}function csReaderText(){var _a2,_b2,_c2;if(typeof readerScopeIndices!="function"||!((_a2=readerState==null?void 0:readerState.sentences)!=null&&_a2.length))return"";let out="",lastPage=-1;for(const i of readerScopeIndices()){const u=readerState.sentences[i],pg=(_c2=(_b2=u.words)==null?void 0:_b2[0])==null?void 0:_c2.page;readerState.mode==="pdf"&&pg!=null&&pg!==lastPage&&(out+=` [p.${pg+1}] `,lastPage=pg),out+=u.text+" "}return out.trim()}function csRehearserText(){if(!window.rehState||!(rehState.lines||[]).length)return"";let out="",page=1,started=!1;for(const l of rehState.lines){if(l.type==="pagebreak"){page++,out+=` [p.${page}] `;continue}const t=(typeof stripMarkdown=="function"?stripMarkdown(l.text||""):l.text||"").trim();t&&(started||(out+="[p.1] ",started=!0),out+=(l.type==="dialog"&&l.speaker?l.speaker+": ":"")+t+` -`)}return out.trim()}const CS_SCALAR_FIELDS=["aliases","first_name","last_name","full_name","title","profession","archetype","physical","clothing","alignment","arc_note","attribute_high","attribute_low","skills","capabilities","backstory","relationships","motivation","fears","mannerisms","voice_pattern","secret","conflict_style","win_condition","voice_design_prompt","image_prompt","silly_tavern_prompt","concept_art_prompt"],CS_DETAIL_FIELDS=CS_SCALAR_FIELDS.filter(f=>!["aliases","first_name","last_name","full_name","title"].includes(f)),CS_IDENTITY_FIELDS=["name","aliases","first_name","last_name","full_name","title"],CS_ALIAS_MAX_TOKENS=12,CS_ALIAS_MAX_CHARS=500,CS_SOURCE_FIELDS={physical:["physical","appearance","body","look"],clothing:["clothing","appearance","armour","armor","item"],relationships:["relationship","ally","rival","enemy","family"],motivation:["motivation","intention","goal","desire"],fears:["fear","dread"],mannerisms:["mannerism","habit","gesture","voice"],voice_pattern:["voice","speech","dialogue"],backstory:["backstory","origin","history"],alignment:["alignment","ethos","morality"],profession:["profession","occupation","job","role"],skills:["skill","capability","ability"],capabilities:["capability","ability","combat","magic"],secret:["secret","flaw"],conflict_style:["conflict","fight","flight","manipulate"],win_condition:["win","goal"]};function csExistingSummary(map){if(!map.size)return"";const KEY_FIELDS=["physical","profession","backstory","motivation","voice_pattern","relationships"];return[...map.values()].slice(0,40).map(s=>{const missing=KEY_FIELDS.filter(f=>!(s[f]||"").trim()),suffix=missing.lengths.trim()).filter(Boolean).filter(s=>s.length<=80&&!/^needs?:/i.test(s)&&!/^complete$/i.test(s));return opts.aliases&&(raw.length>CS_ALIAS_MAX_CHARS||parts.length>CS_ALIAS_MAX_TOKENS)?[]:parts.slice(0,opts.aliases?CS_ALIAS_MAX_TOKENS:void 0)}function csCleanRoster(names){const out=[],seen=new Set;return(names||[]).forEach(n=>{const name=_csStr(n).trim(),key=name.toLowerCase();!name||seen.has(key)||key==="narrator"||/^unknown|unbekannt$/i.test(name)||(seen.add(key),out.push(name))}),out}function csRosterKey(v){return String(v||"").trim().toLowerCase()}function csKnownReaderRoster(){const names=[],add=name=>{name=_csStr(name).trim();const key=name.toLowerCase();!name||key==="narrator"||/^unknown|unbekannt$/i.test(name)||names.some(n=>n.toLowerCase()===key)||names.push(name)},ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook;return ab&&((ab.roster||[]).forEach(add),[ab.segments,ab.liveSegments].forEach(segs=>{(segs||[]).forEach(s=>{(s==null?void 0:s.type)==="dialogue"&&add(s.speaker)})})),document.querySelectorAll("#ab-cv-chars .ab-char-item[data-name], #audiobook-roster option[value]").forEach(el=>{add(el.dataset.name||el.value||el.textContent)}),csCleanRoster(names)}function csBlankSheet(name){return{name,aliases:"",first_name:"",last_name:"",full_name:"",title:"",profession:"",archetype:"",gender:"",physical:"",clothing:"",alignment:"",arc_note:"",attribute_high:"",attribute_low:"",skills:"",capabilities:"",backstory:"",relationships:"",motivation:"",fears:"",mannerisms:"",voice_pattern:"",secret:"",conflict_style:"",win_condition:"",voice_design_prompt:"",image_prompt:"",inventory:[],sources:[],moral_alignment_score:50,arc_direction:"neutral",tier:"supporting"}}function csSeedSheet(raw){const name=_csStr(raw==null?void 0:raw.name).trim(),seed=csBlankSheet(name);if(!name)return seed;const src=raw!=null&&raw.sheet&&typeof raw.sheet=="object"?raw.sheet:raw;if(CS_SCALAR_FIELDS.forEach(f=>{src&&src[f]!=null&&(seed[f]=_csStr(src[f]))}),Array.isArray(src==null?void 0:src.inventory)?seed.inventory=[...src.inventory]:typeof(src==null?void 0:src.inventory)=="string"&&(seed.inventory=src.inventory.split(",").map(x=>x.trim()).filter(Boolean)),Array.isArray(src==null?void 0:src.sources)&&(seed.sources=src.sources.map(item=>item&&typeof item=="object"?{page:item.page!=null?item.page:null,quote:_csStr(item.quote).slice(0,240),line_hint:_csStr(item.line_hint).slice(0,60)}:null).filter(Boolean)),src!=null&&src.tier&&(seed.tier=String(src.tier).toLowerCase().startsWith("main")?"main":"supporting"),(src==null?void 0:src.moral_alignment_score)!=null){const mas=parseInt(src.moral_alignment_score,10);Number.isNaN(mas)||(seed.moral_alignment_score=Math.max(0,Math.min(100,mas)))}return src!=null&&src.arc_direction&&(seed.arc_direction=src.arc_direction),src!=null&&src.gender&&(seed.gender=String(src.gender).trim().toLowerCase()),(src==null?void 0:src.line_count)!=null&&(seed.line_count=src.line_count),seed}function csNameTokens(sheet){const out=new Set;for(const f of CS_IDENTITY_FIELDS){const raw=f==="name"?sheet==null?void 0:sheet.name:sheet==null?void 0:sheet[f];csSplitIdentityTokens(raw,{aliases:f==="aliases"}).forEach(s=>out.add(s.toLowerCase()))}return out}function csSheetMatchesRoster(sheet,roster=[]){const allowed=new Set((roster||[]).map(csRosterKey).filter(Boolean));if(!allowed.size)return!0;for(const token of csNameTokens(sheet))for(const name of allowed)if(token===name||token.includes(name)||name.includes(token))return!0;return!1}function csIdentityNeedles(value){return csSplitIdentityTokens(value,{aliases:!0}).filter(v=>{const n=String(v||"").trim();return n&&n.length>1&&!/^(die|der|das|den|dem|des|ein|eine|einer|er|sie|es|ich|du|wir|ihr)$/i.test(n)})}function csRecordNeedles(rec){var _a2,_b2,_c2,_d2;const seen=new Set,out=[],add=value=>{csIdentityNeedles(value).forEach(v=>{const key=v.toLowerCase();seen.has(key)||(seen.add(key),out.push(v))})};return add(rec==null?void 0:rec.name),add((_a2=rec==null?void 0:rec.sheet)==null?void 0:_a2.aliases),add((_b2=rec==null?void 0:rec.sheet)==null?void 0:_b2.full_name),add([(_c2=rec==null?void 0:rec.sheet)==null?void 0:_c2.first_name,(_d2=rec==null?void 0:rec.sheet)==null?void 0:_d2.last_name].filter(Boolean).join(" ")),out}function csParagraphBlocks(text){return String(text||"").replace(/\r\n/g,` +`)}return out.trim()}const CS_SCALAR_FIELDS=["aliases","first_name","last_name","full_name","title","profession","age_estimate","race_species","languages","nationality_background","social_class","archetype","physical","clothing","alignment","arc_note","attribute_high","attribute_low","skills","capabilities","backstory","relationships","motivation","fears","mannerisms","communication_style","voice_pattern","secret","conflict_style","win_condition","reputation","religious_beliefs","notes","voice_design_prompt","image_prompt","silly_tavern_prompt","concept_art_prompt"],CS_DETAIL_FIELDS=CS_SCALAR_FIELDS.filter(f=>!["aliases","first_name","last_name","full_name","title"].includes(f)),CS_IDENTITY_FIELDS=["name","aliases","first_name","last_name","full_name"],CS_ALIAS_MAX_TOKENS=12,CS_ALIAS_MAX_CHARS=500,CS_SOURCE_FIELDS={physical:["physical","appearance","body","look"],clothing:["clothing","appearance","armour","armor","item"],age_estimate:["age","older","young","teen","adult"],race_species:["race","species","kind","orc","elf","human","vampire"],languages:["language","languages","tongue","dialect","speech"],nationality_background:["nationality","background","origin","homeland","culture"],social_class:["class","social","status","rank"],relationships:["relationship","ally","rival","enemy","family"],motivation:["motivation","intention","goal","desire"],fears:["fear","dread"],mannerisms:["mannerism","habit","gesture","voice"],communication_style:["communication","communicate","demeanor","style"],voice_pattern:["voice","speech","dialogue"],backstory:["backstory","origin","history"],alignment:["alignment","ethos","morality"],profession:["profession","occupation","job","role"],skills:["skill","capability","ability"],capabilities:["capability","ability","combat","magic"],reputation:["reputation","known as","status","perceived"],religious_beliefs:["religion","faith","belief","priest","god","gods","temple"],notes:["note","misc","miscellaneous"],secret:["secret","flaw"],conflict_style:["conflict","fight","flight","manipulate"],win_condition:["win","goal"]};function csExistingSummary(map){if(!map.size)return"";const KEY_FIELDS=["age_estimate","race_species","languages","nationality_background","social_class","profession","physical","clothing","communication_style","voice_pattern","backstory","motivation","relationships","reputation","religious_beliefs","notes"];return[...map.values()].slice(0,40).map(s=>{const missing=KEY_FIELDS.filter(f=>!(s[f]||"").trim()),suffix=missing.lengths.trim()).filter(Boolean).filter(s=>s.length<=80&&!/^needs?:/i.test(s)&&!/^complete$/i.test(s));return opts.aliases&&(raw.length>CS_ALIAS_MAX_CHARS||parts.length>CS_ALIAS_MAX_TOKENS)?[]:parts.slice(0,opts.aliases?CS_ALIAS_MAX_TOKENS:void 0)}function csCleanRoster(names){const out=[],seen=new Set;return(names||[]).forEach(n=>{const name=_csStr(n).trim(),key=name.toLowerCase();!name||seen.has(key)||key==="narrator"||/^unknown|unbekannt$/i.test(name)||(seen.add(key),out.push(name))}),out}function csRosterKey(v){return String(v||"").trim().toLowerCase()}function csKnownReaderRoster(){const names=[],add=name=>{name=_csStr(name).trim();const key=name.toLowerCase();!name||key==="narrator"||/^unknown|unbekannt$/i.test(name)||names.some(n=>n.toLowerCase()===key)||names.push(name)},ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook;return ab&&((ab.roster||[]).forEach(add),[ab.segments,ab.liveSegments].forEach(segs=>{(segs||[]).forEach(s=>{(s==null?void 0:s.type)==="dialogue"&&add(s.speaker)})})),document.querySelectorAll("#ab-cv-chars .ab-char-item[data-name], #audiobook-roster option[value]").forEach(el=>{add(el.dataset.name||el.value||el.textContent)}),csCleanRoster(names)}function csBlankSheet(name){return{name,aliases:"",first_name:"",last_name:"",full_name:"",title:"",profession:"",age_estimate:"",race_species:"",languages:"",nationality_background:"",social_class:"",archetype:"",gender:"",physical:"",clothing:"",alignment:"",arc_note:"",attribute_high:"",attribute_low:"",skills:"",capabilities:"",backstory:"",relationships:"",motivation:"",fears:"",mannerisms:"",communication_style:"",voice_pattern:"",secret:"",conflict_style:"",win_condition:"",reputation:"",religious_beliefs:"",notes:"",voice_design_prompt:"",image_prompt:"",inventory:[],sources:[],moral_alignment_score:50,arc_direction:"neutral",tier:"supporting"}}function csSeedSheet(raw){const name=_csStr(raw==null?void 0:raw.name).trim(),seed=csBlankSheet(name);if(!name)return seed;raw!=null&&raw.image&&(seed._image=raw.image);const src=raw!=null&&raw.sheet&&typeof raw.sheet=="object"?raw.sheet:raw;if(CS_SCALAR_FIELDS.forEach(f=>{src&&src[f]!=null&&(seed[f]=_csStr(src[f]))}),Array.isArray(src==null?void 0:src.inventory)?seed.inventory=[...src.inventory]:typeof(src==null?void 0:src.inventory)=="string"&&(seed.inventory=src.inventory.split(",").map(x=>x.trim()).filter(Boolean)),Array.isArray(src==null?void 0:src.sources)&&(seed.sources=src.sources.map(item=>item&&typeof item=="object"?{page:item.page!=null?item.page:null,quote:_csStr(item.quote).slice(0,240),line_hint:_csStr(item.line_hint).slice(0,60)}:null).filter(Boolean)),src!=null&&src.tier&&(seed.tier=String(src.tier).toLowerCase().startsWith("main")?"main":"supporting"),(src==null?void 0:src.moral_alignment_score)!=null){const mas=parseInt(src.moral_alignment_score,10);Number.isNaN(mas)||(seed.moral_alignment_score=Math.max(0,Math.min(100,mas)))}return src!=null&&src.arc_direction&&(seed.arc_direction=src.arc_direction),src!=null&&src.gender&&(seed.gender=String(src.gender).trim().toLowerCase()),(src==null?void 0:src.line_count)!=null&&(seed.line_count=src.line_count),seed}function csNameTokens(sheet){const out=new Set;for(const f of CS_IDENTITY_FIELDS){const raw=f==="name"?sheet==null?void 0:sheet.name:sheet==null?void 0:sheet[f];csSplitIdentityTokens(raw,{aliases:f==="aliases"}).forEach(s=>out.add(s.toLowerCase()))}return out}function csSheetMatchesRoster(sheet,roster=[]){const allowed=new Set((roster||[]).map(csRosterKey).filter(Boolean));if(!allowed.size)return!0;for(const token of csNameTokens(sheet))for(const name of allowed)if(token===name||token.includes(name)||name.includes(token))return!0;return!1}function csIdentityNeedles(value){return csSplitIdentityTokens(value,{aliases:!0}).filter(v=>{const n=String(v||"").trim();return n&&n.length>1&&!/^(die|der|das|den|dem|des|ein|eine|einer|er|sie|es|ich|du|wir|ihr)$/i.test(n)})}function csRecordNeedles(rec){var _a2,_b2,_c2;const seen=new Set,out=[],add=value=>{csIdentityNeedles(value).forEach(v=>{const key=v.toLowerCase();seen.has(key)||(seen.add(key),out.push(v))})};return add(rec==null?void 0:rec.name),add((_a2=rec==null?void 0:rec.sheet)==null?void 0:_a2.aliases),add([(_b2=rec==null?void 0:rec.sheet)==null?void 0:_b2.first_name,(_c2=rec==null?void 0:rec.sheet)==null?void 0:_c2.last_name].filter(Boolean).join(" ")),out}function csParagraphBlocks(text){return String(text||"").replace(/\r\n/g,` `).split(/\n\s*\n+/).map(s=>s.trim()).filter(Boolean)}function csReaderParagraphBlocks(){var _a2,_b2,_c2;if(readerState.mode==="text"&&readerState.docText)return csParagraphBlocks(readerState.docText);const base=Array.isArray(readerState.baseSentences)&&readerState.baseSentences.length?readerState.baseSentences:Array.isArray(readerState.sentences)?readerState.sentences:[];if(!base.length)return csParagraphBlocks(csReaderText());const paras=[];let cur="",curPage=null;for(const s of base){const page=(_c2=(_b2=(_a2=s==null?void 0:s.words)==null?void 0:_a2[0])==null?void 0:_b2.page)!=null?_c2:null,pageChanged=readerState.mode==="pdf"&&page!=null&&curPage!=null&&page!==curPage;(s!=null&&s.paraStart||pageChanged)&&cur.trim()&&(paras.push(cur.trim()),cur=""),!cur&&readerState.mode==="pdf"&&page!=null&&(cur+=`[p.${page+1}] `),cur+=(cur&&!/\s$/.test(cur)?" ":"")+String((s==null?void 0:s.text)||"").trim(),curPage=page!=null?page:curPage}return cur.trim()&¶s.push(cur.trim()),paras}function csParagraphHasNeedle(paragraph,needle){const p=String(paragraph||""),n=String(needle||"").trim();if(!n||n.length<2)return!1;const lowerP=p.toLowerCase(),lowerN=n.toLowerCase();if(lowerN.includes(" "))return lowerP.includes(lowerN);const esc=lowerN.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(^|[^\\p{L}\\p{N}_])${esc}(?=$|[^\\p{L}\\p{N}_])`,"iu").test(p)}function csEvidenceWindowText(text,needles,before=2,after=2){const paras=Array.isArray(text)?text.slice():csParagraphBlocks(text);if(!paras.length)return String(text||"").trim();const hits=new Set;return paras.forEach((para,idx)=>{if((needles||[]).some(n=>csParagraphHasNeedle(para,n)))for(let i=idx-before;i<=idx+after;i++)i>=0&&ia-b).map(i=>paras[i]).join(` -`).trim():""}function csFindMergeKey(map,sheet){const incoming=csNameTokens(sheet);if(!incoming.size)return(sheet.name||"").trim().toLowerCase();for(const[key,existing]of map.entries()){const existingNames=csNameTokens(existing);for(const n of incoming)if(existingNames.has(n))return key}return(sheet.name||"").trim().toLowerCase()}function csMergeAliases(existing,incoming){const names=new Map,add=v=>csSplitIdentityTokens(v,{aliases:!0}).forEach(s=>names.set(s.toLowerCase(),s));return add(existing.aliases),add(incoming.aliases),incoming.name&&incoming.name!==existing.name&&add(incoming.name),[...names.values()].filter(n=>n.toLowerCase()!==String(existing.name||"").toLowerCase()).join(", ")}function csMerge(map,sheets){const changed=[];for(const s of sheets){const name=(s.name||"").trim();if(!name)continue;s.aliases=csMergeAliases({name,aliases:""},s);const key=csFindMergeKey(map,s);if(!map.has(key)){const copy={...s,name,inventory:[...s.inventory||[]],sources:[...s.sources||[]]};CS_SCALAR_FIELDS.forEach(f=>{copy[f]=_csStr(copy[f])}),map.set(key,copy),changed.push({key,name:copy.name,status:"new"});continue}const e=map.get(key),before=JSON.stringify(e),oldAliases=e.aliases;CS_SCALAR_FIELDS.forEach(f=>{const sv=_csStr(s[f]);sv.length>(e[f]||"").length&&(e[f]=sv)}),e.aliases=csMergeAliases({...e,aliases:oldAliases},s),s.tier==="main"&&(e.tier="main"),s.moral_alignment_score!=null&&(e.moral_alignment_score=e.moral_alignment_score!=null?Math.round((e.moral_alignment_score+s.moral_alignment_score)/2):s.moral_alignment_score),s.arc_direction&&s.arc_direction!=="neutral"&&(e.arc_direction=s.arc_direction),s.gender&&!e.gender&&(e.gender=s.gender),(s.inventory||[]).forEach(it=>{it&&!e.inventory.includes(it)&&e.inventory.length<3&&e.inventory.push(it)}),(s.sources||[]).forEach(src=>{src&&src.quote&&e.sources.length<12&&!e.sources.some(x=>x.quote===src.quote)&&e.sources.push(src)}),JSON.stringify(e)!==before&&changed.push({key,name:e.name,status:"updated"})}return changed}function csProgressSnapshot(map,changed=[]){const changedByKey=new Map(changed.map(x=>[x.key,x.status]));return[...map.entries()].map(([key,s])=>{const filled=CS_DETAIL_FIELDS.filter(f=>_csStr(s[f]).trim()).length;return{key,name:s.name||key,alias:[s.full_name,s.title,s.profession].filter(Boolean).join(" \xB7 "),status:changedByKey.get(key)||"",filled,main:s.tier==="main"}}).sort((a,b)=>(b.status?1:0)-(a.status?1:0)||b.filled-a.filled||a.name.localeCompare(b.name))}async function csGenerateStream(body,onDelta,outerSignal,idleTimeoutMs=6e4){const ctl=new AbortController,onAbort=()=>ctl.abort();outerSignal&&(outerSignal.aborted?ctl.abort():outerSignal.addEventListener("abort",onAbort,{once:!0}));let idleTimer=null;const armIdle=()=>{clearTimeout(idleTimer),idleTimer=setTimeout(()=>ctl.abort(),idleTimeoutMs)};try{armIdle();const r=await fetch("/api/character-sheets/stream",{method:"POST",headers:{"Content-Type":"application/json"},signal:ctl.signal,body:JSON.stringify(body)});if(!r.ok||!r.body)throw new Error("stream HTTP "+r.status);const reader=r.body.getReader(),dec=new TextDecoder;let buf="",result=null;for(;;){const{done,value}=await reader.read();if(done)break;armIdle(),buf+=dec.decode(value,{stream:!0});let at;for(;(at=buf.indexOf(` +`).trim():""}function csFindMergeKey(map,sheet){const incoming=csNameTokens(sheet);if(!incoming.size)return(sheet.name||"").trim().toLowerCase();for(const[key,existing]of map.entries()){const existingNames=csNameTokens(existing);for(const n of incoming)if(existingNames.has(n))return key}return(sheet.name||"").trim().toLowerCase()}function csMergeAliases(existing,incoming){const names=new Map,add=v=>csSplitIdentityTokens(v,{aliases:!0}).forEach(s=>names.set(s.toLowerCase(),s));return add(existing.aliases),add(incoming.aliases),incoming.name&&incoming.name!==existing.name&&add(incoming.name),[...names.values()].filter(n=>n.toLowerCase()!==String(existing.name||"").toLowerCase()).join(", ")}function csMerge(map,sheets){const changed=[];for(const s of sheets){const name=(s.name||"").trim();if(!name)continue;s.aliases=csMergeAliases({name,aliases:""},s);const key=csFindMergeKey(map,s);if(!map.has(key)){const copy={...s,name,inventory:[...s.inventory||[]],sources:[...s.sources||[]]};CS_SCALAR_FIELDS.forEach(f=>{copy[f]=_csStr(copy[f])}),map.set(key,copy),changed.push({key,name:copy.name,status:"new"});continue}const e=map.get(key),before=JSON.stringify(e),oldAliases=e.aliases;CS_SCALAR_FIELDS.forEach(f=>{const sv=_csStr(s[f]);sv.length>(e[f]||"").length&&(e[f]=sv)}),e.aliases=csMergeAliases({...e,aliases:oldAliases},s),s.tier==="main"&&(e.tier="main"),s.moral_alignment_score!=null&&(e.moral_alignment_score=e.moral_alignment_score!=null?Math.round((e.moral_alignment_score+s.moral_alignment_score)/2):s.moral_alignment_score),s.arc_direction&&s.arc_direction!=="neutral"&&(e.arc_direction=s.arc_direction),s.gender&&!e.gender&&(e.gender=s.gender),(s.inventory||[]).forEach(it=>{it&&!e.inventory.includes(it)&&e.inventory.length<3&&e.inventory.push(it)}),(s.sources||[]).forEach(src=>{src&&src.quote&&e.sources.length<12&&!e.sources.some(x=>x.quote===src.quote)&&e.sources.push(src)}),JSON.stringify(e)!==before&&changed.push({key,name:e.name,status:"updated"})}return changed}function csConsolidateAliasDuplicates(map,establishedKeys,establishedCreated){let changed=!0;for(;changed;){changed=!1;for(const[key,s]of map){const dupKey=csSplitIdentityTokens(s.aliases,{aliases:!0}).map(a=>a.toLowerCase()).find(a=>a!==key&&map.has(a));if(!dupKey)continue;const other=map.get(dupKey),sEst=establishedKeys.has(key),oEst=establishedKeys.has(dupKey);let keepKey=key,dropKey=dupKey;if(oEst&&!sEst)keepKey=dupKey,dropKey=key;else if(sEst===oEst){const filled=sh=>CS_DETAIL_FIELDS.filter(f=>_csStr(sh[f]).trim()).length,sFilled=filled(s),oFilled=filled(other);if(oFilled>sFilled)keepKey=dupKey,dropKey=key;else if(oFilled===sFilled&&establishedCreated){const sT=establishedCreated.get(key),oT=establishedCreated.get(dupKey);sT!=null&&oT!=null&&oT{const dv=_csStr(drop[f]);dv.length>(keep[f]||"").length&&(keep[f]=dv)}),keep.aliases=csMergeAliases({...keep,aliases:oldAliases},drop),drop.tier==="main"&&(keep.tier="main"),drop.line_count!=null&&(keep.line_count=Math.max(keep.line_count||0,drop.line_count)),(drop.inventory||[]).forEach(it=>{it&&!keep.inventory.includes(it)&&keep.inventory.length<3&&keep.inventory.push(it)}),(drop.sources||[]).forEach(src=>{src&&src.quote&&keep.sources.length<12&&!keep.sources.some(x=>x.quote===src.quote)&&keep.sources.push(src)}),map.set(keepKey,keep),map.delete(dropKey),changed=!0;break}}}function csProgressSnapshot(map,changed=[]){const changedByKey=new Map(changed.map(x=>[x.key,x.status]));return[...map.entries()].map(([key,s])=>{const filled=CS_DETAIL_FIELDS.filter(f=>_csStr(s[f]).trim()).length;return{key,name:s.name||key,alias:[s.title,s.profession].filter(Boolean).join(" \xB7 "),status:changedByKey.get(key)||"",filled,main:s.tier==="main",gender:s.gender||"",lineCount:Number(s.line_count)||0,image:s._image||""}}).sort((a,b)=>(b.status?1:0)-(a.status?1:0)||b.filled-a.filled||a.name.localeCompare(b.name))}async function csGenerateStream(body,onDelta,outerSignal,idleTimeoutMs=6e4){const ctl=new AbortController,onAbort=()=>ctl.abort();outerSignal&&(outerSignal.aborted?ctl.abort():outerSignal.addEventListener("abort",onAbort,{once:!0}));let idleTimer=null;const armIdle=()=>{clearTimeout(idleTimer),idleTimer=setTimeout(()=>ctl.abort(),idleTimeoutMs)};try{armIdle();const r=await fetch("/api/character-sheets/stream",{method:"POST",headers:{"Content-Type":"application/json"},signal:ctl.signal,body:JSON.stringify(body)});if(!r.ok||!r.body)throw new Error("stream HTTP "+r.status);const reader=r.body.getReader(),dec=new TextDecoder;let buf="",result=null;for(;;){const{done,value}=await reader.read();if(done)break;armIdle(),buf+=dec.decode(value,{stream:!0});let at;for(;(at=buf.indexOf(` -`))>=0;){const line=buf.slice(0,at).trim();if(buf=buf.slice(at+2),!line.startsWith("data:"))continue;let d;try{d=JSON.parse(line.slice(5))}catch{continue}if(d.t&&onDelta&&onDelta(d.t),d.error)throw new Error(d.error);d.done&&(result=d.result||null)}}if(!result)throw new Error("stream ended without result");return result}finally{clearTimeout(idleTimer),outerSignal&&outerSignal.removeEventListener("abort",onAbort)}}async function csGenerate(text,cacheKey,initialRoster,opts={}){if(_cs.running)return null;if(!text)return toast("Nothing to analyse","error"),null;const chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,CS_CHUNK_CHARS):[text];_cs.running=!0,_cs.cancel=!1;const prog=csProgress(chunks.length,opts.pageHost||null),llm_url=csLlmUrl(),model=csLlmModel();let language=csLang();if(typeof detectLang=="function"){const detected=detectLang(text);if(detected&&(!language||/^auto$/i.test(language)||language.toLowerCase()==="english"&&detected.toLowerCase()!=="english")){language=detected;const sel=$("reh-design-lang");if(sel){const o=[...sel.options].find(x=>x.value.toLowerCase()===detected.toLowerCase()||x.textContent.toLowerCase()===detected.toLowerCase());o&&(sel.value=o.value)}}}const map=new Map;(Array.isArray(opts.seedSheets)?opts.seedSheets:[]).forEach(seedRaw=>{const seed=csSeedSheet(seedRaw),name=String(seed.name||"").trim();name&&map.set(name.toLowerCase(),seed)});const roster=csCleanRoster(initialRoster),targetMode=roster.length>0,knownTotal=targetMode?roster.length:null;roster.forEach(name=>{const key=name.toLowerCase();map.has(key)||map.set(key,csBlankSheet(name))});try{for(let i=0;iCS_DETAIL_FIELDS.some(f=>(s[f]||"").trim())).length,charLabel=knownTotal?`${knownTotal} cast characters queued \xB7 ${detailed} profiles with details`:`${map.size} characters found`;prog.update(i,`Passage ${i+1} / ${chunks.length}\u2026`,charLabel,csProgressSnapshot(map),[...map.values()]),prog.startPassage(chunks[i]);try{const character_sheets_prompt=typeof _appSettings!="undefined"&&_appSettings.character_sheets_prompt||CS_DEFAULT_PROMPT,body={text:chunks[i],known_characters:roster.slice(0,120),target_mode:targetMode,existing:csExistingSummary(map),language,llm_url,model,character_sheets_prompt};let data=null,sawDelta=!1;try{data=await csGenerateStream(body,delta=>{sawDelta=!0,prog.thinking(delta)})}catch{sawDelta||prog.noStream();const r=await fetch("/api/character-sheets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){let detail=`HTTP ${r.status}`;try{const e=await r.json();detail=e.detail||e.error||detail}catch{}throw new Error(detail)}const raw=await r.text();try{data=JSON.parse(raw)}catch{throw new Error("Invalid JSON from server (passage may be too large)")}}const incomingSheets=targetMode?(data.sheets||[]).filter(s=>csSheetMatchesRoster(s,roster)):data.sheets||[],changed=csMerge(map,incomingSheets);changed.length&&prog.focus(changed[0].name),targetMode||((data.characters||[]).forEach(n=>{roster.some(r=>r.toLowerCase()===String(n||"").toLowerCase())||roster.push(n)}),(data.sheets||[]).forEach(s=>{csNameTokens(s).forEach(n=>{const display=[s.name,s.full_name,s.title,s.profession,s.aliases].filter(Boolean).join(", ").split(/[,;/|]/).map(x=>x.trim()).find(x=>x.toLowerCase()===n)||n;display&&!roster.some(r=>r.toLowerCase()===display.toLowerCase())&&roster.push(display)})}));const newDetailed=[...map.values()].filter(s=>CS_DETAIL_FIELDS.some(f=>(s[f]||"").trim())).length,newCharLabel=knownTotal?`${knownTotal} cast characters queued \xB7 ${newDetailed} profiles with details`:`${map.size} characters found`;prog.update(i+1,`Passage ${i+1} / ${chunks.length}\u2026`,newCharLabel,csProgressSnapshot(map,changed),[...map.values()])}catch(e){console.error("Character sheets passage",i+1,"failed:",e),toast("Passage "+(i+1)+" failed: "+(e.message||String(e)),"error")}}}finally{prog.done(),_cs.running=!1}if(_cs.cancel)return toast("Cancelled","error"),null;const sheets=[...map.values()];return cacheKey&&(_cs.cache[cacheKey]=sheets),sheets}function csProgress(total,hostEl=null){var _a2,_b2;const usingHost=!!hostEl;let root=usingHost?hostEl:document.getElementById("cs-progress");if(!root){if(usingHost)return null;root=document.createElement("div"),root.id="cs-progress",root.className="audiobook-overlay",document.body.appendChild(root)}if(!root.querySelector("#cs-progress-fill")){usingHost&&(root.innerHTML=""),root.innerHTML=`
+`))>=0;){const line=buf.slice(0,at).trim();if(buf=buf.slice(at+2),!line.startsWith("data:"))continue;let d;try{d=JSON.parse(line.slice(5))}catch{continue}if(d.t&&onDelta&&onDelta(d.t),d.error)throw new Error(d.error);d.done&&(result=d.result||null)}}if(!result)throw new Error("stream ended without result");return result}finally{clearTimeout(idleTimer),outerSignal&&outerSignal.removeEventListener("abort",onAbort)}}async function csGenerate(text,cacheKey,initialRoster,opts={}){if(_cs.running)return null;if(!text)return toast("Nothing to analyse","error"),null;const chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,CS_CHUNK_CHARS):[text];_cs.running=!0,_cs.cancel=!1;try{const prog=csProgress(chunks.length,opts.pageHost||null),llm_url=csLlmUrl(),model=csLlmModel();let language=csLang();if(typeof detectLang=="function"){const detected=detectLang(text);if(detected&&(!language||/^auto$/i.test(language)||language.toLowerCase()==="english"&&detected.toLowerCase()!=="english")){language=detected;const sel=$("reh-design-lang");if(sel){const o=[...sel.options].find(x=>x.value.toLowerCase()===detected.toLowerCase()||x.textContent.toLowerCase()===detected.toLowerCase());o&&(sel.value=o.value)}}}const map=new Map,lineCounts=opts.lineCounts instanceof Map?opts.lineCounts:new Map,seedSheets=Array.isArray(opts.seedSheets)?opts.seedSheets:[],establishedKeys=new Set,establishedCreated=new Map;seedSheets.forEach(seedRaw=>{const seed=csSeedSheet(seedRaw),name=String(seed.name||"").trim();if(!name)return;const lc=lineCounts.get(name.toLowerCase());lc!=null&&(seed.line_count=lc),map.set(name.toLowerCase(),seed),establishedKeys.add(name.toLowerCase());const created=seedRaw!=null&&seedRaw.created?new Date(seedRaw.created).getTime():NaN;isNaN(created)||establishedCreated.set(name.toLowerCase(),created)});const roster=csCleanRoster(initialRoster),targetMode=roster.length>0,knownTotal=targetMode?roster.length:null;roster.forEach(name=>{const key=name.toLowerCase();if(!map.has(key)){const blank=csBlankSheet(name),lc=lineCounts.get(key);lc!=null&&(blank.line_count=lc),map.set(key,blank)}});try{for(let i=0;iCS_DETAIL_FIELDS.some(f=>(s[f]||"").trim())).length,charLabel=knownTotal?`${knownTotal} cast characters queued \xB7 ${detailed} profiles with details`:`${map.size} characters found`;prog.update(i,`Passage ${i+1} / ${chunks.length}\u2026`,charLabel,csProgressSnapshot(map),[...map.values()]),prog.startPassage(chunks[i]);const maxAttempts=4;for(let attempt=1;attempt<=maxAttempts;attempt++)try{const character_sheets_prompt=typeof _appSettings!="undefined"&&_appSettings.character_sheets_prompt||CS_DEFAULT_PROMPT,body={text:chunks[i],known_characters:roster.slice(0,120),target_mode:targetMode,existing:csExistingSummary(map),language,llm_url,model,character_sheets_prompt};let data=null,sawDelta=!1;try{data=await csGenerateStream(body,delta=>{sawDelta=!0,prog.thinking(delta)}),sawDelta||prog.noStream()}catch{sawDelta||prog.noStream();const r=await fetch("/api/character-sheets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){let detail=`HTTP ${r.status}`;try{const e=await r.json();detail=e.detail||e.error||detail}catch{}throw new Error(detail)}const raw=await r.text();try{data=JSON.parse(raw)}catch{throw new Error("Invalid JSON from server (passage may be too large)")}}const incomingSheets=targetMode?(data.sheets||[]).filter(s=>csSheetMatchesRoster(s,roster)):data.sheets||[];incomingSheets.forEach(s=>{const ownName=String(s.name||"").trim().toLowerCase();["first_name","last_name","full_name"].forEach(f=>{const v=String(s[f]||"").trim().toLowerCase();v&&v!==ownName&&roster.some(n=>n.toLowerCase()===v)&&(console.warn(`[character sheets] dropped ${f}="${s[f]}" from "${s.name}" \u2014 matches a different known character`),s[f]="")})});const changed=csMerge(map,incomingSheets);changed.length&&prog.focus(changed[0].name),targetMode||((data.characters||[]).forEach(n=>{roster.some(r=>r.toLowerCase()===String(n||"").toLowerCase())||roster.push(n)}),(data.sheets||[]).forEach(s=>{csNameTokens(s).forEach(n=>{const display=[s.name,s.title,s.profession,s.aliases].filter(Boolean).join(", ").split(/[,;/|]/).map(x=>x.trim()).find(x=>x.toLowerCase()===n)||n;display&&!roster.some(r=>r.toLowerCase()===display.toLowerCase())&&roster.push(display)})}));const newDetailed=[...map.values()].filter(s=>CS_DETAIL_FIELDS.some(f=>(s[f]||"").trim())).length,newCharLabel=knownTotal?`${knownTotal} cast characters queued \xB7 ${newDetailed} profiles with details`:`${map.size} characters found`;prog.update(i+1,`Passage ${i+1} / ${chunks.length}\u2026`,newCharLabel,csProgressSnapshot(map,changed),[...map.values()]);break}catch(e){const msg=e.message||String(e),isRateLimit=/429|too many requests|rate.?limit/i.test(msg),isNetworkError=/failed to fetch|network\s*error|load failed|timed?\s*out|timeout|\b50[234]\b|econnreset|econnrefused|socket hang up|connection (refused|reset|closed)/i.test(msg);if((isRateLimit||isNetworkError)&&attemptsetTimeout(res,waitMs));continue}console.error("Character sheets passage",i+1,"failed:",e),toast("Passage "+(i+1)+" failed: "+msg,"error");break}}}finally{prog.done()}if(_cs.cancel)return toast("Cancelled","error"),null;csConsolidateAliasDuplicates(map,establishedKeys,establishedCreated);const sheets=[...map.values()];return cacheKey&&(_cs.cache[cacheKey]=sheets),sheets}catch(e){return console.error("[character sheets] generation crashed:",e),toast("Character sheet generation crashed: "+(e&&e.message?e.message:String(e)),"error"),null}finally{_cs.running=!1}}function csProgress(total,hostEl=null){var _a2,_b2;const usingHost=!!hostEl;let root=usingHost?hostEl:document.getElementById("cs-progress");if(!root){if(usingHost)return null;root=document.createElement("div"),root.id="cs-progress",root.className="audiobook-overlay",document.body.appendChild(root)}if(!root.querySelector("#cs-progress-fill")){usingHost&&(root.innerHTML=""),root.innerHTML=`
Character sheets @@ -1499,7 +1524,6 @@ Respond with STRICT JSON only:
-
@@ -1507,11 +1531,11 @@ Respond with STRICT JSON only:
Select a character to preview the sheet as it fills.
+
Passage / Live output watching\u2026
-
@@ -1524,7 +1548,6 @@ Respond with STRICT JSON only:
-
@@ -1534,26 +1557,36 @@ Respond with STRICT JSON only:
+
-
- `,(_a2=root.querySelector("#cs-progress-cancel"))==null||_a2.addEventListener("click",()=>{_cs.cancel=!0});const promptBtn=root.querySelector("#cs-prompt-btn"),promptPanel=root.querySelector("#cs-prompt-panel"),promptText=root.querySelector("#cs-prompt-text");promptText&&(promptText.value=typeof _appSettings!="undefined"&&_appSettings.character_sheets_prompt||CS_DEFAULT_PROMPT),promptBtn==null||promptBtn.addEventListener("click",()=>{if(!promptPanel)return;promptPanel.hidden=!promptPanel.hidden;const chevron=root.querySelector("#cs-prompt-chevron");chevron&&(chevron.className=promptPanel.hidden?"mdi mdi-chevron-down":"mdi mdi-chevron-up")});let savedCsPrompts=[];try{savedCsPrompts=JSON.parse(localStorage.getItem("ttsvc_cs_prompts")||"[]")}catch{savedCsPrompts=[]}const libSelect=root.querySelector("#cs-prompt-lib"),delBtn=root.querySelector("#cs-prompt-del"),nameInput=root.querySelector("#cs-prompt-name"),renderCsPromptLib=(selectedIdx=-1)=>{!libSelect||!delBtn||(libSelect.innerHTML=''+savedCsPrompts.map((p,i)=>``).join(""),selectedIdx>=0?(libSelect.value=selectedIdx,delBtn.style.display="inline-flex"):(libSelect.value="",delBtn.style.display="none"))};renderCsPromptLib(),libSelect==null||libSelect.addEventListener("change",()=>{const idx=parseInt(libSelect.value,10);!isNaN(idx)&&savedCsPrompts[idx]?(promptText&&(promptText.value=savedCsPrompts[idx].prompt),nameInput&&(nameInput.value=savedCsPrompts[idx].name),delBtn.style.display="inline-flex"):(nameInput&&(nameInput.value=""),delBtn.style.display="none")}),delBtn==null||delBtn.addEventListener("click",()=>{const idx=parseInt(libSelect.value,10);isNaN(idx)||confirm("Delete this saved prompt preset?")&&(savedCsPrompts.splice(idx,1),localStorage.setItem("ttsvc_cs_prompts",JSON.stringify(savedCsPrompts)),renderCsPromptLib(),toast("Prompt deleted","success"))}),(_b2=root.querySelector("#cs-prompt-save"))==null||_b2.addEventListener("click",async()=>{const val=(promptText==null?void 0:promptText.value.trim())||"";if(!val){toast("Prompt is empty","error");return}const name=(nameInput==null?void 0:nameInput.value.trim())||"Custom Prompt "+(savedCsPrompts.length+1);let targetIdx=parseInt((libSelect==null?void 0:libSelect.value)||"",10);!isNaN(targetIdx)&&savedCsPrompts[targetIdx]&&savedCsPrompts[targetIdx].name===name?savedCsPrompts[targetIdx].prompt=val:(savedCsPrompts.push({name,prompt:val}),targetIdx=savedCsPrompts.length-1),localStorage.setItem("ttsvc_cs_prompts",JSON.stringify(savedCsPrompts)),renderCsPromptLib(targetIdx),toast("Prompt preset saved","success")});let promptSaveTimer=null;promptText==null||promptText.addEventListener("input",()=>{clearTimeout(promptSaveTimer),promptSaveTimer=setTimeout(()=>{const val=promptText.value;typeof _appSettings!="undefined"&&(_appSettings.character_sheets_prompt=val),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({character_sheets_prompt:val})}).catch(()=>{})},500)})}usingHost||(root.style.display="flex");const fill=root.querySelector("#cs-progress-fill"),msg=root.querySelector("#cs-progress-msg"),chars=root.querySelector("#cs-progress-chars"),sideTitle=root.querySelector("#cs-progress-side-title"),sideDone=root.querySelector("#cs-progress-side-done"),sideCollapse=root.querySelector("#cs-progress-side-collapse"),leftPane=root.querySelector(".cs-progress-left"),outputPanel=root.querySelector("#cs-progress-output"),outputCollapse=root.querySelector("#cs-progress-output-collapse"),outputResize=root.querySelector("#cs-progress-output-resize"),preview=root.querySelector("#cs-progress-preview"),search=root.querySelector("#cs-progress-search"),passagePre=root.querySelector("#cs-progress-passage-pre"),livePre=root.querySelector("#cs-progress-live-pre"),liveStatus=root.querySelector("#cs-progress-live-status");let currentRoster=[],currentSheets=[],selectedName="",searchQuery="";const keyFor=v=>String(v||"").trim().toLowerCase(),findSheet=(name,sheets=currentSheets)=>(Array.isArray(sheets)?sheets:[]).find(s=>keyFor(s==null?void 0:s.name)===keyFor(name))||null;let outputCollapsed=!1,outputExpandedHeight=0;const clampOutputHeight=h=>{const max=Math.max(140,Math.round(window.innerHeight*.82));return Math.max(140,Math.min(max,Math.round(h)))},setOutputHeight=(h,persist=!0)=>{const next=clampOutputHeight(h);if(outputExpandedHeight=next,!outputCollapsed&&outputPanel&&(outputPanel.style.height=`${next}px`,outputPanel.style.maxHeight=`${next}px`,outputPanel.dataset.csHeight=String(next)),persist)try{localStorage.setItem("ttsvc_cs_output_height",String(next))}catch{}},applyOutputCollapse=collapsed=>{if(outputCollapsed=!!collapsed,outputPanel==null||outputPanel.classList.toggle("is-collapsed",outputCollapsed),leftPane==null||leftPane.classList.toggle("is-output-collapsed",outputCollapsed),outputCollapse){const icon=outputCollapse.querySelector(".mdi");icon&&(icon.className="mdi "+(outputCollapsed?"mdi-chevron-up":"mdi-chevron-down")),outputCollapse.title=outputCollapsed?"Expand passage and live output":"Collapse passage and live output"}if(outputPanel)if(outputCollapsed){if(!outputExpandedHeight){const cur=parseInt(outputPanel.dataset.csHeight||"",10)||parseInt(outputPanel.style.height||"",10)||outputPanel.getBoundingClientRect().height;cur&&(outputExpandedHeight=clampOutputHeight(cur))}outputPanel.style.height="44px",outputPanel.style.maxHeight="44px"}else{const h=outputExpandedHeight||parseInt(outputPanel.dataset.csHeight||"",10)||260;outputPanel.style.height=`${clampOutputHeight(h)}px`,outputPanel.style.maxHeight=`${clampOutputHeight(h)}px`,outputPanel.dataset.csHeight=String(clampOutputHeight(h))}try{localStorage.setItem("ttsvc_cs_output_collapsed",outputCollapsed?"1":"0")}catch{}};let sideCollapsed=!1;const applySideCollapse=collapsed=>{var _a3,_b3;if(sideCollapsed=!!collapsed,(_a3=root.querySelector("#cs-progress-layout"))==null||_a3.classList.toggle("side-collapsed",sideCollapsed),(_b3=root.querySelector("#cs-progress-side"))==null||_b3.classList.toggle("is-collapsed",sideCollapsed),sideCollapse){const icon=sideCollapse.querySelector(".mdi");icon&&(icon.className="mdi "+(sideCollapsed?"mdi-chevron-left":"mdi-chevron-right")),sideCollapse.title=sideCollapsed?"Expand character list":"Collapse character list"}try{localStorage.setItem("ttsvc_cs_side_collapsed",sideCollapsed?"1":"0")}catch{}};try{sideCollapsed=localStorage.getItem("ttsvc_cs_side_collapsed")==="1"}catch{}try{const savedOutputCollapsed=localStorage.getItem("ttsvc_cs_output_collapsed");savedOutputCollapsed!==null&&(outputCollapsed=savedOutputCollapsed==="1");const savedOutputHeight=parseInt(localStorage.getItem("ttsvc_cs_output_height")||"",10);!Number.isNaN(savedOutputHeight)&&savedOutputHeight>0&&(outputExpandedHeight=clampOutputHeight(savedOutputHeight))}catch{}if(applySideCollapse(sideCollapsed),applyOutputCollapse(outputCollapsed),sideCollapse==null||sideCollapse.addEventListener("click",()=>applySideCollapse(!sideCollapsed)),outputCollapse==null||outputCollapse.addEventListener("click",()=>applyOutputCollapse(!outputCollapsed)),outputResize){let dragging=!1,startY=0,startH=0;const onMove=e=>{dragging&&(outputCollapsed&&applyOutputCollapse(!1),setOutputHeight(startH+(e.clientY-startY)),e.preventDefault())},stopDrag=()=>{dragging=!1,document.body.classList.remove("cs-output-resizing"),document.removeEventListener("mousemove",onMove),document.removeEventListener("mouseup",stopDrag)};outputResize.addEventListener("mousedown",e=>{e.preventDefault(),outputPanel&&(outputCollapsed&&applyOutputCollapse(!1),dragging=!0,startY=e.clientY,startH=parseInt(outputPanel.dataset.csHeight||"",10)||outputPanel.getBoundingClientRect().height||260,document.body.classList.add("cs-output-resizing"),document.addEventListener("mousemove",onMove),document.addEventListener("mouseup",stopDrag))})}const renderPreview=()=>{var _a3;if(!preview)return;const sheet=findSheet(selectedName)||currentSheets[0]||null;if(!sheet){preview.innerHTML='
Select a character to preview the sheet as it fills.
';return}preview.innerHTML=csCardHtml(sheet),(_a3=preview.querySelector(".cs-card"))==null||_a3.classList.add("cs-progress-preview-card"),preview.querySelectorAll(".cs-head-btns, .cs-sources").forEach(el=>el.remove()),preview.querySelectorAll(".cs-avatar").forEach(el=>{el.style.pointerEvents="none",el.style.cursor="default"})},renderChars=()=>{if(!chars)return;const list=currentRoster.filter(c=>{if(!searchQuery)return!0;const name=keyFor(c.name),alias=keyFor(c.alias);return name.includes(searchQuery)||alias.includes(searchQuery)});chars.innerHTML=list.length?list.map(c=>` + + `,(_a2=root.querySelector("#cs-progress-cancel"))==null||_a2.addEventListener("click",()=>{_cs.cancel=!0});const promptBtn=root.querySelector("#cs-prompt-btn"),promptPanel=root.querySelector("#cs-prompt-panel"),promptText=root.querySelector("#cs-prompt-text");promptText&&(promptText.value=typeof _appSettings!="undefined"&&_appSettings.character_sheets_prompt||CS_DEFAULT_PROMPT),promptBtn==null||promptBtn.addEventListener("click",()=>{if(!promptPanel)return;promptPanel.hidden=!promptPanel.hidden;const chevron=root.querySelector("#cs-prompt-chevron");chevron&&(chevron.className=promptPanel.hidden?"mdi mdi-chevron-down":"mdi mdi-chevron-up")});let savedCsPrompts=[];try{savedCsPrompts=JSON.parse(localStorage.getItem("ttsvc_cs_prompts")||"[]")}catch{savedCsPrompts=[]}const libSelect=root.querySelector("#cs-prompt-lib"),delBtn=root.querySelector("#cs-prompt-del"),nameInput=root.querySelector("#cs-prompt-name"),renderCsPromptLib=(selectedIdx=-1)=>{!libSelect||!delBtn||(libSelect.innerHTML=''+savedCsPrompts.map((p,i)=>``).join(""),selectedIdx>=0?(libSelect.value=selectedIdx,delBtn.style.display="inline-flex"):(libSelect.value="",delBtn.style.display="none"))};renderCsPromptLib(),libSelect==null||libSelect.addEventListener("change",()=>{const idx=parseInt(libSelect.value,10);!isNaN(idx)&&savedCsPrompts[idx]?(promptText&&(promptText.value=savedCsPrompts[idx].prompt),nameInput&&(nameInput.value=savedCsPrompts[idx].name),delBtn.style.display="inline-flex"):(nameInput&&(nameInput.value=""),delBtn.style.display="none")}),delBtn==null||delBtn.addEventListener("click",()=>{const idx=parseInt(libSelect.value,10);isNaN(idx)||confirm("Delete this saved prompt preset?")&&(savedCsPrompts.splice(idx,1),localStorage.setItem("ttsvc_cs_prompts",JSON.stringify(savedCsPrompts)),renderCsPromptLib(),toast("Prompt deleted","success"))}),(_b2=root.querySelector("#cs-prompt-save"))==null||_b2.addEventListener("click",async()=>{const val=(promptText==null?void 0:promptText.value.trim())||"";if(!val){toast("Prompt is empty","error");return}const name=(nameInput==null?void 0:nameInput.value.trim())||"Custom Prompt "+(savedCsPrompts.length+1);let targetIdx=parseInt((libSelect==null?void 0:libSelect.value)||"",10);!isNaN(targetIdx)&&savedCsPrompts[targetIdx]&&savedCsPrompts[targetIdx].name===name?savedCsPrompts[targetIdx].prompt=val:(savedCsPrompts.push({name,prompt:val}),targetIdx=savedCsPrompts.length-1),localStorage.setItem("ttsvc_cs_prompts",JSON.stringify(savedCsPrompts)),renderCsPromptLib(targetIdx),toast("Prompt preset saved","success")});let promptSaveTimer=null;promptText==null||promptText.addEventListener("input",()=>{clearTimeout(promptSaveTimer),promptSaveTimer=setTimeout(()=>{const val=promptText.value;typeof _appSettings!="undefined"&&(_appSettings.character_sheets_prompt=val),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({character_sheets_prompt:val})}).catch(()=>{})},500)})}usingHost||(root.style.display="flex");const fill=root.querySelector("#cs-progress-fill"),msg=root.querySelector("#cs-progress-msg"),chars=root.querySelector("#cs-progress-chars"),sideTitle=root.querySelector("#cs-progress-side-title"),sideDone=root.querySelector("#cs-progress-side-done"),sideCollapse=root.querySelector("#cs-progress-side-collapse"),leftPane=root.querySelector(".cs-progress-left"),outputPanel=root.querySelector("#cs-progress-output"),outputCollapse=root.querySelector("#cs-progress-output-collapse"),outputResize=root.querySelector("#cs-progress-output-resize"),preview=root.querySelector("#cs-progress-preview"),search=root.querySelector("#cs-progress-search"),passagePre=root.querySelector("#cs-progress-passage-pre"),livePre=root.querySelector("#cs-progress-live-pre"),liveStatus=root.querySelector("#cs-progress-live-status");let currentRoster=[],currentSheets=[],selectedName="",searchQuery="",rosterSort=(()=>{try{return localStorage.getItem("ttsvc_cs_side_sort")||"lines"}catch{return"lines"}})();const keyFor=v=>String(v||"").trim().toLowerCase(),findSheet=(name,sheets=currentSheets)=>(Array.isArray(sheets)?sheets:[]).find(s=>keyFor(s==null?void 0:s.name)===keyFor(name))||null;let outputCollapsed=!1,outputExpandedHeight=0;const clampOutputHeight=h=>{const max=Math.max(140,Math.round(window.innerHeight*.82));return Math.max(140,Math.min(max,Math.round(h)))},setOutputHeight=(h,persist=!0)=>{const next=clampOutputHeight(h);if(outputExpandedHeight=next,!outputCollapsed&&outputPanel&&(outputPanel.style.height=`${next}px`,outputPanel.style.maxHeight=`${next}px`,outputPanel.dataset.csHeight=String(next)),persist)try{localStorage.setItem("ttsvc_cs_output_height",String(next))}catch{}},applyOutputCollapse=collapsed=>{if(outputCollapsed=!!collapsed,outputPanel==null||outputPanel.classList.toggle("is-collapsed",outputCollapsed),leftPane==null||leftPane.classList.toggle("is-output-collapsed",outputCollapsed),outputCollapse){const icon=outputCollapse.querySelector(".mdi");icon&&(icon.className="mdi "+(outputCollapsed?"mdi-chevron-up":"mdi-chevron-down")),outputCollapse.title=outputCollapsed?"Expand passage and live output":"Collapse passage and live output"}if(outputPanel)if(outputCollapsed){if(!outputExpandedHeight){const cur=parseInt(outputPanel.dataset.csHeight||"",10)||parseInt(outputPanel.style.height||"",10)||outputPanel.getBoundingClientRect().height;cur&&(outputExpandedHeight=clampOutputHeight(cur))}const collapsedH=138;outputPanel.style.height=`${collapsedH}px`,outputPanel.style.maxHeight=`${collapsedH}px`}else{const h=outputExpandedHeight||parseInt(outputPanel.dataset.csHeight||"",10)||260;outputPanel.style.height=`${clampOutputHeight(h)}px`,outputPanel.style.maxHeight=`${clampOutputHeight(h)}px`,outputPanel.dataset.csHeight=String(clampOutputHeight(h))}try{localStorage.setItem("ttsvc_cs_output_collapsed",outputCollapsed?"1":"0")}catch{}};let sideCollapsed=!1;const applySideCollapse=collapsed=>{var _a3,_b3;if(sideCollapsed=!!collapsed,(_a3=root.querySelector("#cs-progress-layout"))==null||_a3.classList.toggle("side-collapsed",sideCollapsed),(_b3=root.querySelector("#cs-progress-side"))==null||_b3.classList.toggle("is-collapsed",sideCollapsed),sideCollapse){const icon=sideCollapse.querySelector(".mdi");icon&&(icon.className="mdi "+(sideCollapsed?"mdi-chevron-left":"mdi-chevron-right")),sideCollapse.title=sideCollapsed?"Expand character list":"Collapse character list"}try{localStorage.setItem("ttsvc_cs_side_collapsed",sideCollapsed?"1":"0")}catch{}};try{sideCollapsed=localStorage.getItem("ttsvc_cs_side_collapsed")==="1"}catch{}try{const savedOutputCollapsed=localStorage.getItem("ttsvc_cs_output_collapsed");savedOutputCollapsed!==null&&(outputCollapsed=savedOutputCollapsed==="1");const savedOutputHeight=parseInt(localStorage.getItem("ttsvc_cs_output_height")||"",10);!Number.isNaN(savedOutputHeight)&&savedOutputHeight>0&&(outputExpandedHeight=clampOutputHeight(savedOutputHeight))}catch{}if(applySideCollapse(sideCollapsed),applyOutputCollapse(outputCollapsed),sideCollapse==null||sideCollapse.addEventListener("click",()=>applySideCollapse(!sideCollapsed)),outputCollapse==null||outputCollapse.addEventListener("click",()=>applyOutputCollapse(!outputCollapsed)),outputResize){let dragging=!1,startY=0,startH=0;const onMove=e=>{dragging&&(outputCollapsed&&applyOutputCollapse(!1),setOutputHeight(startH-(e.clientY-startY)),e.preventDefault())},stopDrag=()=>{dragging=!1,document.body.classList.remove("cs-output-resizing"),document.removeEventListener("mousemove",onMove),document.removeEventListener("mouseup",stopDrag)};outputResize.addEventListener("mousedown",e=>{e.preventDefault(),outputPanel&&(outputCollapsed&&applyOutputCollapse(!1),dragging=!0,startY=e.clientY,startH=parseInt(outputPanel.dataset.csHeight||"",10)||outputPanel.getBoundingClientRect().height||260,document.body.classList.add("cs-output-resizing"),document.addEventListener("mousemove",onMove),document.addEventListener("mouseup",stopDrag))})}const renderPreview=()=>{var _a3,_b3,_c2,_d2,_e2,_f2;if(!preview)return;const sheet=findSheet(selectedName)||currentSheets[0]||null;if(!sheet){preview.innerHTML='
Select a character to preview the sheet as it fills.
';return}preview.innerHTML=csCardHtml(sheet,{voice:sheet.voice,line_count:sheet.line_count}),(_a3=preview.querySelector(".cs-card"))==null||_a3.classList.add("cs-progress-preview-card"),preview.querySelectorAll(".cs-head-btns, .cs-header-actions, .cs-sources").forEach(el=>el.remove()),preview.querySelectorAll(".cs-avatar").forEach(el=>{el.style.pointerEvents="none",el.style.cursor="default"});const book=typeof readerState!="undefined"&&readerState.title||"",ensureRecord=async()=>typeof clUpsert=="function"?await clUpsert(book,sheet):null;(_b3=preview.querySelector(".lcd-pick-voice"))==null||_b3.addEventListener("click",async()=>{const rec=await ensureRecord();rec&&typeof _openVoicePicker=="function"&&_openVoicePicker(preview.querySelector(".lcd-voice-top"),rec,()=>renderPreview())}),(_c2=preview.querySelector(".lcd-auto-voice"))==null||_c2.addEventListener("click",async()=>{const rec=await ensureRecord();rec&&typeof _autoAssignVoice=="function"&&(await _autoAssignVoice(rec),renderPreview())}),(_d2=preview.querySelector(".lcd-online-voice"))==null||_d2.addEventListener("click",async()=>{const rec=await ensureRecord();rec&&typeof _charSearchOnline=="function"&&_charSearchOnline(rec)}),(_e2=preview.querySelector(".lcd-gen-voice"))==null||_e2.addEventListener("click",async()=>{const rec=await ensureRecord();rec&&typeof _charDesignVoice=="function"&&_charDesignVoice(rec)}),(_f2=preview.querySelector(".cs-header-color input"))==null||_f2.addEventListener("input",async e=>{const rec=await ensureRecord();rec&&(sheet.color=e.target.value,rec.color=e.target.value,typeof clPut=="function"&&await clPut(rec))});const sourceBox=preview.querySelector(".cs-source-preview");preview.querySelectorAll(".cs-source-mark:not(.cs-source-mark-sample)").forEach(btn=>{btn.addEventListener("click",()=>{if(!sourceBox)return;preview.querySelectorAll(".cs-source-mark.is-active").forEach(b=>b.classList.remove("is-active")),btn.classList.add("is-active");const quote=btn.dataset.quote||"",hint=btn.dataset.lineHint||"",page=btn.dataset.page||"";sourceBox.innerHTML=`
${hint?escHtml(hint):"Source sentence"}${page?` \xB7 p.${escHtml(page)}`:""}
+
${quote?escHtml(quote):'No exact sentence was saved for this field.'}
`,sourceBox.scrollIntoView({behavior:"smooth",block:"nearest"})})})},rosterSorters={progress:(a,b)=>(b.status?1:0)-(a.status?1:0)||b.filled-a.filled||a.name.localeCompare(b.name),alpha:(a,b)=>a.name.localeCompare(b.name),lines:(a,b)=>(b.lineCount||0)-(a.lineCount||0)||a.name.localeCompare(b.name),gender:(a,b)=>(a.gender||"\uFFFF").localeCompare(b.gender||"\uFFFF")||a.name.localeCompare(b.name)},renderChars=()=>{if(!chars)return;const list=currentRoster.filter(c=>{if(!searchQuery)return!0;const name=keyFor(c.name),alias=keyFor(c.alias);return name.includes(searchQuery)||alias.includes(searchQuery)}).slice().sort(rosterSorters[rosterSort]||rosterSorters.progress);chars.innerHTML=list.length?list.map(c=>`
- ${escHtml((c.name||"?")[0].toUpperCase())} + ${c.image?``:`${escHtml((c.name||"?")[0].toUpperCase())}`} ${escHtml(c.name||"Unknown")} - - ${escHtml(String(c.filled||0))} - - ${CS_DETAIL_FIELDS.length} + + ${c.lineCount?escHtml(String(c.lineCount))+" lines":"\u2014"} + + ${escHtml(String(c.filled||0))}/${CS_DETAIL_FIELDS.length}
- `).join(""):'
reading\u2026
',chars.querySelectorAll(".ab-char-item").forEach(el=>{el.addEventListener("click",()=>{selectedName=keyFor(el.dataset.name),renderPreview()})})};return search==null||search.addEventListener("input",()=>{searchQuery=search.value.trim().toLowerCase(),renderChars()}),renderPreview(),{startPassage(text){passagePre&&(passagePre.textContent=text||""),livePre&&(livePre.textContent="Waiting for streamed JSON output\u2026"),liveStatus&&(liveStatus.textContent="watching\u2026",liveStatus.className="cs-progress-live-status"),outputPanel==null||outputPanel.classList.add("is-working")},thinking(delta){!livePre||!delta||(liveStatus&&(liveStatus.textContent="streaming",liveStatus.className="cs-progress-live-status is-live"),outputPanel==null||outputPanel.classList.add("is-working"),livePre.textContent=(livePre.textContent+delta).slice(-2e4),livePre.scrollTop=livePre.scrollHeight)},noStream(){liveStatus&&(liveStatus.textContent="no live output for this model",liveStatus.className="cs-progress-live-status")},focus(name){name&&(selectedName=keyFor(name),renderPreview())},update(d,label,charLabel,roster=[],sheets=[]){if(fill&&(fill.style.width=d/total*100+"%"),msg&&label&&(msg.textContent=label),sideTitle&&(sideTitle.textContent="Characters found"),sideDone&&(sideDone.innerHTML=charLabel?` ${escHtml(charLabel)}`:""),outputPanel==null||outputPanel.classList.add("is-working"),currentRoster=Array.isArray(roster)?roster:[],currentSheets=Array.isArray(sheets)?sheets:[],!selectedName&¤tRoster.length&&(selectedName=keyFor((currentRoster.find(c=>c.status)||currentRoster[0]||{}).name)),currentSheets.length&&!findSheet(selectedName)){const changedSheet=currentRoster.find(c=>c.status&&findSheet(c.name,currentSheets));changedSheet&&(selectedName=keyFor(changedSheet.name))}renderChars(),renderPreview()},done(){outputPanel==null||outputPanel.classList.remove("is-working"),usingHost||(root.style.display="none")}}}function csAlignmentBar(score,arcDirection,arcNote){if(score==null)return"";const pct=Math.max(0,Math.min(100,score)),arcMap={"stable-good":{arrow:"\u2192",label:"Stable Good",color:"#66bb6a"},"stable-bad":{arrow:"\u2192",label:"Stable Evil",color:"#aaa"},neutral:{arrow:"\u2192",label:"Neutral",color:"#aaa"},"good-to-bad":{arrow:"\u2198",label:"Descends toward evil",color:"#ff7043"},"bad-to-good":{arrow:"\u2197",label:"Redeems toward good",color:"#66bb6a"},complex:{arrow:"\u2195",label:"Complex arc",color:"#ab47bc"}},arc=arcMap[arcDirection]||arcMap.neutral,label=pct>=70?"Good":pct<=30?"Evil":"Neutral/Ambiguous",showArrow=arcDirection&&arcDirection!=="neutral";return`
+ `).join(""):'
reading\u2026
',chars.querySelectorAll(".ab-char-item").forEach(el=>{el.addEventListener("click",()=>{selectedName=keyFor(el.dataset.name),renderPreview()})})};search==null||search.addEventListener("input",()=>{searchQuery=search.value.trim().toLowerCase(),renderChars()});const sortSel=root.querySelector("#cs-progress-sort");return sortSel&&(sortSel.value=rosterSort,sortSel.addEventListener("change",()=>{rosterSort=sortSel.value;try{localStorage.setItem("ttsvc_cs_side_sort",rosterSort)}catch{}renderChars()})),renderPreview(),{startPassage(text){passagePre&&(passagePre.textContent=text||""),livePre&&(livePre.textContent="Waiting for streamed JSON output\u2026"),liveStatus&&(liveStatus.textContent="watching\u2026",liveStatus.className="cs-progress-live-status"),outputPanel==null||outputPanel.classList.add("is-working")},thinking(delta){!livePre||!delta||(liveStatus&&(liveStatus.textContent="streaming",liveStatus.className="cs-progress-live-status is-live"),outputPanel==null||outputPanel.classList.add("is-working"),livePre.textContent=(livePre.textContent+delta).slice(-2e4),livePre.scrollTop=livePre.scrollHeight)},noStream(){liveStatus&&(liveStatus.textContent="no live output for this model",liveStatus.className="cs-progress-live-status"),livePre&&livePre.textContent==="Waiting for streamed JSON output\u2026"&&(livePre.textContent="This model doesn't stream incremental output \u2014 the full result arrives at once when each passage finishes.")},focus(name){name&&(selectedName=keyFor(name),renderPreview())},update(d,label,charLabel,roster=[],sheets=[]){if(fill&&(fill.style.width=d/total*100+"%"),msg&&label&&(msg.textContent=label),sideTitle&&(sideTitle.textContent="Characters found"),sideDone&&(sideDone.innerHTML=charLabel?` ${escHtml(charLabel)}`:""),outputPanel==null||outputPanel.classList.add("is-working"),currentRoster=Array.isArray(roster)?roster:[],currentSheets=Array.isArray(sheets)?sheets:[],!selectedName&¤tRoster.length&&(selectedName=keyFor((currentRoster.find(c=>c.status)||currentRoster[0]||{}).name)),currentSheets.length&&!findSheet(selectedName)){const changedSheet=currentRoster.find(c=>c.status&&findSheet(c.name,currentSheets));changedSheet&&(selectedName=keyFor(changedSheet.name))}renderChars(),renderPreview()},done(){outputPanel==null||outputPanel.classList.remove("is-working"),usingHost||(root.style.display="none")}}}function csAlignmentBar(score,arcDirection,arcNote){if(score==null)return"";const pct=Math.max(0,Math.min(100,score)),arcMap={"stable-good":{arrow:"\u2192",label:"Stable Good",color:"#66bb6a"},"stable-bad":{arrow:"\u2192",label:"Stable Evil",color:"#aaa"},neutral:{arrow:"\u2192",label:"Neutral",color:"#aaa"},"good-to-bad":{arrow:"\u2198",label:"Descends toward evil",color:"#ff7043"},"bad-to-good":{arrow:"\u2197",label:"Redeems toward good",color:"#66bb6a"},complex:{arrow:"\u2195",label:"Complex arc",color:"#ab47bc"}},arc=arcMap[arcDirection]||arcMap.neutral,label=pct>=70?"Good":pct<=30?"Evil":"Neutral/Ambiguous",showArrow=arcDirection&&arcDirection!=="neutral";return`
EvilGood
@@ -1561,7 +1594,7 @@ Respond with STRICT JSON only:
${arcNote?`
${escHtml(arc.label)} \xB7 ${escHtml(arcNote)}
`:`
${escHtml(arc.label)}
`} -
`}function csBuildImagePrompt(s){var _a2;if(_csStr(s.image_prompt).trim())return _csStr(s.image_prompt).trim();const parts=[];s.archetype&&parts.push(s.archetype),s.physical&&parts.push(s.physical),s.clothing&&parts.push(s.clothing),s.alignment&&parts.push(s.alignment);const pct=(_a2=s.moral_alignment_score)!=null?_a2:50;return parts.push(pct>=70?"benevolent expression":pct<=30?"dark and menacing presence":"ambiguous expression"),s.arc_direction==="bad-to-good"&&parts.push("redemptive aura"),s.arc_direction==="good-to-bad"&&parts.push("ominous aura, turning to darkness"),`Portrait of ${s.name}, ${parts.filter(Boolean).join(", ")}, fantasy character art, detailed face, dramatic lighting, high detail.`}function csBuildVoicePrompt(s){var _a2;if(_csStr(s.voice_design_prompt).trim())return _csStr(s.voice_design_prompt).trim();const parts=[];s.voice_pattern&&parts.push(s.voice_pattern),s.mannerisms&&parts.push(s.mannerisms),s.archetype&&parts.push(`archetype: ${s.archetype}`);const pct=(_a2=s.moral_alignment_score)!=null?_a2:50;return pct>=70?parts.push("warm, trustworthy tone"):pct<=30?parts.push("cold, threatening or sinister tone"):parts.push("neutral, measured tone"),parts.filter(Boolean).join(". ")}async function csDeepAnalysis(sheet,sourceText){const llm_url=csLlmUrl(),model=csLlmModel(),language=csLang(),modal=document.createElement("div");modal.className="audiobook-overlay",modal.innerHTML=`
+
`}function csBuildImagePrompt(s){var _a2;if(_csStr(s.image_prompt).trim())return _csStr(s.image_prompt).trim();const parts=[];s.archetype&&parts.push(s.archetype),s.physical&&parts.push(s.physical),s.clothing&&parts.push(s.clothing),s.alignment&&parts.push(s.alignment);const pct=(_a2=s.moral_alignment_score)!=null?_a2:50;return parts.push(pct>=70?"benevolent expression":pct<=30?"dark and menacing presence":"ambiguous expression"),s.arc_direction==="bad-to-good"&&parts.push("redemptive aura"),s.arc_direction==="good-to-bad"&&parts.push("ominous aura, turning to darkness"),`Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(", ")}. Base the setting, era, and art style strictly on the character's own described archetype and clothing above \u2014 do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual choice should fit the world implied by the description, not the real one, unless the description itself is explicitly modern/contemporary. Include a full-body front view as the anchor, a turnaround panel (side and back views), an expression sheet with 3-5 headshots matching their personality, a color palette swatch for hair/eyes/outfit, and labeled callouts for signature props or clothing details. Clean production concept-art layout, plain neutral background, original character not based on any copyrighted character.`}function csBuildVoicePrompt(s){var _a2;if(_csStr(s.voice_design_prompt).trim())return _csStr(s.voice_design_prompt).trim();const parts=[];s.voice_pattern&&parts.push(s.voice_pattern),s.mannerisms&&parts.push(s.mannerisms),s.archetype&&parts.push(`archetype: ${s.archetype}`);const pct=(_a2=s.moral_alignment_score)!=null?_a2:50;return pct>=70?parts.push("warm, trustworthy tone"):pct<=30?parts.push("cold, threatening or sinister tone"):parts.push("neutral, measured tone"),parts.filter(Boolean).join(". ")}async function csDeepAnalysis(sheet,sourceText){const llm_url=csLlmUrl(),model=csLlmModel(),language=csLang(),modal=document.createElement("div");modal.className="audiobook-overlay",modal.innerHTML=`
Deep Analysis \u2014 ${escHtml(sheet.name)} @@ -1588,13 +1621,15 @@ Respond with STRICT JSON only: ${section("mdi-comment-quote-outline","3 \u2014 Dialogue & Voice",a.dialogue_voice)} ${section("mdi-timeline-outline","4 \u2014 Narrative Arc",a.narrative_arc)} ${section("mdi-infinity","5 \u2014 Paradox & Depth",a.paradox)} - `}catch(err){modal.querySelector("#cs-deep-body").innerHTML=`
Error: ${escHtml(String(err.message))}
`}}function csSourcesForField(s,field){const keys=(CS_SOURCE_FIELDS[field]||[field]).map(x=>x.toLowerCase());return(s.sources||[]).filter(src=>{const hint=_csStr(src==null?void 0:src.line_hint).toLowerCase();return hint&&keys.some(k=>hint.includes(k))})}function csSourceBadgeHtml(s,field){const src=csSourcesForField(s,field)[0];if(!src)return"";const idx=(s.sources||[]).findIndex(x=>x===src),n=idx>=0?idx+1:1;return``}function csField(label,value,sheet,field){if(!value)return"";const badge=sheet&&field?csSourceBadgeHtml(sheet,field):"";return`
${label}
${escHtml(String(value))}${badge?` ${badge}`:""}
`}function csIdentityCell(label,value,sheet,field){if(!value)return"";const badge=sheet&&field?csSourceBadgeHtml(sheet,field):"";return`
${label}
${escHtml(String(value))}${badge?` ${badge}`:""}
`}function csPromptBox(label,value,sheetKey){const text=_csStr(value),has=!!text.trim();return`
- ${escHtml(label)}${has?"":' \u2014 not generated yet'} + `}catch(err){modal.querySelector("#cs-deep-body").innerHTML=`
Error: ${escHtml(String(err.message))}
`}}function csSourcesForField(s,field){const keys=(CS_SOURCE_FIELDS[field]||[field]).map(x=>x.toLowerCase());return(s.sources||[]).filter(src=>{const hint=_csStr(src==null?void 0:src.line_hint).toLowerCase();return hint&&keys.some(k=>hint.includes(k))})}function csSourceBadgeHtml(s,field){const all=s.sources||[],matches=csSourcesForField(s,field);return matches.length?matches.map(src=>{const idx=all.findIndex(x=>x===src),n=idx>=0?idx+1:1;return``}).join(""):""}function csField(label,value,sheet,field){if(!value)return"";const badge=sheet&&field?csSourceBadgeHtml(sheet,field):"",key=field?` data-sheet-key="${escHtml(field)}"`:"";return`
${label}
${escHtml(String(value))}${badge?` ${badge}`:""}
`}function csIdentityCell(label,value,sheet,field){if(!value)return"";const badge=sheet&&field?csSourceBadgeHtml(sheet,field):"",key=field?` data-sheet-key="${escHtml(field)}"`:"";return`
${label}
${escHtml(String(value))}${badge?` ${badge}`:""}
`}function csPromptBox(label,value,sheetKey,emptyNote,extraImage){const text=_csStr(value),has=!!text.trim(),actionBtn=has?sheetKey==="voice_design_prompt"?'':sheetKey==="image_prompt"?'':sheetKey==="concept_art_prompt"?``:"":"",imageHtml=extraImage?`
Concept art
`:"";return`
+ ${escHtml(label)}${has?"":' \u2014 '+escHtml(emptyNote||"not generated yet")+""}
+ ${imageHtml}
${escHtml(text)}
+ ${actionBtn}
`}function csAvatarHtml(s){const hue=Math.abs((s.name||"?").split("").reduce((h,c)=>(h*31+c.charCodeAt(0))%360,0));return s._image?`
@@ -1603,68 +1638,65 @@ Respond with STRICT JSON only:
`:`
${escHtml((s.name||"?")[0].toUpperCase())}
-
`}function csIdentityTagList(s){const tags=[],add=(label,value)=>{const text=String(value||"").trim();if(!text)return;const key=text.toLowerCase();tags.some(t=>t.key===key)||tags.push({label,text,key})};s.full_name&&String(s.full_name).trim()&&String(s.full_name).trim()!==String(s.name||"").trim()&&add("full name",s.full_name);const firstLast=[s.first_name,s.last_name].filter(Boolean).join(" ").trim();return firstLast&&firstLast!==String(s.full_name||"").trim()&&firstLast!==String(s.name||"").trim()&&add("first / last",firstLast),tags}function csCardHtml(s){const inv=(s.inventory||[]).filter(Boolean),fullName=String(s.full_name||"").trim(),metaTags=csIdentityTagList(s),identityHtml=`
- ${csIdentityCell("Name",s.name||fullName,s,"name")} - ${csIdentityCell("First Name",s.first_name,s,"first_name")} - ${csIdentityCell("Last Name",s.last_name,s,"last_name")} - ${csIdentityCell("Full Name",fullName&&fullName.toLowerCase()!==String(s.name||"").toLowerCase()?fullName:"",s,"full_name")} - ${csIdentityCell("Gender",s.gender,s,"gender")} - ${csIdentityCell("Title",s.title,s,"title")} - ${csIdentityCell("Occupation",s.profession,s,"profession")} - ${csIdentityCell("Also Known As",s.aliases,s,"aliases")} -
`,invHtml=inv.length?`
Signature Items
    ${inv.map(i=>`
  • ${escHtml(i)}
  • `).join("")}
`:"",attrs=s.attribute_high||s.attribute_low?`
Core Attributes
\u25B2 ${escHtml(s.attribute_high||"\u2014")}  \xB7  \u25BC ${escHtml(s.attribute_low||"\u2014")}
`:"",sources=(s.sources||[]).filter(x=>x&&(x.quote||x.page!=null||x.line_hint)),srcHtml=sources.length?`
Sources / Evidence
${sources.map((x,i)=>``).join("")}
`:"",promptHtml=`
- ${csPromptBox("Voice Design Prompt",s.voice_design_prompt,"voice_design_prompt")} - ${csPromptBox("Character Image Prompt",s.image_prompt,"image_prompt")} - ${csPromptBox("SillyTavern Character Prompt",s.silly_tavern_prompt,"silly_tavern_prompt")} - ${csPromptBox("Character Concept Art Prompt",s.concept_art_prompt,"concept_art_prompt")} -
`;return`
-
- ${csAvatarHtml(s)} -
-
- ${escHtml(s.name)} - ${s.archetype?`${escHtml(s.archetype)}`:""} - ${s.tier==="main"?"Main":"Supporting"} -
- - - -
-
- ${metaTags.length?`
${metaTags.map(t=>`${escHtml(t.label)}${escHtml(t.text)}`).join("")}
`:""} - ${csAlignmentBar(s.moral_alignment_score,s.arc_direction,s.arc_note)} +
`}function csIdentityTagList(s){const tags=[],add=(label,value)=>{const text=String(value||"").trim();if(!text)return;const key=text.toLowerCase();tags.some(t=>t.key===key)||tags.push({label,text,key})},firstLast=[s.first_name,s.last_name].filter(Boolean).join(" ").trim();return firstLast&&firstLast!==String(s.name||"").trim()&&add("first / last",firstLast),tags}function csNameHue(name){return Math.abs((name||"?").split("").reduce((h,c)=>(h*31+c.charCodeAt(0))%360,0))}function csHeaderPill(label,value,icon=""){const text=String(value||"").trim();return text?`${icon?``:""}${escHtml(label)}${escHtml(text)}`:""}function csProfileHeaderHtml(s,meta={},actionsHtml=""){var _a2;const hue=csNameHue(s.name),accent2=`hsl(${(hue+38)%360}, 58%, 34%)`,primaryPills=[csHeaderPill("Occupation",s.profession,"briefcase-outline"),csHeaderPill("Archetype",s.archetype,"shape-outline"),csHeaderPill("Also known as",s.aliases,"tag-multiple-outline")].filter(Boolean).join(""),chips=[csHeaderPill("Lines",(_a2=meta.line_count)!=null?_a2:s.line_count,"format-list-numbered"),csHeaderPill("Voice",meta.voice,"account-voice"),csHeaderPill("Book",meta.book,"book-open-page-variant-outline"),csHeaderPill("Tags",meta.tags,"tag-outline"),csHeaderPill("Gender",s.gender,"human-male-female")].filter(Boolean).join(""),color=typeof clNormalizeColor=="function"?clNormalizeColor(s.color,s.name):`hsl(${hue},58%,42%)`;return`
+ ${csAvatarHtml(s)} +
+
${escHtml(s.name||"Unnamed")}
+ ${primaryPills?`
${primaryPills}
`:""} +
${s.tier?`${escHtml(String(s.tier).toLowerCase()==="main"?"Hauptcharakter":"Nebencharakter")}`:""}${chips?`
${chips}
`:""}
+
+ + + ${actionsHtml?`
${actionsHtml}
`:""} +
`}function csDetailActionsHtml(s){return` + + + `}function csOverviewCardHtml(rec){const s=rec.sheet||{},score=s.moral_alignment_score!=null?Math.max(0,Math.min(100,parseInt(s.moral_alignment_score,10)||50)):50,scoreLabel=score>=70?"Good":score<=30?"Evil":"Neutral",voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",fullName=s.full_name||rec.name||"",tierLabel=String(s.tier||"").toLowerCase()==="main"?"Main character":"Side character",facts=[["Occupation",s.profession],["Archetype",s.archetype],["Gender",s.gender],["Lines",s.line_count],["Voice",voiceId||"Not assigned"]],hue=csNameHue(rec.name);return`
+
+ ${csAvatarHtml(s.name?s:{...s,name:rec.name,_image:rec.image})} +
+
${escHtml(fullName)}
+ ${escHtml(tierLabel)} +
+
+
+
+
Evil${scoreLabel} \xB7 ${score}/100Good
+
+
+
+ ${facts.filter(([,v])=>String(v||"").trim()).map(([label,value])=>`
${escHtml(label)}${escHtml(String(value))}
`).join("")} +
+
+
`}function csLcdField(label,value,sheet,field){if(!value)return"";const badge=sheet&&field?csSourceBadgeHtml(sheet,field):"";return label?`
${escHtml(label)}
${escHtml(String(value))}${badge?` ${badge}`:""}
`:`
${escHtml(String(value))}${badge?` ${badge}`:""}
`}function csLcdSection(icon,label,fieldsHtml,full=!1){const inner=(fieldsHtml||[]).filter(Boolean).join("");return inner?`
${inner}
`:""}function csCardHtml(s,meta={}){const inv=(s.inventory||[]).filter(Boolean),invHtml=inv.length?`
Signature Items
${inv.map(escHtml).join(", ")}
`:"",attrs=s.attribute_high||s.attribute_low?`
Core Attributes
\u25B2 ${escHtml(s.attribute_high||"\u2014")}  \xB7  \u25BC ${escHtml(s.attribute_low||"\u2014")}
`:"",sources=(s.sources||[]).filter(x=>x&&(x.quote||x.page!=null||x.line_hint)),srcHtml=sources.length?`
Sources / Evidence
${sources.map((x,i)=>``).join("")}
`:"",laterNote=_cs.running?"generates after casting finishes":"not generated yet",promptParts=[csPromptBox("Voice Design Prompt",s.voice_design_prompt,"voice_design_prompt"),csPromptBox("Character Image Prompt",s.image_prompt,"image_prompt")].filter((_,i)=>[s.voice_design_prompt,s.image_prompt][i]);promptParts.push(csPromptBox("SillyTavern Character Prompt",s.silly_tavern_prompt,"silly_tavern_prompt",laterNote),csPromptBox("Character Concept Art Prompt",s.concept_art_prompt,"concept_art_prompt",laterNote,s.concept_art_image));const promptHtml=promptParts.length?`
${promptParts.join("")}
`:"",voiceId=meta.voice||s.voice||"";return`
+ ${csProfileHeaderHtml(s,meta,csDetailActionsHtml(s))} +
+
+ + ${voiceId?escHtml(voiceId):'Noch keine Stimme'} + + + + +
+
${csAlignmentBar(s.moral_alignment_score,s.arc_direction,s.arc_note)}
+
+ ${(()=>{const sections=[csLcdSection("mdi-card-account-details-outline","Identit\xE4t",[csLcdField("Voller Name",s.full_name,s,"full_name"),csLcdField("Vorname",s.first_name,s,"first_name"),csLcdField("Nachname",s.last_name,s,"last_name"),csLcdField("Alter",s.age_estimate,s,"age_estimate"),csLcdField("Geschlecht",s.gender,s,"gender"),csLcdField("Rasse / Spezies",s.race_species,s,"race_species"),csLcdField("Sprachen",s.languages,s,"languages"),csLcdField("Titel",s.title,s,"title"),csLcdField("Beruf / Rolle",s.profession,s,"profession"),csLcdField("Herkunft",s.nationality_background,s,"nationality_background"),csLcdField("Sozialer Stand",s.social_class,s,"social_class"),csLcdField("Religion",s.religious_beliefs,s,"religious_beliefs"),csLcdField("Ruf",s.reputation,s,"reputation")]),csLcdSection("mdi-account-outline","Erscheinung",[csLcdField("K\xF6rperlich",s.physical,s,"physical"),csLcdField("Kleidung",s.clothing,s,"clothing")]),csLcdSection("mdi-drama-masks","Pers\xF6nlichkeit",[csLcdField("Eigenheiten",s.mannerisms,s,"mannerisms"),csLcdField("Kommunikationsstil",s.communication_style,s,"communication_style"),csLcdField("Stimme & Sprache",s.voice_pattern,s,"voice_pattern")]),csLcdSection("mdi-book-open-outline","Geschichte",[csLcdField("Hintergrund",s.backstory,s,"backstory"),csLcdField("Motivation",s.motivation,s,"motivation"),csLcdField("\xC4ngste",s.fears,s,"fears")]),csLcdSection("mdi-sword","F\xE4higkeiten",[csLcdField("Fertigkeiten",s.skills,s,"skills"),csLcdField("Besondere F\xE4higkeiten",s.capabilities,s,"capabilities"),attrs,invHtml]),csLcdSection("mdi-shield-sword-outline","Konflikt",[csLcdField("Geheimnis / Fataler Fehler",s.secret,s,"secret"),csLcdField("Konfliktstil",s.conflict_style,s,"conflict_style"),csLcdField("Siegbedingung",s.win_condition,s,"win_condition")]),csLcdSection("mdi-account-group-outline","Beziehungen",[csLcdField("",s.relationships,s,"relationships")],!0),csLcdSection("mdi-note-text-outline","Notizen",[csLcdField("",s.notes,s,"notes")],!0)].filter(Boolean);return sections.length?sections.join(""):`
This character's profile hasn't been generated yet \u2014 it fills in as the passage containing their scenes is processed.
`})()}
-
- ${identityHtml} - ${csField("Physical",s.physical,s,"physical")} - ${csField("Clothing & Appearance",s.clothing,s,"clothing")} - ${csField("Alignment & Ethos",s.alignment,s,"alignment")} - ${attrs} - ${csField("Trained Skills",s.skills,s,"skills")} - ${csField("Capabilities",s.capabilities,s,"capabilities")} - ${invHtml} - ${csField("Backstory & Origin",s.backstory,s,"backstory")} - ${csField("Relationships",s.relationships,s,"relationships")} - ${csField("Motivation",s.motivation,s,"motivation")} - ${csField("Fears",s.fears,s,"fears")} - ${csField("Mannerisms & Habits",s.mannerisms,s,"mannerisms")} - ${s.voice_pattern?`
Voice & Speech
${escHtml(String(s.voice_pattern))}${csSourceBadgeHtml(s,"voice_pattern")?` ${csSourceBadgeHtml(s,"voice_pattern")}`:""}
`:""} - ${csField("Dark Secret / Fatal Flaw",s.secret,s,"secret")} - ${csField("Conflict Style",s.conflict_style,s,"conflict_style")} - ${csField("Win Condition",s.win_condition,s,"win_condition")} -
${srcHtml} ${promptHtml} +
+
Click a i mark next to a field above to see the exact sentence it was drawn from.
+
`}function csToMarkdown(sheets){var _a2;const sec=t=>` ## ${t} `;let md=`# Character Sheets `;for(const tier of["main","supp"]){const list=sheets.filter(s=>s.tier==="main"==(tier==="main"));if(list.length){md+=sec(tier==="main"?"Main characters":"Supporting characters");for(const s of list){md+=` ### ${s.name}${s.archetype?" \u2014 "+s.archetype:""} `,s.aliases&&(md+=`*also known as ${s.aliases}* -`),s.full_name&&(md+=`- **Full name:** ${s.full_name} `),s.gender&&(md+=`- **Gender:** ${s.gender} `),s.title&&(md+=`- **Title:** ${s.title} `),s.profession&&(md+=`- **Occupation:** ${s.profession} @@ -1673,7 +1705,7 @@ Respond with STRICT JSON only: `,s.arc_note&&(md+=` *${s.arc_note}* `);const f=(l,v)=>v?`- **${l}:** ${v} `:"";md+=f("Physical",s.physical)+f("Clothing",s.clothing)+f("Alignment & Ethos",s.alignment)+f("Core Attributes",[s.attribute_high&&"\u25B2 "+s.attribute_high,s.attribute_low&&"\u25BC "+s.attribute_low].filter(Boolean).join(" \xB7 "))+f("Trained Skills",s.skills)+f("Capabilities",s.capabilities)+f("Backstory & Origin",s.backstory)+f("Relationships",s.relationships)+f("Motivation",s.motivation)+f("Fears",s.fears)+f("Mannerisms & Habits",s.mannerisms)+f("Voice & Speech",s.voice_pattern)+f("Voice Design Prompt",s.voice_design_prompt)+f("Image Generation Prompt",s.image_prompt)+f("SillyTavern Character Prompt",s.silly_tavern_prompt)+f("Character Concept Art Prompt",s.concept_art_prompt)+f("Signature Items",(s.inventory||[]).join(", "))+f("Dark Secret / Fatal Flaw",s.secret)+f("Conflict Style",s.conflict_style)+f("Win Condition",s.win_condition);const src=(s.sources||[]).filter(x=>x&&x.quote).map(x=>`${x.line_hint?"["+x.line_hint+"] ":""}${x.page!=null?"p."+x.page+" ":""}"${x.quote}"`).join("; ");src&&(md+=`- *Sources:* ${src} -`)}}}return md.trim()}function csWireResultInteractions(root,sheets,sourceText,book,inline=!1){var _a2,_b2;const copyBtn=root.querySelector("#cs-copy");copyBtn==null||copyBtn.addEventListener("click",()=>{var _a3;(_a3=navigator.clipboard)==null||_a3.writeText(csToMarkdown(sheets)).then(()=>toast("Copied as Markdown","success"),()=>toast("Copy failed","error"))});const closeBtn=root.querySelector("#cs-close");closeBtn==null||closeBtn.addEventListener("click",()=>{var _a3;inline?typeof navLibraryView=="function"&&navLibraryView("characters"):(_a3=document.getElementById("cs-overlay"))==null||_a3.remove()}),(_a2=root.querySelector("#cs-open-cast"))==null||_a2.addEventListener("click",()=>csGoToLibrary()),(_b2=root.querySelector("#cs-back-cast-audio"))==null||_b2.addEventListener("click",()=>{typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("cast")});const bkCtx=book||"";root.querySelectorAll(".cs-avatar").forEach(av=>{av.addEventListener("click",e=>{e.stopPropagation();const name=av.dataset.name,inp=document.createElement("input");inp.type="file",inp.accept="image/*",inp.onchange=async()=>{const file=inp.files[0];if(!file)return;const reader=new FileReader;reader.onload=async ev=>{var _a3;const dataUrl=ev.target.result,id=`${bkCtx}::${name}`.toLowerCase();typeof clSetImage=="function"&&await clSetImage(id,dataUrl);const s=sheets.find(x=>x.name===name);s&&(s._image=dataUrl);const card=root.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`);if(card){const oldAv=card.querySelector(".cs-avatar");oldAv&&(oldAv.outerHTML=csAvatarHtml(s),(_a3=card.querySelector(".cs-avatar"))==null||_a3.addEventListener("click",av.onclick))}toast("Profile picture saved","success")},reader.readAsDataURL(file)},inp.click()})}),root.querySelectorAll(".cs-deep-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const sheet=sheets.find(s=>s.name===btn.dataset.name);sheet&&csDeepAnalysis(sheet,sourceText)})}),root.querySelectorAll(".cs-img-btn").forEach(btn=>{btn.addEventListener("click",e=>{var _a3;e.stopPropagation();const sheet=sheets.find(s=>s.name===btn.dataset.name);sheet&&((_a3=navigator.clipboard)==null||_a3.writeText(csBuildImagePrompt(sheet)).then(()=>toast("Image prompt copied","success"),()=>toast("Copy failed","error")))})}),root.querySelectorAll(".cs-voice-btn").forEach(btn=>{btn.addEventListener("click",e=>{var _a3;e.stopPropagation();const sheet=sheets.find(s=>s.name===btn.dataset.name);sheet&&((_a3=navigator.clipboard)==null||_a3.writeText(csBuildVoicePrompt(sheet)).then(()=>toast("Voice prompt copied \u2014 paste into Design a Voice","success"),()=>toast("Copy failed","error")))})}),root.querySelectorAll(".cs-prompt-text[data-sheet-key]").forEach(el=>{el.addEventListener("input",()=>{const key=el.dataset.sheetKey,card=el.closest(".cs-card"),sheet=sheets.find(s=>s.name===(card==null?void 0:card.dataset.name));sheet&&key&&(sheet[key]=el.textContent||"")})}),root.querySelectorAll(".cs-prompt-copy").forEach(btn=>{btn.addEventListener("click",async e=>{var _a3,_b3,_c2;e.stopPropagation();const text=((_b3=(_a3=btn.closest(".lcd-prompt-body"))==null?void 0:_a3.querySelector(".cs-prompt-text"))==null?void 0:_b3.textContent.trim())||"";if(!text){toast("Nothing to copy yet \u2014 generate first","error");return}typeof copyText=="function"?await copyText(text):(_c2=navigator.clipboard)==null||_c2.writeText(text),toast("Prompt copied","success")})}),root.querySelectorAll(".cs-gen-prompt").forEach(btn=>{btn.addEventListener("click",async e=>{var _a3;e.stopPropagation();const key=btn.dataset.sheetKey,card=btn.closest(".cs-card"),name=card==null?void 0:card.dataset.name,sheet=sheets.find(s=>s.name===name);if(!sheet||!key)return;const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026';try{const sample=[sheet.physical,sheet.backstory,sheet.motivation,sheet.relationships].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},r=await fetch("/api/character-generate-prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:sheet.name,book:book||"",sheet,language,llm_url:target.url,model:target.model,fields:[key]})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const d=await r.json();if(!d[key])throw new Error("Empty response \u2014 try again");sheet[key]=d[key];const textEl=(_a3=btn.closest(".lcd-prompt-body"))==null?void 0:_a3.querySelector(".cs-prompt-text");textEl&&(textEl.textContent=d[key]),typeof csSaveToLibrary=="function"&&await csSaveToLibrary(book,sheets),toast("Prompt generated","success")}catch(err){toast("Prompt generation failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=orig}})}),root.querySelectorAll(".cs-source-link, .cs-source-mark").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const pg=parseInt(btn.dataset.page,10);pg&&typeof window.readerJumpToPage=="function"?window.readerJumpToPage(pg):pg&&typeof toast=="function"&&toast(`Source: page ${pg}`,"info")})})}async function csShow(sheets,title,sourceText,book,hostEl=null){var _a2;const inline=!!hostEl;if((_a2=document.getElementById("cs-overlay"))==null||_a2.remove(),typeof clGetAll=="function")try{const bk=book||title||"",all=await clGetAll(),imgMap=new Map(all.filter(r=>r.image).map(r=>[r.id,r.image]));sheets.forEach(s=>{const id=`${bk}::${s.name}`.toLowerCase();imgMap.has(id)&&(s._image=imgMap.get(id))})}catch{}const main=sheets.filter(s=>s.tier==="main"),supp=sheets.filter(s=>s.tier!=="main"),group=(label,list)=>list.length?`
${label}
`+list.map(csCardHtml).join(""):"",ov=inline?hostEl:document.createElement("div");inline?ov.innerHTML=`
+`)}}}return md.trim()}function csWireResultInteractions(root,sheets,sourceText,book,inline=!1){var _a2,_b2;const copyBtn=root.querySelector("#cs-copy");copyBtn==null||copyBtn.addEventListener("click",()=>{var _a3;(_a3=navigator.clipboard)==null||_a3.writeText(csToMarkdown(sheets)).then(()=>toast("Copied as Markdown","success"),()=>toast("Copy failed","error"))});const closeBtn=root.querySelector("#cs-close");closeBtn==null||closeBtn.addEventListener("click",()=>{var _a3;inline?typeof navLibraryView=="function"&&navLibraryView("characters"):(_a3=document.getElementById("cs-overlay"))==null||_a3.remove()}),(_a2=root.querySelector("#cs-open-cast"))==null||_a2.addEventListener("click",()=>csGoToLibrary()),(_b2=root.querySelector("#cs-back-cast-audio"))==null||_b2.addEventListener("click",()=>{typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("cast")});const bkCtx=book||"";root.querySelectorAll(".cs-avatar").forEach(av=>{av.addEventListener("click",async e=>{e.stopPropagation();const name=av.dataset.name,s=sheets.find(x=>x.name===name);if(!s)return;const id=`${bkCtx}::${name}`.toLowerCase();let rec=typeof clGet=="function"?await clGet(id).catch(()=>null):null;if(!rec&&typeof clUpsert=="function"&&(rec=await clUpsert(bkCtx,s).catch(()=>null)),!rec||typeof _openAvatarLightbox!="function"){toast("Avatar editor is unavailable right now","error");return}_openAvatarLightbox(rec,async updatedRec=>{s._image=(updatedRec==null?void 0:updatedRec.image)||rec.image||"";const card=root.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`);if(card){const oldAv=card.querySelector(".cs-avatar");oldAv&&(oldAv.outerHTML=csAvatarHtml(s))}})})}),root.querySelectorAll(".cs-deep-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const sheet=sheets.find(s=>s.name===btn.dataset.name);sheet&&csDeepAnalysis(sheet,sourceText)})}),root.querySelectorAll(".cs-auto-refine-btn").forEach(btn=>{btn.addEventListener("click",async e=>{e.stopPropagation();const name=String(btn.dataset.name||"").trim();if(name){if(typeof window.csForReaderSelective=="function"){await window.csForReaderSelective([name]);return}toast("Character refinement is unavailable right now","error")}})}),root.querySelectorAll(".cs-edit-btn").forEach(btn=>{btn.addEventListener("click",async e=>{e.stopPropagation();const name=String(btn.dataset.name||"").trim();if(!(!name||typeof clEdit!="function"))try{const all=typeof clGetAll=="function"?await clGetAll():[],wantBook=String(book||"").trim().toLowerCase(),rec=all.find(r=>{var _a3;const recName=String((r==null?void 0:r.name)||((_a3=r==null?void 0:r.sheet)==null?void 0:_a3.name)||"").trim().toLowerCase(),recBook=String((r==null?void 0:r.book)||"").trim().toLowerCase();return recName===name.toLowerCase()&&(!wantBook||recBook===wantBook)})||all.find(r=>{var _a3;return String((r==null?void 0:r.name)||((_a3=r==null?void 0:r.sheet)==null?void 0:_a3.name)||"").trim().toLowerCase()===name.toLowerCase()});if(!rec){toast("Character record not found in library","error");return}clEdit(rec.id)}catch{toast("Could not open editor","error")}})}),root.querySelectorAll(".cs-img-btn").forEach(btn=>{btn.addEventListener("click",e=>{var _a3;e.stopPropagation();const sheet=sheets.find(s=>s.name===btn.dataset.name);sheet&&((_a3=navigator.clipboard)==null||_a3.writeText(csBuildImagePrompt(sheet)).then(()=>toast("Image prompt copied","success"),()=>toast("Copy failed","error")))})}),root.querySelectorAll(".cs-voice-btn").forEach(btn=>{btn.addEventListener("click",e=>{var _a3;e.stopPropagation();const sheet=sheets.find(s=>s.name===btn.dataset.name);sheet&&((_a3=navigator.clipboard)==null||_a3.writeText(csBuildVoicePrompt(sheet)).then(()=>toast("Voice prompt copied \u2014 paste into Design a Voice","success"),()=>toast("Copy failed","error")))})}),root.querySelectorAll(".cs-prompt-text[data-sheet-key]").forEach(el=>{el.addEventListener("input",()=>{const key=el.dataset.sheetKey,card=el.closest(".cs-card"),sheet=sheets.find(s=>s.name===(card==null?void 0:card.dataset.name));sheet&&key&&(sheet[key]=el.textContent||"")})}),root.querySelectorAll(".cs-prompt-copy").forEach(btn=>{btn.addEventListener("click",async e=>{var _a3,_b3,_c2;e.stopPropagation();const text=((_b3=(_a3=btn.closest(".lcd-prompt-body"))==null?void 0:_a3.querySelector(".cs-prompt-text"))==null?void 0:_b3.textContent.trim())||"";if(!text){toast("Nothing to copy yet \u2014 generate first","error");return}typeof copyText=="function"?await copyText(text):(_c2=navigator.clipboard)==null||_c2.writeText(text),toast("Prompt copied","success")})}),root.querySelectorAll(".cs-gen-prompt").forEach(btn=>{btn.addEventListener("click",async e=>{var _a3;e.stopPropagation();const key=btn.dataset.sheetKey,card=btn.closest(".cs-card"),name=card==null?void 0:card.dataset.name,sheet=sheets.find(s=>s.name===name);if(!sheet||!key)return;const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026';try{const sample=[sheet.physical,sheet.backstory,sheet.motivation,sheet.relationships].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},r=await fetch("/api/character-generate-prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:sheet.name,book:book||"",sheet,language,llm_url:target.url,model:target.model,fields:[key]})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const d=await r.json();if(!d[key])throw new Error("Empty response \u2014 try again");sheet[key]=d[key];const textEl=(_a3=btn.closest(".lcd-prompt-body"))==null?void 0:_a3.querySelector(".cs-prompt-text");textEl&&(textEl.textContent=d[key]),typeof csSaveToLibrary=="function"&&await csSaveToLibrary(book,sheets),toast("Prompt generated","success")}catch(err){toast("Prompt generation failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=orig}})}),root.querySelectorAll(".cs-prompt-act").forEach(btn=>{btn.addEventListener("click",async e=>{e.stopPropagation();const card=btn.closest(".cs-card"),name=card==null?void 0:card.dataset.name,sheet=sheets.find(s=>s.name===name);if(!sheet)return;const act=btn.dataset.act;let rec=typeof clUpsert=="function"?await clUpsert(book||"",sheet).catch(()=>null):null;if(!rec){toast("Could not save this character yet \u2014 try again","error");return}if(act==="voice"){typeof _charDesignVoice=="function"&&_charDesignVoice(rec);return}if(act==="image"){const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026';try{await _charAutoGenerateImage(rec),sheet._image=rec.image;const av=card==null?void 0:card.querySelector(".cs-avatar");av&&(av.outerHTML=csAvatarHtml(sheet)),toast("Profile image generated","success")}catch(err){toast("Image generation failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=orig}}if(act==="concept_art"){const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026';try{const img=await _charAutoGenerateConceptArt(rec);sheet.concept_art_image=img;const box=btn.closest(".cs-prompt-box"),body=box==null?void 0:box.querySelector(".lcd-prompt-body"),existingPreview=body==null?void 0:body.querySelector(".cs-concept-art-preview");existingPreview?existingPreview.querySelector("img").src=img:body&&body.insertAdjacentHTML("afterbegin",`
Concept art
`),btn.innerHTML=' Regenerate Concept Art',toast("Concept art generated","success")}catch(err){toast("Concept art generation failed: "+(err.message||err),"error"),btn.innerHTML=orig}finally{btn.disabled=!1}}})}),root.querySelectorAll(".cs-edit-toggle").forEach(btn=>{const card=btn.closest(".cs-card");if(!card)return;const saveTimers=new Map;btn.addEventListener("click",e=>{e.stopPropagation();const editing=card.classList.toggle("cs-editing");btn.innerHTML=editing?'':'',btn.title=editing?"Done editing":"Edit character sheet",card.querySelectorAll(".cs-field-editable[data-sheet-key]").forEach(el=>{el.contentEditable=editing?"true":"false",editing&&!el.dataset.wired&&(el.dataset.wired="1",el.addEventListener("input",()=>{const key=el.dataset.sheetKey,name=card.dataset.name,sheet=sheets.find(s=>s.name===name);!sheet||!key||(clearTimeout(saveTimers.get(key)),saveTimers.set(key,setTimeout(async()=>{sheet[key]=el.textContent.trim(),typeof csSaveToLibrary=="function"&&await csSaveToLibrary(book,[sheet])},900)))}))})})}),root.querySelectorAll(".cs-source-link, .cs-source-mark").forEach(btn=>{btn.addEventListener("click",async e=>{e.stopPropagation();const pg=parseInt(btn.dataset.page,10),quote=(btn.dataset.quote||"").trim();if(!pg){toast("No page reference saved for this source","error");return}if(typeof window.readerJumpToPage!="function"){toast(`Source: page ${pg}`,"info");return}if(await window.readerJumpToPage(pg),quote&&typeof readerSearchApply=="function"){const term=quote.length>60?quote.slice(0,60):quote;readerSearchApply(term,{jump:!0});const inp=document.getElementById("reader-search");inp&&(inp.value=term)}})})}async function csShow(sheets,title,sourceText,book,hostEl=null){var _a2;const inline=!!hostEl;if((_a2=document.getElementById("cs-overlay"))==null||_a2.remove(),typeof clGetAll=="function")try{const bk=book||title||"",all=await clGetAll(),imgMap=new Map(all.filter(r=>r.image).map(r=>[r.id,r.image]));sheets.forEach(s=>{const id=`${bk}::${s.name}`.toLowerCase();imgMap.has(id)&&(s._image=imgMap.get(id))})}catch{}const bkCtx=book||title||"";let allRecs=[];try{allRecs=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(bkCtx):[]}catch{allRecs=[]}const recByName=new Map(allRecs.map(r=>[String(r.name||"").toLowerCase(),r])),recFor=s=>recByName.get(String(s.name||"").toLowerCase())||{id:"",name:s.name,sheet:s,voice:s.voice,book:bkCtx,tags:""},recsById=new Map,cardsFor=list=>list.map(s=>{const rec=recFor(s);return rec.id&&recsById.set(rec.id,rec),typeof _charCardHtml=="function"?_charCardHtml(rec,allRecs):csCardHtml(s)}).join(""),main=sheets.filter(s=>s.tier==="main"),supp=sheets.filter(s=>s.tier!=="main"),group=(label,list)=>list.length?`
${label}
`+cardsFor(list):"",ov=inline?hostEl:document.createElement("div");if(inline?ov.innerHTML=`
${escHtml(title||"Character sheets")} @@ -1685,7 +1717,7 @@ Respond with STRICT JSON only:
Generated sheets are saved into the cast library. Use the buttons above to jump back to the audiobook cast or continue to the roster stage.
-
${group("Main characters",main)}${group("Supporting characters",supp)}
+
${group("Main characters",main)}${group("Supporting characters",supp)}
`:(ov.id="cs-overlay",ov.className="audiobook-overlay",ov.innerHTML=`
@@ -1696,8 +1728,8 @@ Respond with STRICT JSON only:
-
${group("Main characters",main)}${group("Supporting characters",supp)}
-
`,document.body.appendChild(ov),ov.addEventListener("click",e=>{e.target===ov&&ov.remove()})),csWireResultInteractions(ov,sheets,sourceText,book,inline)}async function csSaveToLibrary(book,sheets){if(typeof clUpsertMany=="function")try{const n=await clUpsertMany(book,sheets);n&&toast(`${n} character${n!==1?"s":""} saved to library`,"success")}catch{}}function csGoToLibrary(){typeof navTo=="function"&&navTo("s-library"),typeof navLibraryView=="function"?navLibraryView("characters"):typeof libraryRender=="function"&&libraryRender("characters"),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("castlib")}function csAttachLineCounts(sheets,counts){!counts||!counts.size||sheets.forEach(function(s){const n=counts.get(String(s.name||"").trim().toLowerCase());n!=null&&(s.line_count=n)})}async function csForReader(){const text=csReaderText(),book=readerState.title||"Untitled book",key="reader:"+(readerState.title||"")+":"+(typeof readerScopeIndices=="function"?readerScopeIndices().length:0);typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("chars");const pageHost=csReaderPageHost();if(_cs.cache[key]){await csSaveToLibrary(book,_cs.cache[key]),pageHost&&await csShow(_cs.cache[key],book,text,book,pageHost);return}const knownRoster=csKnownReaderRoster();let seedSheets=[];try{typeof clGetAllByTagOrBook=="function"&&(seedSheets=(await clGetAllByTagOrBook(book)||[]).map(rec=>csSeedSheet(rec)).filter(s=>String(s.name||"").trim()))}catch{seedSheets=[]}const sheets=await csGenerate(text,key,knownRoster,{pageHost,seedSheets});if(!sheets)return;if(!sheets.length){toast("No characters found","error");return}const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook;if(ab!=null&&ab.roster){const counts=new Map;ab.roster.forEach(function(info,name){counts.set(String(name).trim().toLowerCase(),info.count||0)}),csAttachLineCounts(sheets,counts)}await csSaveToLibrary(book,sheets),pageHost&&await csShow(sheets,book,text,book,pageHost),toast(sheets.length+" character sheets saved \u2014 Library \u2192 Cast","success")}async function csForReaderSelective(selectedNames){var _a2;const wanted=new Set((selectedNames||[]).map(n=>String(n).trim().toLowerCase()));if(!wanted.size){toast("No characters selected","error");return}const text=csReaderText(),book=readerState.title||"Untitled book";typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("chars");const pageHost=csReaderPageHost();let records=[];try{records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(book):[]}catch{records=[]}const picked=[],seen=new Set,matchesWanted=rec=>{var _a3,_b2,_c2,_d2;const tokens=csNameTokens({name:(rec==null?void 0:rec.name)||"",aliases:((_a3=rec==null?void 0:rec.sheet)==null?void 0:_a3.aliases)||"",first_name:((_b2=rec==null?void 0:rec.sheet)==null?void 0:_b2.first_name)||"",last_name:((_c2=rec==null?void 0:rec.sheet)==null?void 0:_c2.last_name)||"",full_name:((_d2=rec==null?void 0:rec.sheet)==null?void 0:_d2.full_name)||""});for(const t of tokens){const lower=String(t||"").toLowerCase();for(const w of wanted)if(lower===w||lower.includes(w)||w.includes(lower))return!0}return!1};if((records||[]).forEach(rec=>{if(!rec||!rec.name||!matchesWanted(rec))return;const key=String(rec.name||"").trim().toLowerCase();seen.has(key)||(seen.add(key),picked.push(rec))}),(selectedNames||[]).forEach(name=>{const key=String(name||"").trim().toLowerCase();if(!key||seen.has(key))return;const fallback={name:String(name||"").trim(),sheet:csBlankSheet(String(name||"").trim())};seen.add(key),picked.push(fallback)}),picked.sort((a,b)=>{var _a3,_b2;return(((_a3=b==null?void 0:b.sheet)==null?void 0:_a3.line_count)||0)-(((_b2=a==null?void 0:a.sheet)==null?void 0:_b2.line_count)||0)||String((a==null?void 0:a.name)||"").localeCompare(String((b==null?void 0:b.name)||""))}),!picked.length){toast("No matching cast characters found","error");return}const finalMap=new Map;for(const rec of picked){const targetName=String(rec.name||"").trim();if(!targetName)continue;const needles=csRecordNeedles(rec),scanText=csEvidenceWindowText(csReaderParagraphBlocks(),needles,2,2)||text,seedSheet=csSeedSheet(rec),sheets=await csGenerate(scanText,null,[targetName],{pageHost,seedSheets:[seedSheet]});if(!sheets)return;const filtered2=sheets.filter(s=>wanted.has(String(s.name||"").trim().toLowerCase()));filtered2.length&&(((_a2=rec==null?void 0:rec.sheet)==null?void 0:_a2.line_count)!=null&&filtered2.forEach(s=>{s.line_count=rec.sheet.line_count}),csMerge(finalMap,filtered2))}const filtered=[...finalMap.values()];if(!filtered.length){toast("None of the selected characters turned up in this pass \u2014 try again or pick different ones","error");return}const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook;if(ab!=null&&ab.roster){const counts=new Map;ab.roster.forEach(function(info,name){counts.set(String(name).trim().toLowerCase(),info.count||0)}),csAttachLineCounts(filtered,counts)}await csSaveToLibrary(book,filtered),pageHost&&await csShow(filtered,book,text,book,pageHost),toast(filtered.length+" character"+(filtered.length!==1?"s":"")+" defined","success")}async function csForRehearser(){var _a2,_b2,_c2,_d2;const title=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Character sheets",book=((_b2=$("reh-script-title"))==null?void 0:_b2.value.trim())||"Untitled script",text=csRehearserText(),key="reh:"+title+":"+(rehState.lines||[]).length;typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("chars");const pageHost=csReaderPageHost();if(_cs.cache[key]){await csSaveToLibrary(book,_cs.cache[key]),pageHost&&await csShow(_cs.cache[key],title,text,book,pageHost);return}let seedSheets=[];try{typeof clGetAllByTagOrBook=="function"&&(seedSheets=(await clGetAllByTagOrBook(book)||[]).map(rec=>csSeedSheet(rec)).filter(s=>String(s.name||"").trim()))}catch{seedSheets=[]}const sheets=await csGenerate(text,key,null,{pageHost,seedSheets});if(sheets){if(!sheets.length){toast("No characters found","error");return}if((_d2=(_c2=window.rehState)==null?void 0:_c2.lines)!=null&&_d2.length){const counts=new Map;rehState.lines.forEach(function(l){if(l.type!=="dialog"||!l.speaker)return;const k=String(l.speaker).trim().toLowerCase();counts.set(k,(counts.get(k)||0)+1)}),csAttachLineCounts(sheets,counts)}await csSaveToLibrary(book,sheets),pageHost&&await csShow(sheets,title,text,book,pageHost),toast(sheets.length+" character sheets saved \u2014 Library \u2192 Cast","success")}}window.csForReader=csForReader,window.csForReaderSelective=csForReaderSelective,window.csForRehearser=csForRehearser,(_nc=$("reader-charsheets-btn"))==null||_nc.addEventListener("click",csForReader),(_oc=$("reh-charsheets-btn"))==null||_oc.addEventListener("click",csForRehearser);const CL_EDIT_FIELDS=[["name","Name"],["aliases","Aliases / also known as"],["first_name","First name"],["last_name","Last name"],["full_name","Full name"],["title","Title / role"],["archetype","Archetype"],["physical","Physical"],["clothing","Clothing & Appearance"],["alignment","Alignment & Ethos"],["arc_note","Arc note"],["skills","Trained Skills"],["capabilities","Capabilities"],["backstory","Backstory & Origin"],["relationships","Relationships"],["motivation","Motivation"],["fears","Fears"],["mannerisms","Mannerisms & Habits"],["voice_pattern","Voice & Speech"],["voice_design_prompt","Voice Design Prompt"],["image_prompt","Image Generation Prompt"],["silly_tavern_prompt","SillyTavern Character Prompt"],["concept_art_prompt","Concept Art Prompt"],["secret","Dark Secret / Fatal Flaw"],["conflict_style","Conflict Style"],["win_condition","Win Condition"]];async function clGetAll(){const r=await fetch("/api/characters");if(!r.ok)throw new Error("clGetAll failed: "+r.status);return(await r.json()).characters||[]}async function clGet(id){const r=await fetch("/api/characters/"+encodeURIComponent(id));if(r.status!==404){if(!r.ok)throw new Error("clGet failed: "+r.status);return r.json()}}async function clPut(rec){rec!=null&&rec.color&&(rec.sheet=rec.sheet||{},rec.sheet.color=clNormalizeColor(rec.color,rec.name));const r=await fetch("/api/characters/"+encodeURIComponent(rec.id),{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(rec)});if(!r.ok)throw new Error("clPut failed: "+r.status);return r.json()}async function clDelete(id){const r=await fetch("/api/characters/"+encodeURIComponent(id),{method:"DELETE"});if(!r.ok)throw new Error("clDelete failed: "+r.status)}function clKey(book,name){return`${String(book||"").trim()}::${String(name||"").trim()}`.toLowerCase()}function clHslToHex(h,s,l){s/=100,l/=100;const k=n=>(n+h/30)%12,a=s*Math.min(l,1-l),f=n=>l-a*Math.max(-1,Math.min(k(n)-3,Math.min(9-k(n),1)));return"#"+[f(0),f(8),f(4)].map(x=>Math.round(255*x).toString(16).padStart(2,"0")).join("")}function clNameHue(name){return Math.abs((name||"?").split("").reduce((h,c)=>(h*31+c.charCodeAt(0))%360,0))}function clNormalizeColor(color,name){const c=String(color||"").trim();return/^#[0-9a-f]{6}$/i.test(c)?c:/^#[0-9a-f]{3}$/i.test(c)?"#"+c.slice(1).split("").map(ch=>ch+ch).join(""):clHslToHex(clNameHue(name),58,43)}const CL_IDENTITY_FIELDS=["name","aliases","first_name","last_name","full_name","title"],CL_ALIAS_MAX_TOKENS=12,CL_ALIAS_MAX_CHARS=500,CL_ALIAS_STOPWORDS=new Set(["die","der","das","den","dem","des","ein","eine","einer","er","sie","es","ich","du","wir","ihr","the","a","an","he","she","it","they","who"]);function clSplitIdentityTokens(v,opts={}){const raw=_clStr(v),parts=raw.split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i).map(x=>x.trim()).filter(Boolean).filter(x=>x.length<=80&&!/^needs?:/i.test(x)&&!/^complete$/i.test(x)).filter(x=>!CL_ALIAS_STOPWORDS.has(x.toLowerCase()));return opts.aliases&&(raw.length>CL_ALIAS_MAX_CHARS||parts.length>CL_ALIAS_MAX_TOKENS)?[]:parts.slice(0,opts.aliases?CL_ALIAS_MAX_TOKENS:void 0)}function clIdentityNames(recOrSheet){const s=(recOrSheet==null?void 0:recOrSheet.sheet)||recOrSheet||{},out=new Set,add=(v,opts={})=>clSplitIdentityTokens(v,opts).forEach(x=>out.add(x.toLowerCase()));return add((recOrSheet==null?void 0:recOrSheet.name)||s.name),CL_IDENTITY_FIELDS.filter(k=>k!=="name").forEach(k=>add(s[k],{aliases:k==="aliases"})),out}function clMergeAliases(existing,incoming){const names=new Map,add=v=>clSplitIdentityTokens(v,{aliases:!0}).forEach(x=>names.set(x.toLowerCase(),x));return add(existing.aliases),add(incoming.aliases),incoming.name&&incoming.name!==existing.name&&add(incoming.name),[...names.values()].filter(n=>n.toLowerCase()!==String(existing.name||"").toLowerCase()).join(", ")}function clSameIdentity(rec,book,sheet){if(String((rec==null?void 0:rec.book)||"").trim().toLowerCase()!==String(book||"").trim().toLowerCase())return!1;const a=clIdentityNames(rec),b=clIdentityNames(sheet);for(const n of b)if(a.has(n))return!0;return!1}function _clStr(v){return v==null?"":typeof v=="string"?v:Array.isArray(v)?v.filter(Boolean).join(", "):JSON.stringify(v)}function _clSanitize(sheet){const out={...sheet};return(typeof CS_SCALAR_FIELDS!="undefined"?CS_SCALAR_FIELDS:CL_EDIT_FIELDS.map(f=>f[0]).filter(k=>k!=="name")).forEach(f=>{out[f]!=null&&(out[f]=_clStr(out[f]))}),out}function clMergeSheet(existing,incoming){const e={...existing},inc=_clSanitize(incoming),scalars=typeof CS_SCALAR_FIELDS!="undefined"?CS_SCALAR_FIELDS:CL_EDIT_FIELDS.map(f=>f[0]).filter(k=>k!=="name"),oldAliases=e.aliases;return scalars.forEach(f=>{(inc[f]||"").length>(e[f]||"").length&&(e[f]=inc[f])}),e.aliases=clMergeAliases({...e,aliases:oldAliases},incoming),incoming.tier==="main"&&(e.tier="main"),incoming.moral_alignment_score!=null&&(e.moral_alignment_score=e.moral_alignment_score!=null?Math.round((e.moral_alignment_score+incoming.moral_alignment_score)/2):incoming.moral_alignment_score),incoming.arc_direction&&incoming.arc_direction!=="neutral"&&(e.arc_direction=incoming.arc_direction),incoming.gender&&!e.gender&&(e.gender=incoming.gender),incoming.line_count!=null&&(e.line_count=incoming.line_count),e.inventory=[...existing.inventory||[]],(incoming.inventory||[]).forEach(it=>{it&&!e.inventory.includes(it)&&e.inventory.length<3&&e.inventory.push(it)}),e.sources=[...existing.sources||[]],(incoming.sources||[]).forEach(src=>{src&&src.quote&&e.sources.length<12&&!e.sources.some(x=>x.quote===src.quote)&&e.sources.push(src)}),e}function clMergeTags(...parts){const set=new Set;return parts.forEach(p=>String(p||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>set.add(t))),[...set].join(", ")}async function clUpsert(book,sheet){var _a2;const name=(sheet.name||"").trim();if(!name)return null;const bk=(book||"").trim()||"Unsorted",aliasPrev=(await clGetAll().catch(()=>[])).find(r=>clSameIdentity(r,bk,sheet)),id=(aliasPrev==null?void 0:aliasPrev.id)||clKey(bk,name),now=new Date,prev=aliasPrev||await clGet(id).catch(()=>null),merged=prev?clMergeSheet(prev.sheet||{},sheet):{..._clSanitize(sheet),name},canonicalName=(prev==null?void 0:prev.name)||name;merged.name=canonicalName;const tags=clMergeTags(prev==null?void 0:prev.tags,sheet.tags,bk),color=clNormalizeColor((prev==null?void 0:prev.color)||((_a2=prev==null?void 0:prev.sheet)==null?void 0:_a2.color)||sheet.color,canonicalName);merged.color=color;const rec={id,book:bk,name:canonicalName,tags,sheet:merged,color,analysis:(prev==null?void 0:prev.analysis)||null,voice:(prev==null?void 0:prev.voice)||sheet.voice||null,image:(prev==null?void 0:prev.image)||sheet.image||null,created:(prev==null?void 0:prev.created)||now,updated:now};return await clPut(rec),rec}async function clSetImage(id,dataUrl){const rec=await clGet(id).catch(()=>null);if(rec)return rec.image=dataUrl||null,rec.updated=new Date,await clPut(rec),rec}window.clSetImage=clSetImage;async function clGetAllByTagOrBook(title){const key=String(title||"").trim().toLowerCase();return key?(await clGetAll().catch(()=>[])).filter(r=>String(r.book||"").trim().toLowerCase()===key?!0:String(r.tags||"").split(",").some(t=>t.trim().toLowerCase()===key)):[]}async function clUpsertMany(book,sheets){let n=0;for(const s of sheets||[])await clUpsert(book,s)&&n++;return n}(async function(){try{if((await clGetAll()).length>0)return;const idbRecs=await _clIdbGetAll().catch(()=>[]);if(!idbRecs.length)return;const r=await fetch("/api/characters/migrate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(idbRecs)});if(r.ok){const d=await r.json();console.log(`[characters-library] migrated ${d.imported} records from IndexedDB \u2192 SQLite`)}}catch(e){console.warn("[characters-library] migration skipped:",e)}})();function _clIdbGetAll(){return new Promise((resolve,reject)=>{const req=indexedDB.open("character-library",1);req.onerror=()=>resolve([]),req.onsuccess=e=>{const db=e.target.result;if(!db.objectStoreNames.contains("characters")){db.close(),resolve([]);return}const all=db.transaction("characters","readonly").objectStore("characters").getAll();all.onsuccess=ev=>{db.close(),resolve(ev.target.result||[])},all.onerror=()=>{db.close(),resolve([])}}})}let _clRecords=[];async function clRender(){if(!document.getElementById("cl-grid"))return;const filterEl=document.getElementById("cl-book-filter"),searchEl=document.getElementById("cl-search");filterEl&&!filterEl.dataset.bound&&(filterEl.dataset.bound="1",filterEl.addEventListener("change",clApplyFilter)),searchEl&&!searchEl.dataset.bound&&(searchEl.dataset.bound="1",searchEl.addEventListener("input",clApplyFilter));try{_clRecords=await clGetAll()}catch{_clRecords=[]}const filter=document.getElementById("cl-book-filter"),prods=clAllProductions();if(filter){const cur=filter.value;filter.innerHTML=``+prods.map(b=>``).join(""),cur&&prods.includes(cur)&&(filter.value=cur)}clApplyFilter()}function clAllProductions(){const set=new Set;return _clRecords.forEach(r=>{r.book&&set.add(r.book),String(r.tags||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>set.add(t))}),[...set].sort((a,b)=>a.localeCompare(b))}function clApplyFilter(){var _a2,_b2;const grid=document.getElementById("cl-grid");if(!grid)return;const book=((_a2=document.getElementById("cl-book-filter"))==null?void 0:_a2.value)||"",q=(((_b2=document.getElementById("cl-search"))==null?void 0:_b2.value)||"").trim().toLowerCase();let recs=_clRecords.slice();if(book){const bk=book.toLowerCase();recs=recs.filter(r=>(r.book||"").toLowerCase()===bk||String(r.tags||"").split(",").some(t=>t.trim().toLowerCase()===bk))}if(q&&(recs=recs.filter(r=>{var _a3,_b3,_c2,_d2,_e2,_f2;return(r.name||"").toLowerCase().includes(q)||(((_a3=r.sheet)==null?void 0:_a3.aliases)||"").toLowerCase().includes(q)||(((_b3=r.sheet)==null?void 0:_b3.first_name)||"").toLowerCase().includes(q)||(((_c2=r.sheet)==null?void 0:_c2.last_name)||"").toLowerCase().includes(q)||(((_d2=r.sheet)==null?void 0:_d2.full_name)||"").toLowerCase().includes(q)||(((_e2=r.sheet)==null?void 0:_e2.title)||"").toLowerCase().includes(q)||(((_f2=r.sheet)==null?void 0:_f2.archetype)||"").toLowerCase().includes(q)||(r.tags||"").toLowerCase().includes(q)||(r.book||"").toLowerCase().includes(q)})),!recs.length){grid.innerHTML=`
+
${group("Main characters",main)}${group("Supporting characters",supp)}
+
`,document.body.appendChild(ov),ov.addEventListener("click",e=>{e.target===ov&&ov.remove()})),csWireResultInteractions(ov,sheets,sourceText,book,inline),typeof _wireCharCards=="function"){const listEl=ov.querySelector(".cs-list")||ov;_wireCharCards(ov,recsById,allRecs,null,{container:listEl,onBack:()=>csShow(sheets,title,sourceText,book,hostEl)})}}async function csSaveToLibrary(book,sheets){if(typeof clUpsertMany=="function")try{const n=await clUpsertMany(book,sheets);n&&toast(`${n} character${n!==1?"s":""} saved to library`,"success")}catch{}}async function csAutoGenerateExternalPrompts(book,sheets){if(!Array.isArray(sheets)||!sheets.length)return;const target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""};let done=0,failed=0,repeatMsg="",repeatCount=0;toast(`Generating SillyTavern + Concept Art prompts for ${sheets.length} character${sheets.length!==1?"s":""}\u2026`,"info");for(const s of sheets)if(!(!s||!s.name))try{const sample=[s.physical,s.backstory,s.motivation].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",r=await fetch("/api/character-generate-prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:s.name,book,sheet:s,language,llm_url:target.url,model:target.model,fields:["silly_tavern_prompt","concept_art_prompt"]})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const d=await r.json();d.silly_tavern_prompt&&(s.silly_tavern_prompt=d.silly_tavern_prompt),d.concept_art_prompt&&(s.concept_art_prompt=d.concept_art_prompt),done++,repeatCount=0}catch(e){failed++;const msg=e&&e.message?e.message:String(e);if(console.error("[auto external prompts]",s.name,e),msg===repeatMsg?repeatCount++:(repeatMsg=msg,repeatCount=1),repeatCount>=3)break}try{await clUpsertMany(book,sheets)}catch{}const suffix=failed?` (${failed} failed${repeatMsg?": "+repeatMsg.slice(0,160):""})`:"";if(toast(`External prompts generated for ${done} character${done!==1?"s":""}${suffix}`,failed&&!done?"error":"success"),typeof _charAutoGenerateConceptArt!="function")return;const withPrompt=sheets.filter(s=>s&&s.name&&_libStr(s.concept_art_prompt).trim());if(!withPrompt.length)return;let imgDone=0,imgFailed=0,imgRepeatMsg="",imgRepeatCount=0;toast(`Generating concept art for ${withPrompt.length} character${withPrompt.length!==1?"s":""}\u2026`,"info");for(const s of withPrompt)try{await _charAutoGenerateConceptArt({id:clKey(book,s.name),sheet:s}),imgDone++,imgRepeatCount=0}catch(e){imgFailed++;const msg=e&&e.message?e.message:String(e);if(console.error("[auto concept art]",s.name,e),msg===imgRepeatMsg?imgRepeatCount++:(imgRepeatMsg=msg,imgRepeatCount=1),imgRepeatCount>=3)break}const imgSuffix=imgFailed?` (${imgFailed} failed${imgRepeatMsg?": "+imgRepeatMsg.slice(0,160):""})`:"";toast(`Concept art generated for ${imgDone} character${imgDone!==1?"s":""}${imgSuffix}`,imgFailed&&!imgDone?"error":"success")}function csGoToLibrary(){typeof navTo=="function"&&navTo("s-library"),typeof navLibraryView=="function"?navLibraryView("characters"):typeof libraryRender=="function"&&libraryRender("characters"),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("castlib")}function csAttachLineCounts(sheets,counts){!counts||!counts.size||sheets.forEach(function(s){const n=counts.get(String(s.name||"").trim().toLowerCase());n!=null&&(s.line_count=n)})}async function csForReader(opts={}){var _a2;const fresh=!!opts.fresh,text=csReaderText(),book=readerState.title||"Untitled book",key="reader:"+(readerState.title||"")+":"+(typeof readerScopeIndices=="function"?readerScopeIndices().length:0);typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("chars"),typeof showReaderView=="function"&&setTimeout(()=>showReaderView("chars"),120);const pageHost=csReaderPageHost();if(!fresh&&_cs.cache[key]){await csSaveToLibrary(book,_cs.cache[key]),pageHost&&await csShow(_cs.cache[key],book,text,book,pageHost);return}const knownRoster=csKnownReaderRoster();let seedSheets=[];if(!fresh)try{if(typeof clGetAllByTagOrBook=="function"){const existing=await clGetAllByTagOrBook(book),rosterSet=new Set(knownRoster.map(n=>n.toLowerCase())),stillCast=rec=>!rosterSet.size||rosterSet.has(String((rec==null?void 0:rec.name)||"").trim().toLowerCase());seedSheets=(existing||[]).filter(stillCast).map(rec=>csSeedSheet(rec)).filter(s=>String(s.name||"").trim())}}catch{seedSheets=[]}const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook,lineCounts=new Map;if((_a2=ab==null?void 0:ab.segments)!=null&&_a2.length)for(const s of ab.segments){if((s==null?void 0:s.type)!=="dialogue"||!s.speaker)continue;const k=String(s.speaker).trim().toLowerCase();lineCounts.set(k,(lineCounts.get(k)||0)+1)}const sheets=await csGenerate(text,key,knownRoster,{pageHost,seedSheets,lineCounts});if(sheets){if(!sheets.length){toast("No characters found","error");return}lineCounts.size&&csAttachLineCounts(sheets,lineCounts),await csSaveToLibrary(book,sheets),pageHost&&await csShow(sheets,book,text,book,pageHost),toast(sheets.length+" character sheets saved \u2014 Library \u2192 Cast","success"),csAutoGenerateExternalPrompts(book,sheets)}}async function csForReaderSelective(selectedNames){var _a2;const wanted=new Set((selectedNames||[]).map(n=>String(n).trim().toLowerCase()));if(!wanted.size){toast("No characters selected","error");return}const text=csReaderText(),book=readerState.title||"Untitled book";typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("chars"),typeof showReaderView=="function"&&setTimeout(()=>showReaderView("chars"),120);const pageHost=csReaderPageHost();let records=[];try{records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(book):[]}catch{records=[]}const picked=[],seen=new Set,matchesWanted=rec=>{var _a3,_b2,_c2,_d2;const tokens=csNameTokens({name:(rec==null?void 0:rec.name)||"",aliases:((_a3=rec==null?void 0:rec.sheet)==null?void 0:_a3.aliases)||"",first_name:((_b2=rec==null?void 0:rec.sheet)==null?void 0:_b2.first_name)||"",last_name:((_c2=rec==null?void 0:rec.sheet)==null?void 0:_c2.last_name)||"",full_name:((_d2=rec==null?void 0:rec.sheet)==null?void 0:_d2.full_name)||""});for(const t of tokens){const lower=String(t||"").toLowerCase();for(const w of wanted)if(lower===w||lower.includes(w)||w.includes(lower))return!0}return!1};if((records||[]).forEach(rec=>{if(!rec||!rec.name||!matchesWanted(rec))return;const key=String(rec.name||"").trim().toLowerCase();seen.has(key)||(seen.add(key),picked.push(rec))}),(selectedNames||[]).forEach(name=>{const key=String(name||"").trim().toLowerCase();if(!key||seen.has(key))return;const fallback={name:String(name||"").trim(),sheet:csBlankSheet(String(name||"").trim())};seen.add(key),picked.push(fallback)}),picked.sort((a,b)=>{var _a3,_b2;return(((_a3=b==null?void 0:b.sheet)==null?void 0:_a3.line_count)||0)-(((_b2=a==null?void 0:a.sheet)==null?void 0:_b2.line_count)||0)||String((a==null?void 0:a.name)||"").localeCompare(String((b==null?void 0:b.name)||""))}),!picked.length){toast("No matching cast characters found","error");return}const finalMap=new Map;for(const rec of picked){const targetName=String(rec.name||"").trim();if(!targetName)continue;const needles=csRecordNeedles(rec),scanText=csEvidenceWindowText(csReaderParagraphBlocks(),needles,2,2)||text,seedSheet=csSeedSheet(rec),sheets=await csGenerate(scanText,null,[targetName],{pageHost,seedSheets:[seedSheet]});if(!sheets)return;const filtered2=sheets.filter(s=>wanted.has(String(s.name||"").trim().toLowerCase()));filtered2.length&&(((_a2=rec==null?void 0:rec.sheet)==null?void 0:_a2.line_count)!=null&&filtered2.forEach(s=>{s.line_count=rec.sheet.line_count}),csMerge(finalMap,filtered2))}const filtered=[...finalMap.values()];if(!filtered.length){toast("None of the selected characters turned up in this pass \u2014 try again or pick different ones","error");return}const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook;if(ab!=null&&ab.roster){const counts=new Map;ab.roster.forEach(function(info,name){counts.set(String(name).trim().toLowerCase(),info.count||0)}),csAttachLineCounts(filtered,counts)}await csSaveToLibrary(book,filtered),pageHost&&await csShow(filtered,book,text,book,pageHost),toast(filtered.length+" character"+(filtered.length!==1?"s":"")+" defined","success")}async function csForRehearser(){var _a2,_b2,_c2,_d2;const title=((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Character sheets",book=((_b2=$("reh-script-title"))==null?void 0:_b2.value.trim())||"Untitled script",text=csRehearserText(),key="reh:"+title+":"+(rehState.lines||[]).length;typeof navTo=="function"&&navTo("s-reader"),typeof showReaderView=="function"&&showReaderView("chars"),typeof showReaderView=="function"&&setTimeout(()=>showReaderView("chars"),120);const pageHost=csReaderPageHost();if(_cs.cache[key]){await csSaveToLibrary(book,_cs.cache[key]),pageHost&&await csShow(_cs.cache[key],title,text,book,pageHost);return}let seedSheets=[];try{typeof clGetAllByTagOrBook=="function"&&(seedSheets=(await clGetAllByTagOrBook(book)||[]).map(rec=>csSeedSheet(rec)).filter(s=>String(s.name||"").trim()))}catch{seedSheets=[]}const sheets=await csGenerate(text,key,null,{pageHost,seedSheets});if(sheets){if(!sheets.length){toast("No characters found","error");return}if((_d2=(_c2=window.rehState)==null?void 0:_c2.lines)!=null&&_d2.length){const counts=new Map;rehState.lines.forEach(function(l){if(l.type!=="dialog"||!l.speaker)return;const k=String(l.speaker).trim().toLowerCase();counts.set(k,(counts.get(k)||0)+1)}),csAttachLineCounts(sheets,counts)}await csSaveToLibrary(book,sheets),pageHost&&await csShow(sheets,title,text,book,pageHost),toast(sheets.length+" character sheets saved \u2014 Library \u2192 Cast","success")}}window.csForReader=csForReader,window.csForReaderSelective=csForReaderSelective,window.csForRehearser=csForRehearser,(_pc=$("reader-charsheets-btn"))==null||_pc.addEventListener("click",csForReader),(_qc=$("reh-charsheets-btn"))==null||_qc.addEventListener("click",csForRehearser);const CL_EDIT_FIELDS=[["name","Name"],["aliases","Aliases / also known as"],["first_name","First name"],["last_name","Last name"],["title","Title / role"],["age_estimate","Estimated age"],["race_species","Race / species"],["languages","Languages"],["nationality_background","Nationality / background"],["social_class","Social class"],["archetype","Archetype"],["physical","Physical"],["clothing","Clothing & Appearance"],["alignment","Alignment & Ethos"],["arc_note","Arc note"],["skills","Trained Skills"],["capabilities","Capabilities"],["backstory","Backstory & Origin"],["relationships","Relationships"],["motivation","Motivation"],["fears","Fears"],["mannerisms","Mannerisms & Habits"],["communication_style","Communication style"],["reputation","Reputation"],["religious_beliefs","Religious beliefs"],["notes","Notes"],["voice_pattern","Voice & Speech"],["voice_design_prompt","Voice Design Prompt"],["image_prompt","Image Generation Prompt"],["silly_tavern_prompt","SillyTavern Character Prompt"],["concept_art_prompt","Concept Art Prompt"],["secret","Dark Secret / Fatal Flaw"],["conflict_style","Conflict Style"],["win_condition","Win Condition"]];async function clGetAll(){const r=await fetch("/api/characters");if(!r.ok)throw new Error("clGetAll failed: "+r.status);return(await r.json()).characters||[]}async function clGet(id){const r=await fetch("/api/characters/"+encodeURIComponent(id));if(r.status!==404){if(!r.ok)throw new Error("clGet failed: "+r.status);return r.json()}}async function clPut(rec){rec!=null&&rec.color&&(rec.sheet=rec.sheet||{},rec.sheet.color=clNormalizeColor(rec.color,rec.name));const r=await fetch("/api/characters/"+encodeURIComponent(rec.id),{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(rec)});if(!r.ok)throw new Error("clPut failed: "+r.status);const saved=await r.json();return typeof window._rehSyncCastVoiceFromLibrary=="function"&&window._rehSyncCastVoiceFromLibrary(saved),saved}async function clDelete(id){const r=await fetch("/api/characters/"+encodeURIComponent(id),{method:"DELETE"});if(!r.ok)throw new Error("clDelete failed: "+r.status)}function clKey(book,name){return`${String(book||"").trim()}::${String(name||"").trim()}`.toLowerCase()}function clHslToHex(h,s,l){s/=100,l/=100;const k=n=>(n+h/30)%12,a=s*Math.min(l,1-l),f=n=>l-a*Math.max(-1,Math.min(k(n)-3,Math.min(9-k(n),1)));return"#"+[f(0),f(8),f(4)].map(x=>Math.round(255*x).toString(16).padStart(2,"0")).join("")}function clNameHue(name){return Math.abs((name||"?").split("").reduce((h,c)=>(h*31+c.charCodeAt(0))%360,0))}function clNormalizeColor(color,name){const c=String(color||"").trim();return/^#[0-9a-f]{6}$/i.test(c)?c:/^#[0-9a-f]{3}$/i.test(c)?"#"+c.slice(1).split("").map(ch=>ch+ch).join(""):clHslToHex(clNameHue(name),58,43)}const CL_IDENTITY_FIELDS=["name","aliases","first_name","last_name","full_name"],CL_ALIAS_MAX_TOKENS=12,CL_ALIAS_MAX_CHARS=500,CL_ALIAS_STOPWORDS=new Set(["die","der","das","den","dem","des","ein","eine","einer","er","sie","es","ich","du","wir","ihr","the","a","an","he","she","it","they","who"]);function clSplitIdentityTokens(v,opts={}){const raw=_clStr(v),parts=raw.split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i).map(x=>x.trim()).filter(Boolean).filter(x=>x.length<=80&&!/^needs?:/i.test(x)&&!/^complete$/i.test(x)).filter(x=>!CL_ALIAS_STOPWORDS.has(x.toLowerCase()));return opts.aliases&&(raw.length>CL_ALIAS_MAX_CHARS||parts.length>CL_ALIAS_MAX_TOKENS)?[]:parts.slice(0,opts.aliases?CL_ALIAS_MAX_TOKENS:void 0)}function clIdentityNames(recOrSheet){const s=(recOrSheet==null?void 0:recOrSheet.sheet)||recOrSheet||{},out=new Set,add=(v,opts={})=>clSplitIdentityTokens(v,opts).forEach(x=>out.add(x.toLowerCase()));return add((recOrSheet==null?void 0:recOrSheet.name)||s.name),CL_IDENTITY_FIELDS.filter(k=>k!=="name").forEach(k=>add(s[k],{aliases:k==="aliases"})),out}function clMergeAliases(existing,incoming){const names=new Map,add=v=>clSplitIdentityTokens(v,{aliases:!0}).forEach(x=>names.set(x.toLowerCase(),x));return add(existing.aliases),add(incoming.aliases),incoming.name&&incoming.name!==existing.name&&add(incoming.name),[...names.values()].filter(n=>n.toLowerCase()!==String(existing.name||"").toLowerCase()).join(", ")}function clSameIdentity(rec,book,sheet){if(String((rec==null?void 0:rec.book)||"").trim().toLowerCase()!==String(book||"").trim().toLowerCase())return!1;const a=clIdentityNames(rec),b=clIdentityNames(sheet);for(const n of b)if(a.has(n))return!0;return!1}function _clStr(v){return v==null?"":typeof v=="string"?v:Array.isArray(v)?v.filter(Boolean).join(", "):JSON.stringify(v)}function _clSanitize(sheet){const out={...sheet};return(typeof CS_SCALAR_FIELDS!="undefined"?CS_SCALAR_FIELDS:CL_EDIT_FIELDS.map(f=>f[0]).filter(k=>k!=="name")).forEach(f=>{out[f]!=null&&(out[f]=_clStr(out[f]))}),out}function clMergeSheet(existing,incoming){const e={...existing},inc=_clSanitize(incoming),scalars=typeof CS_SCALAR_FIELDS!="undefined"?CS_SCALAR_FIELDS:CL_EDIT_FIELDS.map(f=>f[0]).filter(k=>k!=="name"),oldAliases=e.aliases;return scalars.forEach(f=>{(inc[f]||"").length>(e[f]||"").length&&(e[f]=inc[f])}),e.aliases=clMergeAliases({...e,aliases:oldAliases},incoming),incoming.tier==="main"&&(e.tier="main"),incoming.moral_alignment_score!=null&&(e.moral_alignment_score=e.moral_alignment_score!=null?Math.round((e.moral_alignment_score+incoming.moral_alignment_score)/2):incoming.moral_alignment_score),incoming.arc_direction&&incoming.arc_direction!=="neutral"&&(e.arc_direction=incoming.arc_direction),incoming.gender&&!e.gender&&(e.gender=incoming.gender),incoming.line_count!=null&&(e.line_count=incoming.line_count),e.inventory=[...existing.inventory||[]],(incoming.inventory||[]).forEach(it=>{it&&!e.inventory.includes(it)&&e.inventory.length<3&&e.inventory.push(it)}),e.sources=[...existing.sources||[]],(incoming.sources||[]).forEach(src=>{src&&src.quote&&e.sources.length<12&&!e.sources.some(x=>x.quote===src.quote)&&e.sources.push(src)}),e}function clMergeTags(...parts){const set=new Set;return parts.forEach(p=>String(p||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>set.add(t))),[...set].join(", ")}async function clUpsert(book,sheet,knownId){var _a2;const name=(sheet.name||"").trim();if(!name)return null;const bk=(book||"").trim()||"Unsorted",aliasPrev=knownId?await clGet(knownId).catch(()=>null):(await clGetAll().catch(()=>[])).find(r=>clSameIdentity(r,bk,sheet)),id=(aliasPrev==null?void 0:aliasPrev.id)||knownId||clKey(bk,name),now=new Date,prev=aliasPrev||await clGet(id).catch(()=>null),merged=prev?clMergeSheet(prev.sheet||{},sheet):{..._clSanitize(sheet),name},canonicalName=(prev==null?void 0:prev.name)||name;merged.name=canonicalName;const tags=clMergeTags(prev==null?void 0:prev.tags,sheet.tags,bk),color=clNormalizeColor((prev==null?void 0:prev.color)||((_a2=prev==null?void 0:prev.sheet)==null?void 0:_a2.color)||sheet.color,canonicalName);merged.color=color;const rec={id,book:bk,name:canonicalName,tags,sheet:merged,color,analysis:(prev==null?void 0:prev.analysis)||null,voice:sheet.voice||(prev==null?void 0:prev.voice)||null,image:sheet.image||(prev==null?void 0:prev.image)||null,created:(prev==null?void 0:prev.created)||now,updated:now};return await clPut(rec),rec}async function clSetImage(id,dataUrl){const rec=await clGet(id).catch(()=>null);if(rec)return rec.image=dataUrl||null,rec.updated=new Date,await clPut(rec),rec}window.clSetImage=clSetImage;async function clGetAllByTagOrBook(title){const key=String(title||"").trim().toLowerCase();return key?(await clGetAll().catch(()=>[])).filter(r=>String(r.book||"").trim().toLowerCase()===key?!0:String(r.tags||"").split(",").some(t=>t.trim().toLowerCase()===key)):[]}async function clUpsertMany(book,sheets){let n=0;for(const s of sheets||[])await clUpsert(book,s)&&n++;return n}(async function(){try{if((await clGetAll()).length>0)return;const idbRecs=await _clIdbGetAll().catch(()=>[]);if(!idbRecs.length)return;const r=await fetch("/api/characters/migrate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(idbRecs)});if(r.ok){const d=await r.json();console.log(`[characters-library] migrated ${d.imported} records from IndexedDB \u2192 SQLite`)}}catch(e){console.warn("[characters-library] migration skipped:",e)}})();function _clIdbGetAll(){return new Promise((resolve,reject)=>{const req=indexedDB.open("character-library",1);req.onerror=()=>resolve([]),req.onsuccess=e=>{const db=e.target.result;if(!db.objectStoreNames.contains("characters")){db.close(),resolve([]);return}const all=db.transaction("characters","readonly").objectStore("characters").getAll();all.onsuccess=ev=>{db.close(),resolve(ev.target.result||[])},all.onerror=()=>{db.close(),resolve([])}}})}let _clRecords=[];async function clRender(){if(!document.getElementById("cl-grid"))return;const filterEl=document.getElementById("cl-book-filter"),searchEl=document.getElementById("cl-search");filterEl&&!filterEl.dataset.bound&&(filterEl.dataset.bound="1",filterEl.addEventListener("change",clApplyFilter)),searchEl&&!searchEl.dataset.bound&&(searchEl.dataset.bound="1",searchEl.addEventListener("input",clApplyFilter));try{_clRecords=await clGetAll()}catch{_clRecords=[]}const filter=document.getElementById("cl-book-filter"),prods=clAllProductions();if(filter){const cur=filter.value;filter.innerHTML=``+prods.map(b=>``).join(""),cur&&prods.includes(cur)&&(filter.value=cur)}clApplyFilter()}function clAllProductions(){const set=new Set;return _clRecords.forEach(r=>{r.book&&set.add(r.book),String(r.tags||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>set.add(t))}),[...set].sort((a,b)=>a.localeCompare(b))}function clApplyFilter(){var _a2,_b2;const grid=document.getElementById("cl-grid");if(!grid)return;const book=((_a2=document.getElementById("cl-book-filter"))==null?void 0:_a2.value)||"",q=(((_b2=document.getElementById("cl-search"))==null?void 0:_b2.value)||"").trim().toLowerCase();let recs=_clRecords.slice();if(book){const bk=book.toLowerCase();recs=recs.filter(r=>(r.book||"").toLowerCase()===bk||String(r.tags||"").split(",").some(t=>t.trim().toLowerCase()===bk))}if(q&&(recs=recs.filter(r=>{var _a3,_b3,_c2,_d2,_e2;return(r.name||"").toLowerCase().includes(q)||(((_a3=r.sheet)==null?void 0:_a3.aliases)||"").toLowerCase().includes(q)||(((_b3=r.sheet)==null?void 0:_b3.first_name)||"").toLowerCase().includes(q)||(((_c2=r.sheet)==null?void 0:_c2.last_name)||"").toLowerCase().includes(q)||(((_d2=r.sheet)==null?void 0:_d2.title)||"").toLowerCase().includes(q)||(((_e2=r.sheet)==null?void 0:_e2.archetype)||"").toLowerCase().includes(q)||(r.tags||"").toLowerCase().includes(q)||(r.book||"").toLowerCase().includes(q)})),!recs.length){grid.innerHTML=`

${_clRecords.length?"No characters match your filter.":"No characters yet."}

Run Character sheets from Read Aloud or the Script Rehearser to populate your library.

@@ -1705,13 +1737,12 @@ Respond with STRICT JSON only:
${escHtml(bk)} ${list.length}
${list.map(clCardHtml).join("")}
-
`}).join("")}function clCardHtml(rec){const tags=String(rec.tags||"").split(",").map(t=>t.trim()).filter(Boolean),chips=tags.length?`
${tags.map(t=>`${escHtml(t)}`).join("")}
`:"";return`
+
`}).join("")}function clCardHtml(rec){return`
- ${csCardHtml(rec.sheet)} - ${chips} + ${typeof csOverviewCardHtml=="function"?csOverviewCardHtml(rec):csCardHtml(rec.sheet)}
`}function clEdit(id){var _a2;const rec=_clRecords.find(r=>r.id===id);if(!rec)return;const s=rec.sheet||{},modal=document.createElement("div");modal.className="audiobook-overlay";const rows=CL_EDIT_FIELDS.map(([k,label])=>{const multiline=!["name","aliases","archetype"].includes(k),val=escHtml(String(s[k]||""));return``}).join("");modal.innerHTML=`
Edit \u2014 ${escHtml(rec.name)} @@ -1734,4 +1765,4 @@ Respond with STRICT JSON only: `)[0].slice(0,60)||"",backstory:data.description||data.char_persona||"",mannerisms:data.personality||"",arc_note:data.scenario||"",voice_pattern:data.mes_example||data.example_dialogue||data.first_mes||data.char_greeting||"",gender:ext.gender||stGuessGender((data.description||"")+" "+(data.personality||"")),note:data.creator_notes||"",tags,voice:ext.tts_voice||null,tier:"supporting",st_card:data}}function stFromRecord(rec){const sh=rec.sheet||{},prev=sh.st_card||{},descParts=[sh.physical,sh.clothing,sh.backstory,sh.alignment].map(x=>String(x||"").trim()).filter(Boolean),persona=[sh.archetype,sh.mannerisms,sh.motivation,sh.fears].map(x=>String(x||"").trim()).filter(Boolean).join(` `);return{spec:"chara_card_v2",spec_version:"2.0",data:{name:rec.name,description:prev.description||descParts.join(` -`),personality:persona||prev.personality||"",scenario:rec.book||prev.scenario||"",first_mes:prev.first_mes||"",mes_example:sh.voice_pattern||prev.mes_example||"",creator_notes:"Exported from TTS Voice Creator"+(rec.book?" \xB7 "+rec.book:""),system_prompt:prev.system_prompt||"",post_history_instructions:prev.post_history_instructions||"",tags:String(rec.tags||rec.book||"").split(",").map(t=>t.trim()).filter(Boolean),creator:prev.creator||"",character_version:prev.character_version||"1.0",extensions:Object.assign({},prev.extensions,{tts_voice:rec.voice||""})}}}function stDownloadJson(card,filename){const blob=new Blob([JSON.stringify(card,null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=filename,document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(a.href),1e3)}async function stImportCards(book,fileList){let n=0;for(const file of fileList)try{const data=await stParseFile(file),sheet=stToSheet(data);typeof clUpsert=="function"&&(await clUpsert(book||sheet.name,Object.assign({},sheet,{tags:book||""})),n++)}catch(e){typeof toast=="function"&&toast("\u201C"+(file.name||"card")+"\u201D: "+(e.message||e),"error")}return n}function stImportDialog(book,onDone){const inp=document.createElement("input");inp.type="file",inp.accept=".json,.png",inp.multiple=!0,inp.onchange=async()=>{if(!inp.files.length)return;const n=await stImportCards(book,inp.files);typeof toast=="function"&&toast(n?"Imported "+n+" character"+(n>1?"s":""):"Nothing imported",n?"success":"error"),typeof onDone=="function"&&onDone()},inp.click()}function stExportRecord(rec){const card=stFromRecord(rec),safe=String(rec.name||"character").replace(/[^\w\- ]+/g,"").trim().replace(/\s+/g,"_")||"character";stDownloadJson(card,safe+".card.json")}window.stParseFile=stParseFile,window.stToSheet=stToSheet,window.stFromRecord=stFromRecord,window.stImportCards=stImportCards,window.stImportDialog=stImportDialog,window.stExportRecord=stExportRecord;const LIB_READER_API="/api/reader/docs";function prodKey(title){return String(title||"").trim().toLowerCase()}window._libraryView=function(){try{return localStorage.getItem("ttsvc_library_view")||"books"}catch{return"books"}}(),window.navLibraryView=function(view){typeof navTo=="function"&&navTo("s-library"),window._libraryView=view;try{localStorage.setItem("ttsvc_library_view",view)}catch{}document.querySelectorAll("[data-library-view]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryView===view)}),document.querySelectorAll("[data-library-panel]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryPanel===view)}),view==="characters"&&typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("castlib"),libraryRender(view)},window.libraryRender=function(view){view=view||window._libraryView||"books",document.querySelectorAll("[data-library-view]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryView===view)}),document.querySelectorAll("[data-library-panel]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryPanel===view)}),view==="books"?libraryRenderBooks():view==="plays"?libraryRenderPlays():view==="characters"&&typeof window.libraryRenderCharacters=="function"&&window.libraryRenderCharacters()};function _libSkeleton(n){return Array.from({length:n},()=>'
').join("")}async function libraryRenderBooks(){const list=document.getElementById("lib-books-list");if(!list)return;list.innerHTML=_libSkeleton(4);let all=[];try{const r=await fetch(LIB_READER_API);r.ok&&(all=(await r.json()).docs||[])}catch{all=[]}if(!all.length){list.innerHTML='

No books yet.

Open Read Aloud, import a PDF or text, and save it to your library.

';return}all.sort(function(a,b){return new Date(b.updated||0)-new Date(a.updated||0)}),list.innerHTML=all.map(function(rec){const total=rec.sentenceCount||0,synthPct=total?Math.round((rec.synthCount||0)/total*100):0,readPct=total?Math.round((rec.idx||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"",cov=libBookCover(rec.title||"Untitled"),coverUrl=LIB_READER_API+"/"+rec.id+"/cover?t="+new Date(rec.updated||Date.now()).getTime(),bg=rec.hasCover?`style="background-image:linear-gradient(to bottom,rgba(0,0,0,.3),rgba(0,0,0,.8)),url('`+coverUrl+`');background-size:cover;background-position:center;color:#fff"`:'style="--bk1:'+cov.c1+";--bk2:"+cov.c2+'"';return'
'+escHtml(rec.title||"Untitled")+'
'+total+" sentences"+(rec.pageCount?" \xB7 "+rec.pageCount+" pg":"")+'
'+readPct+"% read \xB7 "+date+"
"}).join(""),list.querySelectorAll(".lib-book").forEach(function(el){const id=el.dataset.id,title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||(window._readerStartView="main",typeof navTo=="function"&&navTo("s-reader"),typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(id))});const reh=el.querySelector(".lib-act-rehearse");reh&&reh.addEventListener("click",function(e){e.stopPropagation(),productionOpenInRehearser(title)});const del=el.querySelector(".lib-act-del-book");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete book?","Audio files will be removed.",async function(){try{if(!(await fetch(LIB_READER_API+"/"+id,{method:"DELETE"})).ok)throw new Error("delete failed");toast("Book deleted","success"),libraryRenderBooks()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function libraryRenderPlays(){const list=document.getElementById("lib-plays-list");if(!list)return;list.innerHTML=_libSkeleton(3);let all=[];try{all=typeof rehDbGetAll=="function"?await rehDbGetAll():[]}catch{all=[]}if(!all.length){list.innerHTML='

No theater plays yet.

Open Script Rehearsal \u2192 Import / Export to add a script, or cast a book as an audiobook.

';return}all.sort(function(a,b){return new Date(b.updated||0)-new Date(a.updated||0)}),list.innerHTML=all.map(function(rec){const speakers=Object.keys(rec.cast||{}),total=typeof parseScript=="function"?parseScript(rec.script||"").filter(function(l){return l.type==="dialog"}).length:0,pct=total?Math.round((rec.lineIndex||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"\u2014",cov=libBookCover(rec.title||"Untitled"),avatars=speakers.slice(0,5).map(function(sp){return''+(sp[0]||"?").toUpperCase()+""}).join("");return'
'+escHtml(rec.title||"Untitled")+'
'+avatars+"
"+total+" lines \xB7 "+speakers.length+' cast
'+pct+"% \xB7 "+date+"
"}).join(""),list.querySelectorAll(".lib-play").forEach(function(el){const id=parseInt(el.dataset.id,10),title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||openPlayInRehearser(id)});const ra=el.querySelector(".lib-act-readaloud");ra&&ra.addEventListener("click",function(e){e.stopPropagation(),productionOpenInReader(title)});const del=el.querySelector(".lib-act-del-play");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete rehearsal?","This cannot be undone.",async function(){try{typeof rehDbDelete=="function"&&await rehDbDelete(id),toast("Rehearsal deleted","success"),libraryRenderPlays()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function openPlayInRehearser(id){try{const rec=typeof rehDbGetById=="function"?await rehDbGetById(id):null;rec&&typeof loadRecord=="function"?loadRecord(rec):toast("Rehearsal not found","error")}catch{toast("Could not open rehearsal","error")}}async function productionOpenInRehearser(title){const key=prodKey(title);try{const match=(typeof rehDbGetAll=="function"?await rehDbGetAll():[]).find(function(p){return prodKey(p.title)===key});if(match){openPlayInRehearser(match.id);return}}catch{}let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&book.kind!=="pdf")try{const sr=await fetch(LIB_READER_API+"/"+book.id+"/source"),text=sr.ok?await sr.text():"";if(text&&typeof audiobookOpenInRehearser=="function"){audiobookOpenInRehearser(text,title,[]);return}}catch{}if(book&&book.kind==="pdf"){typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(book.id),toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal',"info");return}toast("No source to rehearse for this title yet","error")}async function productionOpenInReader(title){const key=prodKey(title);let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&typeof readerOpenLibraryDoc=="function"){readerOpenLibraryDoc(book.id);return}typeof navTo=="function"&&navTo("s-reader"),toast("No audiobook for this title yet \u2014 import its source in Read Aloud","info")}async function castForProduction(title){const out={};if(typeof clGetAllByTagOrBook!="function")return out;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{recs=[]}return recs.forEach(function(r){const name=(r.name||"").trim();if(!name)return;const voice=r.voice&&r.voice.id?r.voice.id:typeof r.voice=="string"?r.voice:"";out[name.toLowerCase()]={name,voice:voice||"",gender:r.sheet&&r.sheet.gender||"",soul:r.sheet&&(r.sheet.voice_pattern||r.sheet.motivation)||"",tags:r.tags||""}}),out}window.castForProduction=castForProduction;async function castWriteBack(title,castMap){if(!title||!castMap||typeof clGetAllByTagOrBook!="function"||typeof clPut!="function")return;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{return}if(!recs.length)return;const byName={};recs.forEach(function(r){byName[(r.name||"").trim().toLowerCase()]=r});let n=0;for(const sp of Object.keys(castMap)){if(String(sp).includes("NARRATOR"))continue;const voice=(castMap[sp]||{}).voice;if(!voice||voice==="me")continue;const rec=byName[String(sp).trim().toLowerCase()];if(!(!rec||(rec.voice&&rec.voice.id?rec.voice.id:typeof rec.voice=="string"?rec.voice:"")===voice)){rec.voice={id:voice},rec.updated=new Date;try{await clPut(rec),n++}catch{}}}return n}window.castWriteBack=castWriteBack;function libBookCover(title){let h=0;const s=String(title||"Untitled");for(let i=0;i'+heading+'
'+sub+'
',o.addEventListener("click",function(e){e.stopPropagation()}),o.querySelector("[data-lib-cancel]").addEventListener("click",function(e){e.stopPropagation(),o.remove()}),o.querySelector("[data-lib-ok]").addEventListener("click",async function(e){e.stopPropagation(),o.innerHTML='',await onConfirm()}),cardEl.appendChild(o)}window.libraryRenderBooks=libraryRenderBooks,window.libraryRenderPlays=libraryRenderPlays,window.productionOpenInRehearser=productionOpenInRehearser,window.productionOpenInReader=productionOpenInReader,window.prodKey=prodKey;async function libraryRenderCharacters(){const container=document.getElementById("lib-chars-list");if(!container)return;container.innerHTML='
Loading characters\u2026
';let all=[];try{all=typeof clGetAll=="function"?await clGetAll():[]}catch{all=[]}const byId=new Map(all.map(function(rec){return[rec.id,rec]}));if(!all.length){container.innerHTML='

No characters yet.

Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets \u2014 or import an existing cast from SillyTavern.

',container.querySelector("#lib-chars-import").addEventListener("click",function(){typeof stImportDialog=="function"&&stImportDialog("",function(){libraryRenderCharacters()})});return}const byBook={};all.forEach(function(rec){const bk=rec.book||"Unsorted";byBook[bk]||(byBook[bk]=[]),byBook[bk].push(rec)}),container.innerHTML="";const viewMode=localStorage.getItem("ttsvc_libchars_view")==="table"?"table":"cards",SORT_OPTIONS=[["tier","Rolle (Haupt zuerst)"],["alpha","Alphabet"],["lines","Anzahl Zeilen"],["gender","Geschlecht"],["voice","Stimme zugewiesen"]],sortMode=SORT_OPTIONS.some(function(o){return o[0]===localStorage.getItem("ttsvc_libchars_sort")})?localStorage.getItem("ttsvc_libchars_sort"):"tier",bar=document.createElement("div");bar.className="lib-chars-toolbar",bar.innerHTML='
',bar.querySelector("#lib-chars-import").addEventListener("click",function(){typeof stImportDialog=="function"&&stImportDialog("",function(){libraryRenderCharacters()})}),bar.querySelector("#lib-chars-sort-sel").addEventListener("change",function(){localStorage.setItem("ttsvc_libchars_sort",this.value),libraryRenderCharacters()}),bar.querySelectorAll(".lib-chars-view-toggle button").forEach(function(btn){btn.addEventListener("click",function(){localStorage.setItem("ttsvc_libchars_view",btn.dataset.view),libraryRenderCharacters()})}),container.appendChild(bar);const _charSortCmp={tier:function(a,b){var _a2,_b2,_c2,_d2;const tierOrder={main:0,supporting:1,minor:2},ta=(_b2=tierOrder[String(((_a2=a.sheet)==null?void 0:_a2.tier)||"minor").toLowerCase()])!=null?_b2:2,tb=(_d2=tierOrder[String(((_c2=b.sheet)==null?void 0:_c2.tier)||"minor").toLowerCase()])!=null?_d2:2;return ta-tb||(a.name||"").localeCompare(b.name||"")},alpha:function(a,b){return(a.name||"").localeCompare(b.name||"")},lines:function(a,b){var _a2,_b2;return(((_a2=b.sheet)==null?void 0:_a2.line_count)||0)-(((_b2=a.sheet)==null?void 0:_b2.line_count)||0)||(a.name||"").localeCompare(b.name||"")},gender:function(a,b){var _a2,_b2;const ga=String(((_a2=a.sheet)==null?void 0:_a2.gender)||"zzz"),gb=String(((_b2=b.sheet)==null?void 0:_b2.gender)||"zzz");return ga.localeCompare(gb)||(a.name||"").localeCompare(b.name||"")},voice:function(a,b){return(b.voice?1:0)-(a.voice?1:0)||(a.name||"").localeCompare(b.name||"")}},productions=document.createDocumentFragment();Object.keys(byBook).sort().forEach(function(book){const chars=byBook[book].sort(_charSortCmp[sortMode]||_charSortCmp.tier),cov=libBookCover(book),prod=document.createElement("div");prod.className="lib-chars-production",prod.innerHTML='
'+escHtml(book)+'
'+(viewMode==="table"?_charsTableHtml(chars):'
'+chars.map(function(rec){return _charCardHtml(rec,chars)}).join("")+"
"),prod.querySelector(".lib-chars-casting-btn").addEventListener("click",function(){typeof navTo=="function"&&navTo("s-reader")}),prod.querySelector(".lib-chars-cast-btn").addEventListener("click",function(){typeof productionOpenInReader=="function"&&productionOpenInReader(book),toast("Open the book in Read Aloud then click Cast Characters","info")}),prod.querySelector(".lib-chars-reh-btn").addEventListener("click",function(){typeof productionOpenInRehearser=="function"&&productionOpenInRehearser(book)}),prod.querySelector(".lib-chars-read-btn").addEventListener("click",function(){typeof productionOpenInReader=="function"&&productionOpenInReader(book)}),prod.querySelector(".lib-chars-imp-btn").addEventListener("click",function(){typeof stImportDialog=="function"&&stImportDialog(book,function(){libraryRenderCharacters()})});const bulkBtn=prod.querySelector(".lib-chars-bulk-voice-btn"),bulkCount=prod.querySelector(".lib-chars-bulk-count"),refreshBulkBtn=function(){const n=prod.querySelectorAll(".lib-char-select-cb:checked").length;bulkCount.textContent=n,bulkBtn.disabled=n===0};prod.querySelectorAll(".lib-char-select-cb").forEach(function(cb){cb.addEventListener("change",refreshBulkBtn)}),bulkBtn.addEventListener("click",async function(){const ids=[...prod.querySelectorAll(".lib-char-select-cb:checked")].map(function(cb){return cb.dataset.charId});if(!ids.length)return;bulkBtn.disabled=!0;const origLabel=bulkBtn.innerHTML;let done=0;for(const id of ids){const rec=byId.get(id);if(rec){bulkBtn.innerHTML=' Assigning '+ ++done+" / "+ids.length+"\u2026";try{await _autoAssignVoice(rec)}catch{}}}bulkBtn.innerHTML=origLabel,toast(`Voice assigned to ${done} character${done!==1?"s":""}`,"success"),libraryRenderCharacters()}),prod.querySelectorAll(".lib-char-card").forEach(function(card){var _a2,_b2,_c2,_d2,_e2,_f2;const charId=card.dataset.charId,rec=byId.get(charId);rec&&(card.addEventListener("click",function(e){e.target.closest("button, .lib-char-avatar, .lib-voice-picker-popup")||_charDetailPage(rec,chars)}),(_a2=card.querySelector(".lib-char-avatar"))==null||_a2.addEventListener("click",function(e){e.stopPropagation();const inp=document.createElement("input");inp.type="file",inp.accept="image/*",inp.onchange=async function(){const file=inp.files[0];if(!file)return;const fr=new FileReader;fr.onload=async function(ev){typeof clSetImage=="function"&&await clSetImage(rec.id,ev.target.result),toast("Profile picture saved","success"),libraryRenderCharacters()},fr.readAsDataURL(file)},inp.click()}),(_b2=card.querySelector(".lib-char-pick-voice"))==null||_b2.addEventListener("click",function(e){e.stopPropagation(),_openVoicePicker(card,rec,function(){libraryRenderCharacters()})}),(_c2=card.querySelector(".lib-char-auto-voice"))==null||_c2.addEventListener("click",async function(e){e.stopPropagation(),await _autoAssignVoice(rec),libraryRenderCharacters()}),(_d2=card.querySelector(".lib-char-export"))==null||_d2.addEventListener("click",function(e){e.stopPropagation(),typeof stExportRecord=="function"&&stExportRecord(rec)}),(_e2=card.querySelector(".lib-char-online-voice"))==null||_e2.addEventListener("click",function(e){e.stopPropagation(),_charSearchOnline(rec)}),(_f2=card.querySelector(".lib-char-gen-voice"))==null||_f2.addEventListener("click",function(e){e.stopPropagation(),_charDesignVoice(rec)}))}),productions.appendChild(prod)}),container.appendChild(productions)}function _charHue(name){return Math.abs((name||"?").split("").reduce(function(h,c){return(h*31+c.charCodeAt(0))%360},0))}function _charAlignHtml(sh){const score=sh.moral_alignment_score;if(score==null)return"";const pct=Math.max(0,Math.min(100,score)),arc=sh.arc_direction||"neutral",arrowMap={"good-to-bad":{ch:"\u2198",color:"#ff7043",tip:"Arc: Descends toward evil"},"bad-to-good":{ch:"\u2197",color:"#66bb6a",tip:"Arc: Redeems toward good"},complex:{ch:"\u2195",color:"#ab47bc",tip:"Arc: Complex / unpredictable"},"stable-good":{ch:"\u2192",color:"#66bb6a",tip:"Arc: Stable good"},"stable-bad":{ch:"\u2192",color:"#888",tip:"Arc: Stable evil"},neutral:{ch:"\u2192",color:"#aaa",tip:"Arc: Neutral"}},a=arrowMap[arc]||arrowMap.neutral;return'
\u25CF
\u25CF'+a.ch+"
"}function _libStr(v){return v==null?"":typeof v=="string"?v:Array.isArray(v)?v.filter(Boolean).join(", "):JSON.stringify(v)}function _charRelsHtml(rec,allChars){var _a2;if(!allChars||allChars.length<2)return"";const relText=_libStr((_a2=rec.sheet)==null?void 0:_a2.relationships).toLowerCase();if(!relText)return"";const hits=allChars.filter(function(c){return c.id!==rec.id&&(c.name||"").length>1}).map(function(c){const re=new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"gi");return{c,n:(relText.match(re)||[]).length}}).filter(function(x){return x.n>0}).sort(function(a,b){return b.n-a.n}).slice(0,5);return hits.length?'
'+hits.map(function(x){return''+escHtml((x.c.name||"?")[0].toUpperCase())+""}).join("")+"
":""}function _charCardHtml(rec,allChars){const sh=rec.sheet||{},hue=_charHue(rec.name),hue2=(hue+40)%360,voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",voiceLabel=voiceId?escHtml(voiceId):'Keine Stimme',tier=String(sh.tier||"").toLowerCase(),tierBadge=tier==="main"?'Haupt':tier==="supporting"?'Neben':"",gender=String(sh.gender||"").toLowerCase(),genderIcon=gender.startsWith("f")?"mdi-gender-female":gender.startsWith("m")?"mdi-gender-male":"mdi-gender-non-binary",snippet=_libStr(sh.mannerisms||sh.voice_pattern||sh.motivation||sh.backstory||"").slice(0,140),tagList=String(rec.tags||"").split(",").map(function(t){return t.trim()}).filter(Boolean),tagsHtml=tagList.length?'
'+tagList.map(function(t){return''+escHtml(t)+""}).join("")+"
":"",avatarInner=rec.image?''+escHtml(rec.name)+'':escHtml((rec.name||"?")[0].toUpperCase());return'
'+avatarInner+'
'+tierBadge+escHtml(rec.name)+'
'+(_libStr(sh.title)?'
'+escHtml(_libStr(sh.title))+"
":"")+(_libStr(sh.aliases)?'
aka '+escHtml(_libStr(sh.aliases))+"
":"")+(sh.archetype?'
'+escHtml(_libStr(sh.archetype))+"
":"")+(snippet?'
'+escHtml(snippet)+"
":"")+tagsHtml+_charAlignHtml(sh)+_charRelsHtml(rec,allChars)+'
'+voiceLabel+'
'}function _charsTableHtml(chars){return'
'+chars.map(function(rec){const sh=rec.sheet||{},hue=_charHue(rec.name),voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",voiceLang=rec.voice&&typeof rec.voice=="object"&&rec.voice.language||"",tier=String(sh.tier||"").toLowerCase(),tierBadge=tier==="main"?'Haupt':tier==="supporting"?'Neben':"",gender=String(sh.gender||"").toLowerCase(),genderIcon=gender.startsWith("f")?"mdi-gender-female":gender.startsWith("m")?"mdi-gender-male":gender?"mdi-gender-non-binary":"",score=sh.moral_alignment_score,pct=score!=null?Math.max(0,Math.min(100,score)):null,tagList=String(rec.tags||"").split(",").map(function(t){return t.trim()}).filter(Boolean),avatarInner=rec.image?''+escHtml(rec.name)+'':escHtml((rec.name||"?")[0].toUpperCase()),hasPrompt=function(key){return!!(sh[key]&&String(sh[key]).trim())},promptCell=function(key,title){return''};return''}).join("")+"
Name\u26A5ZeilenSpracheGut/B\xF6seStimmeTagsSTTTSBild
'+avatarInner+'
'+tierBadge+escHtml(rec.name)+""+(genderIcon?'':'\u2014')+""+(sh.line_count!=null?sh.line_count:'\u2014')+""+(voiceLang?escHtml(voiceLang):'\u2014')+""+(pct!=null?'
':'\u2014')+'
'+(voiceId?escHtml(voiceId):'Keine Stimme')+'
'+tagList.map(function(t){return''+escHtml(t)+""}).join("")+"
"+promptCell("silly_tavern_prompt","SillyTavern")+""+promptCell("voice_design_prompt","TTS Voice")+""+promptCell("image_prompt","Bild")+'
"}function _lcdSourcesHtml(sources){const list=Array.isArray(sources)?sources.filter(function(s){return s&&(s.quote||s.page!=null)}):[];return list.length?'
'+list.map(function(s){const page=s.page!=null?"Seite "+s.page:"",hint=_libStr(s.line_hint||s.hint||"");return'
'+(page||hint?'
'+escHtml([page,hint].filter(Boolean).join(" \xB7 "))+"
":"")+(s.quote?'
\u201E'+escHtml(_libStr(s.quote))+'"
':"")+"
"}).join("")+"
":""}function _lcdField(label,value,multiline){const v=_libStr(value);return v?'
'+label+'
'+escHtml(v)+"
":""}function _lcdSection(icon,label,fields){const body=fields.join("");return body?'
"+body+"
":""}function _lcdSectionFull(icon,label,fields){const body=fields.join("");return body?'
"+body+"
":""}function _lcdPromptBox(label,value,sheetKey){const has=!!(value&&String(value).trim());return'
'+escHtml(label)+(has?"":' \u2014 not generated yet')+'
'+escHtml(value||"")+'
"}function _lcdFieldEdit(label,value,sheetKey,sourceIdxs){const v=_libStr(value),links=(sourceIdxs||[]).map(function(idx){return''+(sourceIdxs.indexOf(idx)+1)+""}).join("");return'
'+(label||links?'
'+escHtml(label)+(links?' '+links+"":"")+"
":"")+'
'+escHtml(v)+"
"}function _jumpToReaderPage(pageNum){typeof navTo=="function"&&navTo("s-reader"),setTimeout(function(){var _a2,_b2;const pages=(_a2=window.readerState)==null?void 0:_a2.pages;if(pages&&pages.length>=pageNum){const pg=pages[pageNum-1];if(pg!=null&&pg.pageDiv){pg.pageDiv.scrollIntoView({behavior:"smooth",block:"start"});return}}const sentences=(_b2=window.readerState)==null?void 0:_b2.sentences;if(sentences&&sentences.length){const target0=pageNum-1,idx=sentences.findIndex(function(s){return(s.words||[]).some(function(w){var _a3,_b3;return((_b3=(_a3=w.page)!=null?_a3:w.para)!=null?_b3:0)>=target0})});if(idx>=0&&typeof readerJumpTo=="function"){readerJumpTo(idx);return}}toast('\xD6ffne das Buch in \u201EVorlesen" und klicke nochmal auf die Quelle',"info")},300)}async function _charDetailPage(rec,allChars){var _a2,_b2,_c2,_d2;const container=document.getElementById("lib-chars-list");if(!container)return;window._libDetailRec=rec;const sh=rec.sheet||{},hue=_charHue(rec.name),hue2=(hue+40)%360,tier=String(sh.tier||"").toLowerCase(),tierLabel=tier==="main"?"Hauptcharakter":tier==="supporting"?"Nebencharakter":tier==="minor"?"Nebenfigur":"",gender=_libStr(sh.gender),genderIcon=gender.toLowerCase().startsWith("f")?"mdi-gender-female":gender.toLowerCase().startsWith("m")?"mdi-gender-male":"mdi-gender-non-binary",voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",score=sh.moral_alignment_score,pct=score!=null?Math.max(0,Math.min(100,score)):null,arcMap={"good-to-bad":{ch:"\u2198",label:"Entwicklung zum B\xF6sen",color:"#ff7043"},"bad-to-good":{ch:"\u2197",label:"Wandel zum Guten",color:"#66bb6a"},complex:{ch:"\u2195",label:"Komplex / unvorhersehbar",color:"#ab47bc"},"stable-good":{ch:"\u2192",label:"Stabil gut",color:"#66bb6a"},"stable-bad":{ch:"\u2192",label:"Stabil b\xF6se",color:"#888"},neutral:{ch:"\u2192",label:"Neutral / stabil",color:"#aaa"}},arcInfo=arcMap[sh.arc_direction||"neutral"]||arcMap.neutral,avatarHtml=rec.image?'
'+escHtml(rec.name)+'
':'
'+escHtml((rec.name||"?")[0].toUpperCase())+"
",alignHtml=pct!=null?'
B\xF6seGut'+pct+'/100
'+arcInfo.ch+" "+arcInfo.label+(pct>=70?" \xB7 Rechtschaffen ("+pct+"/100)":pct<=30?" \xB7 B\xF6se ("+pct+"/100)":" \xB7 Moralisch ambivalent ("+pct+"/100)")+"
"+(_libStr(sh.alignment)?'
'+escHtml(_libStr(sh.alignment))+"
":"")+"
":"",promptsHtml='
'+_lcdPromptBox("Voice Design Prompt",sh.voice_design_prompt,"voice_design_prompt")+_lcdPromptBox("Character Image Prompt",sh.image_prompt,"image_prompt")+_lcdPromptBox("SillyTavern Character Prompt",sh.silly_tavern_prompt,"silly_tavern_prompt")+_lcdPromptBox("Concept Art Prompt",sh.concept_art_prompt,"concept_art_prompt")+"
",relText=_libStr(sh.relationships).toLowerCase(),relHits=(allChars||[]).filter(function(c){return c.id!==rec.id&&(c.name||"").length>1}).map(function(c){const re=new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"gi");return{c,n:(relText.match(re)||[]).length}}).filter(function(x){return x.n>0}).sort(function(a,b){return b.n-a.n}).slice(0,8),relDotsHtml=relHits.length?'
'+relHits.map(function(x){return''+escHtml((x.c.name||"?")[0].toUpperCase())+""}).join("")+"
":"",sourcesList=Array.isArray(sh.sources)?sh.sources.filter(function(s){return s&&(s.quote||s.page!=null)}):[],sourcesByField={};sourcesList.forEach(function(s,idx){const key=_libStr(s.line_hint||s.hint||"").trim().toLowerCase();key&&(sourcesByField[key]=sourcesByField[key]||[]).push(idx)});const sourcesHtml=sourcesList.length?'
'+sourcesList.map(function(s,idx){const page=s.page!=null?"Seite "+s.page:"",hint=_libStr(s.line_hint||s.hint||"");return'
'+(page||hint?'
'+escHtml([page,hint].filter(Boolean).join(" \xB7 "))+"
":"")+(s.quote?'
\u201E'+escHtml(_libStr(s.quote))+'"
':"")+"
"}).join("")+"
":"",sidebarHtml=(allChars||[]).slice().sort(function(a,b){var _a3,_b3,_c3,_d3;return(((_b3=(_a3=b.sheet)==null?void 0:_a3.sources)==null?void 0:_b3.length)||0)-(((_d3=(_c3=a.sheet)==null?void 0:_c3.sources)==null?void 0:_d3.length)||0)}).map(function(c){var _a3;const h=_charHue(c.name),count=(((_a3=c.sheet)==null?void 0:_a3.sources)||[]).length;return'
'+escHtml((c.name||"?")[0].toUpperCase())+''+escHtml(c.name)+""+(count?''+count+"":"")+"
"}).join("");container.innerHTML="";const pg=document.createElement("div");pg.className="lib-char-page",pg.innerHTML='
'+avatarHtml+'
'+escHtml(rec.name)+"
"+(_libStr(sh.full_name)&&_libStr(sh.full_name).toLowerCase()!==String(rec.name||"").toLowerCase()?'
'+escHtml(_libStr(sh.full_name))+"
":"")+(_libStr(sh.title)?'
'+escHtml(_libStr(sh.title))+"
":"")+'
'+escHtml(_libStr(sh.aliases))+'
'+escHtml(_libStr(sh.archetype))+'
'+(tierLabel?''+tierLabel+"":"")+(gender?' '+escHtml(gender)+"":"")+'
'+(voiceId?escHtml(voiceId):'Noch keine Stimme zugewiesen')+'
'+alignHtml+'
'+_lcdSection("mdi-card-account-details-outline","Identit\xE4t",[_lcdFieldEdit("Voller Name",sh.full_name,"full_name",sourcesByField.full_name),_lcdFieldEdit("Vorname",sh.first_name,"first_name",sourcesByField.first_name),_lcdFieldEdit("Nachname",sh.last_name,"last_name",sourcesByField.last_name),_lcdFieldEdit("Geschlecht",sh.gender,"gender",sourcesByField.gender),_lcdFieldEdit("Titel",sh.title,"title",sourcesByField.title),_lcdFieldEdit("Beruf / Rolle",sh.profession,"profession",sourcesByField.profession),_lcdFieldEdit("Auch bekannt als",sh.aliases,"aliases",sourcesByField.aliases)])+_lcdSection("mdi-account-outline","Erscheinung",[_lcdFieldEdit("K\xF6rperlich",sh.physical,"physical",sourcesByField.physical),_lcdFieldEdit("Kleidung & Aussehen",sh.clothing,"clothing",sourcesByField.clothing)])+_lcdSection("mdi-drama-masks","Pers\xF6nlichkeit",[_lcdFieldEdit("Eigenheiten & Verhalten",sh.mannerisms,"mannerisms",sourcesByField.mannerisms),_lcdFieldEdit("Stimme & Sprache",sh.voice_pattern,"voice_pattern",sourcesByField.voice_pattern)])+_lcdSection("mdi-book-open-outline","Geschichte",[_lcdFieldEdit("Hintergrund & Herkunft",sh.backstory,"backstory",sourcesByField.backstory),_lcdFieldEdit("Motivation",sh.motivation,"motivation",sourcesByField.motivation),_lcdFieldEdit("\xC4ngste",sh.fears,"fears",sourcesByField.fears)])+_lcdSection("mdi-sword","F\xE4higkeiten",[_lcdFieldEdit("Fertigkeiten",sh.skills,"skills",sourcesByField.skills),_lcdFieldEdit("Besondere F\xE4higkeiten",sh.capabilities,"capabilities",sourcesByField.capabilities),_lcdFieldEdit("St\xE4rkstes Attribut",sh.attribute_high,"attribute_high"),_lcdFieldEdit("Schw\xE4chstes Attribut",sh.attribute_low,"attribute_low")])+_lcdSectionFull("mdi-account-group-outline","Beziehungen",[_lcdFieldEdit("",sh.relationships,"relationships",sourcesByField.relationships),relDotsHtml])+_lcdSection("mdi-shield-sword-outline","Konflikt & Strategie",[_lcdFieldEdit("Konfliktstil",sh.conflict_style,"conflict_style",sourcesByField.conflict_style),_lcdFieldEdit("Siegbedingung",sh.win_condition,"win_condition",sourcesByField.win_condition)])+_lcdSection("mdi-eye-outline","Geheimnisse & Bogen",[_lcdFieldEdit("Dunkles Geheimnis / fataler Fehler",sh.secret,"secret"),_lcdFieldEdit("Charakterentwicklung",sh.arc_note,"arc_note")])+promptsHtml+"
"+sourcesHtml+(rec.analysis?'
'+escHtml(String(rec.analysis))+"
":"")+'
Charaktere \xB7 '+escHtml(rec.book||"")+"
"+sidebarHtml+"
",container.appendChild(pg),pg.querySelector(".lib-cpg-back").addEventListener("click",function(){libraryRenderCharacters()}),pg.querySelector(".lcd-avatar-upload").addEventListener("click",function(){const inp=document.createElement("input");inp.type="file",inp.accept="image/*",inp.onchange=async function(){const file=inp.files[0];if(!file)return;const fr=new FileReader;fr.onload=async function(ev){typeof clSetImage=="function"&&await clSetImage(rec.id,ev.target.result),toast("Profilbild gespeichert","success"),rec.image=ev.target.result;const av=pg.querySelector(".lcd-avatar-upload");av&&(av.innerHTML=''+escHtml(rec.name)+'')},fr.readAsDataURL(file)},inp.click()}),pg.querySelectorAll(".lib-cpg-sidebar-item").forEach(function(item){item.addEventListener("click",async function(){const target=(allChars||[]).find(function(c){return c.id===item.dataset.charId});target&&_charDetailPage(target,allChars)})}),pg.querySelectorAll(".lcd-source-clickable").forEach(function(item){item.addEventListener("click",function(){const n=parseInt(item.dataset.page,10);isNaN(n)||_jumpToReaderPage(n)})}),(_a2=pg.querySelector(".lcd-pick-voice"))==null||_a2.addEventListener("click",async function(){_openVoicePicker(pg.querySelector(".lcd-voice-top"),rec,async function(){const all=await clGetAll().catch(()=>allChars),up=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(up,all.filter(function(r){return r.book===rec.book}))})}),(_b2=pg.querySelector(".lcd-auto-voice"))==null||_b2.addEventListener("click",async function(){await _autoAssignVoice(rec);const all=await clGetAll().catch(()=>allChars),up=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(up,all.filter(function(r){return r.book===rec.book}))}),(_c2=pg.querySelector(".lcd-online-voice"))==null||_c2.addEventListener("click",function(){_charSearchOnline(rec)}),(_d2=pg.querySelector(".lcd-gen-voice"))==null||_d2.addEventListener("click",function(){_charDesignVoice(rec)}),pg.querySelectorAll(".lcd-prompt-copy").forEach(function(btn){btn.addEventListener("click",async function(){var _a3;const box=btn.closest(".lcd-prompt-body"),text=((_a3=box==null?void 0:box.querySelector(".lcd-prompt-text"))==null?void 0:_a3.textContent.trim())||"";if(!text){toast("Nothing to copy yet \u2014 click Generate first","error");return}typeof copyText=="function"&&await copyText(text),toast("Prompt copied","success")})}),pg.querySelectorAll(".lcd-gen-prompt").forEach(function(btn){btn.addEventListener("click",async function(e){e.preventDefault();const key=btn.dataset.sheetKey,orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026';try{const sh2=rec.sheet||{},sample=[sh2.physical,sh2.backstory,sh2.motivation].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},r=await fetch("/api/character-generate-prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:rec.name,book:rec.book||"",sheet:sh2,language,llm_url:target.url,model:target.model,fields:[key]})});if(!r.ok)throw new Error((await r.json().catch(function(){return{}})).detail||r.statusText);const d=await r.json();if(!d[key])throw new Error("Empty response \u2014 try again");rec.sheet||(rec.sheet={}),rec.sheet[key]=d[key],rec.updated=new Date,typeof clPut=="function"&&await clPut(rec),toast("Prompt generated","success"),_charDetailPage(rec,allChars)}catch(err){toast("Prompt generation failed: "+(err.message||err),"error"),btn.disabled=!1,btn.innerHTML=orig}})});const slider=pg.querySelector(".lcd-align-slider"),sliderVal=pg.querySelector(".lcd-align-slider-val"),arcEl=pg.querySelector(".lcd-align-arc");slider&&slider.addEventListener("input",async function(){const val=parseInt(slider.value,10);sliderVal&&(sliderVal.textContent=val+"/100"),arcEl&&(arcEl.textContent=arcInfo.ch+" "+arcInfo.label+(val>=70?" \xB7 Rechtschaffen ("+val+"/100)":val<=30?" \xB7 B\xF6se ("+val+"/100)":" \xB7 Moralisch ambivalent ("+val+"/100)")),arcEl&&(arcEl.style.color=arcInfo.color),rec.sheet.moral_alignment_score=val,rec.updated=new Date,typeof clPut=="function"&&await clPut(rec)});let _saveTimer=null;function _schedSave(key,value,isRecKey){clearTimeout(_saveTimer),_saveTimer=setTimeout(async function(){isRecKey?rec[key]=value:(rec.sheet||(rec.sheet={}),rec.sheet[key]=value),rec.updated=new Date,typeof clPut=="function"&&await clPut(rec)},900)}pg.querySelectorAll("[contenteditable][data-sheet-key]").forEach(function(el){el.addEventListener("input",function(){_schedSave(el.dataset.sheetKey,el.textContent.trim(),!1)})}),pg.querySelectorAll("[contenteditable][data-rec-key]").forEach(function(el){el.addEventListener("input",function(){_schedSave(el.dataset.recKey,el.textContent.trim(),!0)})})}window._charDetailPage=_charDetailPage;function _charDetailModal(rec,allChars){const sh=rec.sheet||{},cov=libBookCover(rec.name),hue=_charHue(rec.name),tier=String(sh.tier||"").toLowerCase(),tierLabel=tier==="main"?"Hauptcharakter":tier==="supporting"?"Nebencharakter":tier==="minor"?"Nebenfigur":"",gender=_libStr(sh.gender),genderIcon=gender.toLowerCase().startsWith("f")?"mdi-gender-female":gender.toLowerCase().startsWith("m")?"mdi-gender-male":"mdi-gender-non-binary",score=sh.moral_alignment_score,pct=score!=null?Math.max(0,Math.min(100,score)):null,arc=sh.arc_direction||"neutral",arcMap={"good-to-bad":{ch:"\u2198",label:"Entwicklung zum B\xF6sen",color:"#ff7043"},"bad-to-good":{ch:"\u2197",label:"Wandel zum Guten",color:"#66bb6a"},complex:{ch:"\u2195",label:"Komplex / unvorhersehbar",color:"#ab47bc"},"stable-good":{ch:"\u2192",label:"Stabil gut",color:"#66bb6a"},"stable-bad":{ch:"\u2192",label:"Stabil b\xF6se",color:"#888"},neutral:{ch:"\u2192",label:"Neutral / stabil",color:"#aaa"}},arcInfo=arcMap[arc]||arcMap.neutral,voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",avatarHtml=rec.image?'
'+escHtml(rec.name)+'
':'
'+escHtml((rec.name||"?")[0].toUpperCase())+"
",alignHtml=pct!=null?'
B\xF6se
Gut
'+arcInfo.ch+" "+arcInfo.label+(pct>=70?" \xB7 Rechtschaffen ("+pct+"/100)":pct<=30?" \xB7 B\xF6se ("+pct+"/100)":" \xB7 Moralisch ambivalent ("+pct+"/100)")+"
"+(_libStr(sh.arc_note)?'
'+escHtml(_libStr(sh.arc_note))+"
":"")+(_libStr(sh.alignment)?'
'+escHtml(_libStr(sh.alignment))+"
":"")+"
":"",relText=_libStr(sh.relationships).toLowerCase(),relHits=(allChars||[]).filter(function(c){return c.id!==rec.id&&(c.name||"").length>1}).map(function(c){const re=new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"gi");return{c,n:(relText.match(re)||[]).length}}).filter(function(x){return x.n>0}).sort(function(a,b){return b.n-a.n}).slice(0,8),relDotsHtml=relHits.length?'
'+relHits.map(function(x){return''+escHtml((x.c.name||"?")[0].toUpperCase())+""}).join("")+"
":"",ov=document.createElement("div");ov.className="lib-char-detail-ov",ov.innerHTML='
'+avatarHtml+'
'+escHtml(rec.name)+"
"+(_libStr(sh.full_name)&&_libStr(sh.full_name).toLowerCase()!==String(rec.name||"").toLowerCase()?'
'+escHtml(_libStr(sh.full_name))+"
":"")+(_libStr(sh.title)?'
'+escHtml(_libStr(sh.title))+"
":"")+(_libStr(sh.aliases)?'
auch bekannt als '+escHtml(_libStr(sh.aliases))+"
":"")+(_libStr(sh.archetype)?'
'+escHtml(_libStr(sh.archetype))+"
":"")+'
'+(tierLabel?''+tierLabel+"":"")+(gender?' '+escHtml(gender)+"":"")+'
'+(voiceId?escHtml(voiceId):'Noch keine Stimme zugewiesen')+'
'+alignHtml+'
'+_lcdSection("mdi-card-account-details-outline","Identit\xE4t",[_lcdField("Voller Name",sh.full_name,!0),_lcdField("Vorname",sh.first_name,!0),_lcdField("Nachname",sh.last_name,!0),_lcdField("Geschlecht",sh.gender,!0),_lcdField("Titel",sh.title,!0),_lcdField("Beruf / Rolle",sh.profession,!0),_lcdField("Auch bekannt als",sh.aliases,!0)])+_lcdSection("mdi-account-outline","Erscheinung",[_lcdField("K\xF6rperlich",sh.physical,!0),_lcdField("Kleidung & Aussehen",sh.clothing,!0)])+_lcdSection("mdi-drama-masks","Pers\xF6nlichkeit",[_lcdField("Eigenheiten & Verhalten",sh.mannerisms,!0),_lcdField("Stimme & Sprache",sh.voice_pattern,!0)])+_lcdSection("mdi-book-open-outline","Geschichte",[_lcdField("Hintergrund & Herkunft",sh.backstory,!0),_lcdField("Motivation",sh.motivation,!0),_lcdField("\xC4ngste",sh.fears,!0)])+_lcdSection("mdi-sword","F\xE4higkeiten",[_lcdField("Fertigkeiten",sh.skills,!0),_lcdField("Besondere F\xE4higkeiten",sh.capabilities,!0),_lcdField("St\xE4rkstes Attribut",sh.attribute_high,!1),_lcdField("Schw\xE4chstes Attribut",sh.attribute_low,!1)])+_lcdSectionFull("mdi-account-group-outline","Beziehungen",[_lcdField("",sh.relationships,!0),relDotsHtml])+_lcdSection("mdi-shield-sword-outline","Konflikt & Strategie",[_lcdField("Konfliktstil",sh.conflict_style,!0),_lcdField("Siegbedingung",sh.win_condition,!0)])+_lcdSection("mdi-eye-outline","Geheimnisse & Bogen",[_lcdField("Dunkles Geheimnis / fataler Fehler",sh.secret,!0),_lcdField("Charakterentwicklung",sh.arc_note,!0)])+"
"+_lcdSourcesHtml(sh.sources)+(rec.analysis?'
'+escHtml(String(rec.analysis))+"
":"")+"
",document.body.appendChild(ov);const close=function(){ov.remove()};ov.querySelector(".lcd-close-btn").addEventListener("click",close),ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector(".lcd-edit-btn").addEventListener("click",function(){close(),typeof clEdit=="function"&&clEdit(rec.id)});const box=ov.querySelector(".lib-char-detail-box");ov.querySelector(".lcd-pick-voice").addEventListener("click",function(e){e.stopPropagation(),_openVoicePicker(box,rec,function(){close(),libraryRenderCharacters()})}),ov.querySelector(".lcd-auto-voice").addEventListener("click",async function(e){e.stopPropagation(),await _autoAssignVoice(rec),close(),libraryRenderCharacters()}),ov.querySelector(".lcd-online-voice").addEventListener("click",function(e){e.stopPropagation(),_charSearchOnline(rec)}),ov.querySelector(".lcd-gen-voice").addEventListener("click",function(e){e.stopPropagation(),_charDesignVoice(rec)}),ov.querySelector(".lcd-avatar").addEventListener("click",function(){const inp=document.createElement("input");inp.type="file",inp.accept="image/*",inp.onchange=async function(){const file=inp.files[0];if(!file)return;const fr=new FileReader;fr.onload=async function(ev){typeof clSetImage=="function"&&await clSetImage(rec.id,ev.target.result),toast("Profile picture saved","success"),close(),libraryRenderCharacters()},fr.readAsDataURL(file)},inp.click()})}window._charDetailModal=_charDetailModal;function _openVoicePicker(cardEl,rec,onDone){var _a2;document.querySelectorAll(".lib-voice-picker-popup").forEach(function(p){p.remove()});const voices=window._voices||[],gender=String(((_a2=rec.sheet)==null?void 0:_a2.gender)||"").toLowerCase(),genderMatch=gender.startsWith("f")?"f":gender.startsWith("m")?"m":"",popup=document.createElement("div");popup.className="lib-voice-picker-popup",popup.innerHTML='
';function renderList(filter){let list=voices.filter(function(v){return v.enabled!==!1});if(filter){const f=filter.toLowerCase();list=list.filter(function(v){return(v.id||"").toLowerCase().includes(f)||(v.name||"").toLowerCase().includes(f)})}else genderMatch&&(list=list.filter(function(v){const vg=String(v.gender||"").toLowerCase();return vg.startsWith(genderMatch)||!vg}).concat(list.filter(function(v){const vg=String(v.gender||"").toLowerCase();return vg&&!vg.startsWith(genderMatch)})));const ul=popup.querySelector(".lib-vp-list");ul.innerHTML=list.slice(0,60).map(function(v){return'
'+escHtml(v.id||v.name||"")+(v.gender?' \xB7 '+escHtml(v.gender)+"":"")+"
"}).join("")+(list.length===0?'
No voices found
':""),ul.querySelectorAll(".lib-vp-item").forEach(function(item){item.addEventListener("click",async function(){const vid=item.dataset.vid;await clUpsert(rec.book,Object.assign({},rec.sheet,{name:rec.name,voice:vid})),popup.remove(),onDone()})})}renderList(""),popup.querySelector(".lib-vp-input").addEventListener("input",function(e){renderList(e.target.value)}),cardEl.style.position="relative",cardEl.appendChild(popup),setTimeout(function(){function close(e){popup.contains(e.target)||(popup.remove(),document.removeEventListener("click",close))}document.addEventListener("click",close)},0),popup.querySelector(".lib-vp-input").focus()}async function _autoAssignVoice(rec){var _a2;const voices=(window._voices||[]).filter(function(v){return v.enabled!==!1});if(!voices.length){toast("Voice library not loaded","error");return}const gender=String(((_a2=rec.sheet)==null?void 0:_a2.gender)||"").toLowerCase().trim(),isFemale=gender.startsWith("f")||gender.startsWith("w"),isMale=!isFemale&&gender.startsWith("m"),gMatch=isFemale?"f":isMale?"m":"";let pool=gMatch?voices.filter(function(v){const vg=String(v.gender||"").toLowerCase();return gMatch==="f"?vg.startsWith("f")||vg.startsWith("w"):vg.startsWith("m")}):voices;pool.length||(pool=voices);const usedInBook=new Set;try{(await clGetAllByTagOrBook(rec.book)).forEach(function(r){r.voice&&r.id!==rec.id&&usedInBook.add(r.voice)})}catch{}const fresh=pool.filter(function(v){return!usedInBook.has(v.id)}),candidate=(fresh.length?fresh:pool).sort(function(a,b){return(b.rating||0)-(a.rating||0)})[0];if(!candidate){toast("No matching voice found","error");return}await clUpsert(rec.book,Object.assign({},rec.sheet,{name:rec.name,voice:candidate.id})),toast("Assigned "+candidate.id+" \u2192 "+rec.name,"success")}function _charLang(rec){const sh=rec.sheet||{},text=[sh.backstory,sh.voice_pattern,sh.mannerisms,sh.relationships,sh.motivation,sh.archetype].filter(Boolean).join(" ");return typeof detectLang=="function"?detectLang(text):""}function _buildVoicePrompt(rec){const sh=rec.sheet||{},g=String(sh.gender||"").toLowerCase(),genderWord=g.startsWith("f")?"female":g.startsWith("m")?"male":"",bits=[];return bits.push("A "+(genderWord?genderWord+" ":"")+"voice"+(sh.archetype?" for "+sh.archetype.toLowerCase():"")+"."),sh.voice_pattern&&bits.push(sh.voice_pattern),sh.mannerisms&&bits.push("Mannerisms: "+sh.mannerisms),sh.physical&&bits.push(sh.physical),sh.alignment&&bits.push("Disposition: "+sh.alignment),bits.join(" ").slice(0,600)}function _selectLoose(sel,val){if(!sel||!val)return;const v=String(val).toLowerCase(),opt=[...sel.options].find(function(o){const ov=o.value.toLowerCase(),ot=o.textContent.toLowerCase();return ov===v||ot===v||ov.startsWith(v)||ot.startsWith(v)||v.startsWith(ov)});opt&&(sel.value=opt.value,sel.dispatchEvent(new Event("change")))}function _charSearchOnline(rec){typeof navTo=="function"&&navTo("s-studio");const lang=_charLang(rec);setTimeout(function(){const fishTab=document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');fishTab&&fishTab.click(),setTimeout(function(){const langSel=document.getElementById("fa-lang");langSel&&_selectLoose(langSel,lang);const search=document.getElementById("fa-search");search&&(search.value=rec.name,search.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:!0})))},120)},120),toast("Searching online voices for "+rec.name+(lang?" ("+lang+")":""),"info")}function _charDesignVoice(rec){typeof navTo=="function"&&navTo("s-design");const sh=rec.sheet||{},lang=_charLang(rec);setTimeout(function(){_selectLoose(document.getElementById("design-gender"),sh.gender),_selectLoose(document.getElementById("design-language"),lang);const instruct=document.getElementById("design-instruct");instruct&&(instruct.value=_buildVoicePrompt(rec));const nm=document.getElementById("design-preset-name");nm&&(nm.value=rec.name)},140),toast("Voice design prepared for "+rec.name+(lang?" \xB7 "+lang:""),"info")}window._charSearchOnline=_charSearchOnline,window._charDesignVoice=_charDesignVoice,window.libraryRenderCharacters=libraryRenderCharacters; +`),personality:persona||prev.personality||"",scenario:rec.book||prev.scenario||"",first_mes:prev.first_mes||"",mes_example:sh.voice_pattern||prev.mes_example||"",creator_notes:"Exported from TTS Voice Creator"+(rec.book?" \xB7 "+rec.book:""),system_prompt:prev.system_prompt||"",post_history_instructions:prev.post_history_instructions||"",tags:String(rec.tags||rec.book||"").split(",").map(t=>t.trim()).filter(Boolean),creator:prev.creator||"",character_version:prev.character_version||"1.0",extensions:Object.assign({},prev.extensions,{tts_voice:rec.voice||""})}}}function stDownloadJson(card,filename){const blob=new Blob([JSON.stringify(card,null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=filename,document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(a.href),1e3)}async function stImportCards(book,fileList){let n=0;for(const file of fileList)try{const data=await stParseFile(file),sheet=stToSheet(data);typeof clUpsert=="function"&&(await clUpsert(book||sheet.name,Object.assign({},sheet,{tags:book||""})),n++)}catch(e){typeof toast=="function"&&toast("\u201C"+(file.name||"card")+"\u201D: "+(e.message||e),"error")}return n}function stImportDialog(book,onDone){const inp=document.createElement("input");inp.type="file",inp.accept=".json,.png",inp.multiple=!0,inp.onchange=async()=>{if(!inp.files.length)return;const n=await stImportCards(book,inp.files);typeof toast=="function"&&toast(n?"Imported "+n+" character"+(n>1?"s":""):"Nothing imported",n?"success":"error"),typeof onDone=="function"&&onDone()},inp.click()}function stExportRecord(rec){const card=stFromRecord(rec),safe=String(rec.name||"character").replace(/[^\w\- ]+/g,"").trim().replace(/\s+/g,"_")||"character";stDownloadJson(card,safe+".card.json")}window.stParseFile=stParseFile,window.stToSheet=stToSheet,window.stFromRecord=stFromRecord,window.stImportCards=stImportCards,window.stImportDialog=stImportDialog,window.stExportRecord=stExportRecord;const LIB_READER_API="/api/reader/docs";function prodKey(title){return String(title||"").trim().toLowerCase()}window._libraryView=function(){try{return localStorage.getItem("ttsvc_library_view")||"books"}catch{return"books"}}(),window.navLibraryView=function(view){typeof navTo=="function"&&navTo("s-library"),window._libraryView=view;try{localStorage.setItem("ttsvc_library_view",view)}catch{}document.querySelectorAll("[data-library-view]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryView===view)}),document.querySelectorAll("[data-library-panel]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryPanel===view)}),view==="characters"&&typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("castlib"),libraryRender(view)},window.libraryRender=function(view){view=view||window._libraryView||"books",document.querySelectorAll("[data-library-view]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryView===view)}),document.querySelectorAll("[data-library-panel]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryPanel===view)}),view==="books"?libraryRenderBooks():view==="plays"?libraryRenderPlays():view==="characters"&&typeof window.libraryRenderCharacters=="function"&&window.libraryRenderCharacters()};function _libSkeleton(n){return Array.from({length:n},()=>'
').join("")}async function libraryRenderBooks(){const list=document.getElementById("lib-books-list");if(!list)return;list.innerHTML=_libSkeleton(4);let all=[];try{const r=await fetch(LIB_READER_API);r.ok&&(all=(await r.json()).docs||[])}catch{all=[]}if(!all.length){list.innerHTML='

No books yet.

Open Read Aloud, import a PDF or text, and save it to your library.

';return}all.sort(function(a,b){return new Date(b.updated||0)-new Date(a.updated||0)}),list.innerHTML=all.map(function(rec){const total=rec.sentenceCount||0,synthPct=total?Math.round((rec.synthCount||0)/total*100):0,readPct=total?Math.round((rec.idx||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"",cov=libBookCover(rec.title||"Untitled"),coverUrl=LIB_READER_API+"/"+rec.id+"/cover?t="+new Date(rec.updated||Date.now()).getTime(),bg=rec.hasCover?`style="background-image:linear-gradient(to bottom,rgba(0,0,0,.3),rgba(0,0,0,.8)),url('`+coverUrl+`');background-size:cover;background-position:center;color:#fff"`:'style="--bk1:'+cov.c1+";--bk2:"+cov.c2+'"';return'
'+escHtml(rec.title||"Untitled")+'
'+total+" sentences"+(rec.pageCount?" \xB7 "+rec.pageCount+" pg":"")+'
'+readPct+"% read \xB7 "+date+"
"}).join(""),list.querySelectorAll(".lib-book").forEach(function(el){const id=el.dataset.id,title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||(window._readerStartView="main",typeof navTo=="function"&&navTo("s-reader"),typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(id))});const reh=el.querySelector(".lib-act-rehearse");reh&&reh.addEventListener("click",function(e){e.stopPropagation(),productionOpenInRehearser(title)});const del=el.querySelector(".lib-act-del-book");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete book?","Audio files will be removed.",async function(){try{if(!(await fetch(LIB_READER_API+"/"+id,{method:"DELETE"})).ok)throw new Error("delete failed");toast("Book deleted","success"),libraryRenderBooks()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function libraryRenderPlays(){const list=document.getElementById("lib-plays-list");if(!list)return;list.innerHTML=_libSkeleton(3);let all=[];try{all=typeof rehDbGetAll=="function"?await rehDbGetAll():[]}catch{all=[]}if(!all.length){list.innerHTML='

No theater plays yet.

Open Script Rehearsal \u2192 Import / Export to add a script, or cast a book as an audiobook.

';return}all.sort(function(a,b){return new Date(b.updated||0)-new Date(a.updated||0)}),list.innerHTML=all.map(function(rec){const speakers=Object.keys(rec.cast||{}),total=typeof parseScript=="function"?parseScript(rec.script||"").filter(function(l){return l.type==="dialog"}).length:0,pct=total?Math.round((rec.lineIndex||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"\u2014",cov=libBookCover(rec.title||"Untitled"),avatars=speakers.slice(0,5).map(function(sp){return''+(sp[0]||"?").toUpperCase()+""}).join("");return'
'+escHtml(rec.title||"Untitled")+'
'+avatars+"
"+total+" lines \xB7 "+speakers.length+' cast
'+pct+"% \xB7 "+date+"
"}).join(""),list.querySelectorAll(".lib-play").forEach(function(el){const id=parseInt(el.dataset.id,10),title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||openPlayInRehearser(id)});const ra=el.querySelector(".lib-act-readaloud");ra&&ra.addEventListener("click",function(e){e.stopPropagation(),productionOpenInReader(title)});const del=el.querySelector(".lib-act-del-play");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete rehearsal?","This cannot be undone.",async function(){try{typeof rehDbDelete=="function"&&await rehDbDelete(id),toast("Rehearsal deleted","success"),libraryRenderPlays()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function openPlayInRehearser(id){try{const rec=typeof rehDbGetById=="function"?await rehDbGetById(id):null;rec&&typeof loadRecord=="function"?loadRecord(rec):toast("Rehearsal not found","error")}catch{toast("Could not open rehearsal","error")}}async function productionOpenInRehearser(title){const key=prodKey(title);try{const match=(typeof rehDbGetAll=="function"?await rehDbGetAll():[]).find(function(p){return prodKey(p.title)===key});if(match){openPlayInRehearser(match.id);return}}catch{}let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&book.kind!=="pdf")try{const sr=await fetch(LIB_READER_API+"/"+book.id+"/source"),text=sr.ok?await sr.text():"";if(text&&typeof audiobookOpenInRehearser=="function"){audiobookOpenInRehearser(text,title,[]);return}}catch{}if(book&&book.kind==="pdf"){typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(book.id),toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal',"info");return}toast("No source to rehearse for this title yet","error")}async function productionOpenInReader(title){const key=prodKey(title);let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&typeof readerOpenLibraryDoc=="function"){readerOpenLibraryDoc(book.id);return}typeof navTo=="function"&&navTo("s-reader"),toast("No audiobook for this title yet \u2014 import its source in Read Aloud","info")}async function castForProduction(title){const out={};if(typeof clGetAllByTagOrBook!="function")return out;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{recs=[]}return recs.forEach(function(r){const name=(r.name||"").trim();if(!name)return;const voice=r.voice&&r.voice.id?r.voice.id:typeof r.voice=="string"?r.voice:"";out[name.toLowerCase()]={name,voice:voice||"",gender:r.sheet&&r.sheet.gender||"",soul:r.sheet&&(r.sheet.voice_pattern||r.sheet.motivation)||"",tags:r.tags||""}}),out}window.castForProduction=castForProduction;async function castWriteBack(title,castMap){if(!title||!castMap||typeof clGetAllByTagOrBook!="function"||typeof clPut!="function")return;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{return}if(!recs.length)return;const byName={};recs.forEach(function(r){byName[(r.name||"").trim().toLowerCase()]=r});let n=0;for(const sp of Object.keys(castMap)){if(String(sp).includes("NARRATOR"))continue;const voice=(castMap[sp]||{}).voice;if(!voice||voice==="me")continue;const rec=byName[String(sp).trim().toLowerCase()];if(!(!rec||(rec.voice&&rec.voice.id?rec.voice.id:typeof rec.voice=="string"?rec.voice:"")===voice)){rec.voice={id:voice},rec.updated=new Date;try{await clPut(rec),n++}catch{}}}return n}window.castWriteBack=castWriteBack;function libBookCover(title){let h=0;const s=String(title||"Untitled");for(let i=0;i'+heading+'
'+sub+'
',o.addEventListener("click",function(e){e.stopPropagation()}),o.querySelector("[data-lib-cancel]").addEventListener("click",function(e){e.stopPropagation(),o.remove()}),o.querySelector("[data-lib-ok]").addEventListener("click",async function(e){e.stopPropagation(),o.innerHTML='',await onConfirm()}),cardEl.appendChild(o)}window.libraryRenderBooks=libraryRenderBooks,window.libraryRenderPlays=libraryRenderPlays,window.productionOpenInRehearser=productionOpenInRehearser,window.productionOpenInReader=productionOpenInReader,window.prodKey=prodKey;async function libraryRenderCharacters(){var _a2;const container=document.getElementById("lib-chars-list");if(!container)return;let all=[];try{all=typeof clGetAll=="function"?await clGetAll():[]}catch(e){console.warn("[characters] load failed",e),typeof toast=="function"&&toast("Failed to load characters \u2014 keeping the current view","error");return}const mainEl=document.getElementById("main-content"),savedScrollTop=!window._libCharsScrollToBook&&mainEl?mainEl.scrollTop:null;container.innerHTML='
Loading characters\u2026
';const byId=new Map(all.map(function(rec){return[rec.id,rec]}));if(!all.length){container.innerHTML='

No characters yet.

Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets \u2014 or import an existing cast from SillyTavern.

',container.querySelector("#lib-chars-import").addEventListener("click",function(){typeof stImportDialog=="function"&&stImportDialog("",function(){libraryRenderCharacters()})}),savedScrollTop!=null&&(mainEl.scrollTop=savedScrollTop);return}const byBook={};all.forEach(function(rec){const bk=rec.book||"Unsorted";byBook[bk]||(byBook[bk]=[]),byBook[bk].push(rec)}),Object.keys(byBook).forEach(function(bk){if(!byBook[bk].some(function(r){return String(r.name||"").trim().toLowerCase()==="narrator"})){const narrRec={id:clKey(bk,"Narrator"),book:bk,name:"Narrator",tags:bk,voice:null,image:null,sheet:{}};byId.set(narrRec.id,narrRec),byBook[bk].unshift(narrRec)}}),container.innerHTML="";const viewMode=localStorage.getItem("ttsvc_libchars_view")==="table"?"table":"cards";let returnToReader=!1;try{returnToReader=sessionStorage.getItem("ttsvc_cast_return")==="reader"}catch{}const SORT_OPTIONS=[["tier","Rolle (Haupt zuerst)"],["alpha","Alphabet"],["lines","Anzahl Zeilen"],["gender","Geschlecht"],["voice","Stimme zugewiesen"]],sortMode=SORT_OPTIONS.some(function(o){return o[0]===localStorage.getItem("ttsvc_libchars_sort")})?localStorage.getItem("ttsvc_libchars_sort"):"tier",bar=document.createElement("div");bar.className="lib-chars-toolbar",bar.innerHTML=(returnToReader?'':"")+'
',bar.querySelector("#lib-chars-import").addEventListener("click",function(){typeof stImportDialog=="function"&&stImportDialog("",function(){libraryRenderCharacters()})}),(_a2=bar.querySelector("#lib-chars-back-reader"))==null||_a2.addEventListener("click",function(){try{sessionStorage.removeItem("ttsvc_cast_return")}catch{}typeof navTo=="function"&&navTo("s-reader")}),bar.querySelector("#lib-chars-sort-sel").addEventListener("change",function(){localStorage.setItem("ttsvc_libchars_sort",this.value),libraryRenderCharacters()}),bar.querySelectorAll(".lib-chars-view-toggle button").forEach(function(btn){btn.addEventListener("click",function(){localStorage.setItem("ttsvc_libchars_view",btn.dataset.view),libraryRenderCharacters()})}),container.appendChild(bar);const _charSortCmp={tier:function(a,b){var _a3,_b2,_c2,_d2;const tierOrder={main:0,supporting:1,minor:2},ta=(_b2=tierOrder[String(((_a3=a.sheet)==null?void 0:_a3.tier)||"minor").toLowerCase()])!=null?_b2:2,tb=(_d2=tierOrder[String(((_c2=b.sheet)==null?void 0:_c2.tier)||"minor").toLowerCase()])!=null?_d2:2;return ta-tb||(a.name||"").localeCompare(b.name||"")},alpha:function(a,b){return(a.name||"").localeCompare(b.name||"")},lines:function(a,b){var _a3,_b2;return(((_a3=b.sheet)==null?void 0:_a3.line_count)||0)-(((_b2=a.sheet)==null?void 0:_b2.line_count)||0)||(a.name||"").localeCompare(b.name||"")},gender:function(a,b){var _a3,_b2;const ga=String(((_a3=a.sheet)==null?void 0:_a3.gender)||"zzz"),gb=String(((_b2=b.sheet)==null?void 0:_b2.gender)||"zzz");return ga.localeCompare(gb)||(a.name||"").localeCompare(b.name||"")},voice:function(a,b){return(b.voice?1:0)-(a.voice?1:0)||(a.name||"").localeCompare(b.name||"")},age:function(a,b){return _charAgeSortVal(a.sheet)-_charAgeSortVal(b.sheet)||(a.name||"").localeCompare(b.name||"")},language:function(a,b){return _charLangLabel(a).localeCompare(_charLangLabel(b))||(a.name||"").localeCompare(b.name||"")},align:function(a,b){var _a3,_b2,_c2,_d2;return((_b2=(_a3=b.sheet)==null?void 0:_a3.moral_alignment_score)!=null?_b2:-1)-((_d2=(_c2=a.sheet)==null?void 0:_c2.moral_alignment_score)!=null?_d2:-1)||(a.name||"").localeCompare(b.name||"")}},sortDir=localStorage.getItem("ttsvc_libchars_sort_dir")==="desc"?"desc":"asc",productions=document.createDocumentFragment();if(Object.keys(byBook).sort().forEach(function(book){const chars=byBook[book].sort(_charSortCmp[sortMode]||_charSortCmp.tier);sortDir==="desc"&&chars.reverse();const narrIdx=chars.findIndex(function(r){return String(r.name||"").trim().toLowerCase()==="narrator"});narrIdx>0&&chars.unshift(chars.splice(narrIdx,1)[0]);const cov=libBookCover(book),prod=document.createElement("div");prod.className="lib-chars-production",prod.dataset.book=book;const collapseKey="ttsvc_libchars_collapsed::"+book;let isCollapsed=localStorage.getItem(collapseKey)==="1";window._libCharsScrollToBook&&(isCollapsed=book!==window._libCharsScrollToBook),isCollapsed&&prod.classList.add("lib-chars-production-collapsed"),prod.innerHTML='
'+escHtml(book)+'
`+(viewMode==="table"?_charsTableHtml(chars,sortMode,sortDir):'
'+chars.map(function(rec){return _charCardHtml(rec,chars)}).join("")+"
")+"
",prod.querySelector(".lib-chars-prod-collapse-btn").addEventListener("click",function(e){e.stopPropagation();const collapsed=prod.classList.toggle("lib-chars-production-collapsed");localStorage.setItem(collapseKey,collapsed?"1":"0")}),prod.querySelector(".lib-chars-prod-head").addEventListener("click",function(e){e.target.closest("button, select, input, a")||prod.querySelector(".lib-chars-prod-collapse-btn").click()}),prod.querySelector(".lib-chars-bookctx-btn").addEventListener("click",function(){_editBookProfile(book)}),prod.querySelector(".lib-chars-casting-btn").addEventListener("click",function(){typeof navTo=="function"&&navTo("s-reader")}),prod.querySelector(".lib-chars-cast-btn").addEventListener("click",function(){typeof productionOpenInReader=="function"&&productionOpenInReader(book),toast("Open the book in Read Aloud then click Cast Characters","info")}),prod.querySelector(".lib-chars-reh-btn").addEventListener("click",function(){typeof productionOpenInRehearser=="function"&&productionOpenInRehearser(book)}),prod.querySelector(".lib-chars-read-btn").addEventListener("click",function(){typeof productionOpenInReader=="function"&&productionOpenInReader(book)}),prod.querySelector(".lib-chars-imp-btn").addEventListener("click",function(){typeof stImportDialog=="function"&&stImportDialog(book,function(){libraryRenderCharacters()})}),prod.querySelectorAll("[data-sort-key]").forEach(function(th){th.addEventListener("click",function(){const key=th.dataset.sortKey,nextDir=sortMode===key&&sortDir==="asc"?"desc":"asc";localStorage.setItem("ttsvc_libchars_sort",key),localStorage.setItem("ttsvc_libchars_sort_dir",nextDir),libraryRenderCharacters()})});const selectAllBtn=prod.querySelector(".lib-chars-select-all-btn"),bulkBtn=prod.querySelector(".lib-chars-bulk-voice-btn"),bulkCount=prod.querySelector(".lib-chars-bulk-count"),designBtn=prod.querySelector(".lib-chars-bulk-design-btn"),designCount=prod.querySelector(".lib-chars-bulk-count-design"),imageBtn=prod.querySelector(".lib-chars-bulk-image-btn"),imageCount=prod.querySelector(".lib-chars-bulk-count-image"),imageProviderSel=prod.querySelector(".lib-chars-image-provider");imageProviderSel&&(imageProviderSel.value=typeof _appSettings!="undefined"&&_appSettings.image_gen_provider||"");const deleteBtn=prod.querySelector(".lib-chars-bulk-delete-btn"),deleteCount=prod.querySelector(".lib-chars-bulk-count-delete"),tblSelectAllCb=prod.querySelector(".lib-chars-tbl-select-all-cb"),syncTblSelectAllCb=function(){if(!tblSelectAllCb)return;const boxes=[...prod.querySelectorAll(".lib-char-select-cb")],checkedN=boxes.filter(function(cb){return cb.checked}).length;tblSelectAllCb.checked=boxes.length>0&&checkedN===boxes.length,tblSelectAllCb.indeterminate=checkedN>0&&checkedN0&&boxes.every(function(cb){return cb.checked});boxes.forEach(function(cb){cb.checked=!allChecked}),refreshBulkBtn()}),tblSelectAllCb&&tblSelectAllCb.addEventListener("change",function(){[...prod.querySelectorAll(".lib-char-select-cb")].forEach(function(cb){cb.checked=tblSelectAllCb.checked}),refreshBulkBtn()}),syncTblSelectAllCb();const runBulk=async function(btn,ids,verb,fn){btn.disabled=!0;const orig=btn.innerHTML;let done=0,failed=0,lastErrMsg="",repeatErrMsg="",repeatCount=0,aborted=!1;for(const id of ids){const rec=byId.get(id);if(rec){btn.innerHTML=' '+verb+" "+(done+failed+1)+" / "+ids.length+"\u2026";try{await fn(rec),done++,repeatCount=0}catch(e){if(failed++,lastErrMsg=e&&e.message?e.message:String(e),console.error("[bulk "+verb+"]",rec.name,e),lastErrMsg===repeatErrMsg?repeatCount++:(repeatErrMsg=lastErrMsg,repeatCount=1),repeatCount>=3){aborted=!0;break}}}}btn.innerHTML=orig;const remaining=ids.length-done-failed,suffix=failed?` (${failed} failed${aborted&&remaining?`, ${remaining} skipped`:""}${lastErrMsg?": "+lastErrMsg.slice(0,200):""})`:"";toast(`${verb} finished for ${done} character${done!==1?"s":""}${suffix}`,failed&&!done?"error":"success"),await _flushPendingTtsRestart(),typeof loadVoiceLibrary=="function"&&await loadVoiceLibrary({refresh:!0}).catch(()=>{}),libraryRenderCharacters()};bulkBtn.addEventListener("click",function(){const ids=[...prod.querySelectorAll(".lib-char-select-cb:checked")].map(function(cb){return cb.dataset.charId});ids.length&&runBulk(bulkBtn,ids,"Assigning",_autoAssignVoice)}),designBtn.addEventListener("click",function(){const ids=[...prod.querySelectorAll(".lib-char-select-cb:checked")].map(function(cb){return cb.dataset.charId});ids.length&&runBulk(designBtn,ids,"Designing",_charAutoDesignVoice)}),imageBtn.addEventListener("click",function(){const ids=[...prod.querySelectorAll(".lib-char-select-cb:checked")].map(function(cb){return cb.dataset.charId});if(!ids.length)return;const provider=imageProviderSel?imageProviderSel.value:"";runBulk(imageBtn,ids,"Generating images",function(rec){return _charAutoGenerateImage(rec,provider)})});const fixLangBtn=prod.querySelector(".lib-chars-fix-lang-btn");fixLangBtn==null||fixLangBtn.addEventListener("click",function(){const _bookLangCounts={};chars.forEach(function(r){const l=_charLang(r);l&&(_bookLangCounts[l]=(_bookLangCounts[l]||0)+1)});let _bookLang="",_bookLangBest=0;Object.keys(_bookLangCounts).forEach(function(l){_bookLangCounts[l]>_bookLangBest&&(_bookLang=l,_bookLangBest=_bookLangCounts[l])});const bookCode=_bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[_bookLang]:null,mismatched=chars.filter(function(rec){if(!rec.voice||!bookCode)return!1;const voiceId=typeof rec.voice=="object"?rec.voice.id:rec.voice;return _voiceLangCode(voiceId)!==bookCode});if(!mismatched.length){toast("No language-mismatched voices found in this production","info");return}runBulk(fixLangBtn,mismatched.map(function(r){return r.id}),"Redesigning",function(rec){return _charAutoDesignVoice(rec,!0)})}),deleteBtn.addEventListener("click",async function(){const ids=[...prod.querySelectorAll(".lib-char-select-cb:checked")].map(function(cb){return cb.dataset.charId});!ids.length||!await confirmDialog(`Delete ${ids.length} character${ids.length!==1?"s":""} from the library? This cannot be undone \u2014 use it to clear out stale/corrupted entries before a fresh recast.`,{title:"Delete characters?",okLabel:"Delete",danger:!0})||runBulk(deleteBtn,ids,"Deleting",function(rec){return clDelete(rec.id)})}),_wireCharCards(prod,byId,chars),productions.appendChild(prod)}),container.appendChild(productions),window._libCharsScrollToBook){const target=window._libCharsScrollToBook;window._libCharsScrollToBook=null;const prodEl=[...container.querySelectorAll(".lib-chars-production")].find(function(p){return p.dataset.book===target});prodEl&&(prodEl.scrollIntoView({behavior:"smooth",block:"start"}),prodEl.classList.add("lib-chars-production-highlight"),setTimeout(function(){prodEl.classList.remove("lib-chars-production-highlight")},2200))}else savedScrollTop!=null&&(mainEl.scrollTop=savedScrollTop)}function _charHue(name){return Math.abs((name||"?").split("").reduce(function(h,c){return(h*31+c.charCodeAt(0))%360},0))}function _charAlignHtml(sh){const score=sh.moral_alignment_score;if(score==null)return"";const pct=Math.max(0,Math.min(100,score)),arc=sh.arc_direction||"neutral",arrowMap={"good-to-bad":{ch:"\u2198",color:"#ff7043",tip:"Arc: Descends toward evil"},"bad-to-good":{ch:"\u2197",color:"#66bb6a",tip:"Arc: Redeems toward good"},complex:{ch:"\u2195",color:"#ab47bc",tip:"Arc: Complex / unpredictable"},"stable-good":{ch:"\u2192",color:"#66bb6a",tip:"Arc: Stable good"},"stable-bad":{ch:"\u2192",color:"#888",tip:"Arc: Stable evil"},neutral:{ch:"\u2192",color:"#aaa",tip:"Arc: Neutral"}},a=arrowMap[arc]||arrowMap.neutral;return'
\u25CF
\u25CF'+a.ch+"
"}function _libStr(v){return v==null?"":typeof v=="string"?v:Array.isArray(v)?v.filter(Boolean).join(", "):JSON.stringify(v)}function _charAgeLabel(sh){return _libStr((sh==null?void 0:sh.age_estimate)||"").trim()}function _charAgeSortVal(sh){const m=_charAgeLabel(sh).match(/\d+/);return m?parseInt(m[0],10):9999}function _charLangLabel(rec){const sh=(rec==null?void 0:rec.sheet)||{},voiceLang=rec!=null&&rec.voice&&typeof rec.voice=="object"&&rec.voice.language||"";return _libStr(sh.languages||voiceLang).trim()}function _charGenderLabel(sh){const gender=String((sh==null?void 0:sh.gender)||"").trim();return gender?gender.charAt(0).toUpperCase()+gender.slice(1):""}function _charRelsHtml(rec,allChars){var _a2;if(!allChars||allChars.length<2)return"";const relText=_libStr((_a2=rec.sheet)==null?void 0:_a2.relationships).toLowerCase();if(!relText)return"";const hits=allChars.filter(function(c){return c.id!==rec.id&&(c.name||"").length>1}).map(function(c){const re=new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"gi");return{c,n:(relText.match(re)||[]).length}}).filter(function(x){return x.n>0}).sort(function(a,b){return b.n-a.n}).slice(0,5);return hits.length?'
'+hits.map(function(x){return''+escHtml((x.c.name||"?")[0].toUpperCase())+""}).join("")+"
":""}let _avatarHoverEl=null;function _showAvatarHoverPreview(rec,anchorEl){if(!rec.image)return;_avatarHoverEl||(_avatarHoverEl=document.createElement("div"),_avatarHoverEl.className="lib-avatar-hover-preview",_avatarHoverEl.innerHTML="",document.body.appendChild(_avatarHoverEl)),_avatarHoverEl.querySelector("img").src=rec.image;const rect=anchorEl.getBoundingClientRect(),size=512;let left=rect.right+12;left+size>window.innerWidth&&(left=rect.left-size-12);let top=rect.top+rect.height/2-size/2;top=Math.max(8,Math.min(top,window.innerHeight-size-8)),_avatarHoverEl.style.left=Math.max(8,left)+"px",_avatarHoverEl.style.top=top+"px",_avatarHoverEl.hidden=!1}function _hideAvatarHoverPreview(){_avatarHoverEl&&(_avatarHoverEl.hidden=!0)}function _wireCharCards(root,recsById,allRecs,onChange,detailOpts){const refresh=onChange||libraryRenderCharacters;root.querySelectorAll(".lib-char-card").forEach(function(card){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2,_j2;const charId=card.dataset.charId,rec=recsById.get?recsById.get(charId):recsById[charId];if(rec){if(card.addEventListener("click",function(e){e.target.closest("button, .lib-char-avatar, .lib-voice-picker-popup")||_charDetailPage(rec,allRecs,detailOpts)}),(_a2=card.querySelector(".lib-char-avatar"))==null||_a2.addEventListener("click",function(e){e.stopPropagation(),_openAvatarLightbox(rec,refresh)}),rec.image){const avatarEl=card.querySelector(".lib-char-avatar");avatarEl==null||avatarEl.addEventListener("mouseenter",function(){_showAvatarHoverPreview(rec,avatarEl)}),avatarEl==null||avatarEl.addEventListener("mouseleave",_hideAvatarHoverPreview)}(_b2=card.querySelector(".lib-char-voice-pill"))==null||_b2.addEventListener("click",function(e){e.stopPropagation(),_openVoicePicker(e.currentTarget,rec,function(){refresh()})}),(_c2=card.querySelector(".lib-char-pick-voice"))==null||_c2.addEventListener("click",function(e){e.stopPropagation(),_openVoicePicker(e.currentTarget,rec,function(){refresh()})}),(_d2=card.querySelector(".lib-char-auto-voice"))==null||_d2.addEventListener("click",async function(e){e.stopPropagation(),await _autoAssignVoice(rec),refresh()}),(_e2=card.querySelector(".lib-char-redesign-voice"))==null||_e2.addEventListener("click",async function(e){e.stopPropagation();const btn=e.currentTarget;btn.disabled=!0;try{await _charAutoDesignVoice(rec,!0),_schedulePendingTtsRestart(),refresh()}catch(err){toast("Voice design failed: "+(err.message||err),"error")}finally{btn.disabled=!1}}),(_f2=card.querySelector(".lib-char-remove-voice"))==null||_f2.addEventListener("click",async function(e){e.stopPropagation(),await clPut(Object.assign({},rec,{voice:"",updated:new Date})),rec.voice="",_syncVoicePictureFromChar(rec),toast("Voice removed from "+rec.name,"success"),refresh()}),(_g2=card.querySelector(".lib-char-export"))==null||_g2.addEventListener("click",function(e){e.stopPropagation(),typeof stExportRecord=="function"&&stExportRecord(rec)}),(_h2=card.querySelector(".lib-char-online-voice"))==null||_h2.addEventListener("click",function(e){e.stopPropagation(),_charSearchOnline(rec)}),(_i2=card.querySelector(".lib-char-gen-voice"))==null||_i2.addEventListener("click",function(e){e.stopPropagation(),_charDesignVoiceInline(rec)}),(_j2=card.querySelector(".lib-char-voice-play"))==null||_j2.addEventListener("click",function(e){e.stopPropagation(),_libPreviewCharVoice(rec,e.currentTarget)})}})}let _libVoicePreviewEl=null,_libVoicePreviewBtn=null;function _libStopVoicePreview(){if(_libVoicePreviewEl&&(_libVoicePreviewEl.pause(),_libVoicePreviewEl.src=""),_libVoicePreviewBtn){_libVoicePreviewBtn.classList.remove("playing","loading");const icon=_libVoicePreviewBtn.querySelector(".mdi");icon&&(icon.className="mdi mdi-play")}_libVoicePreviewBtn=null}async function _libPreviewCharVoice(rec,btn){const voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"";if(!voiceId){toast("No voice assigned yet","error");return}if(_libVoicePreviewBtn===btn&&_libVoicePreviewEl&&!_libVoicePreviewEl.paused){_libStopVoicePreview();return}_libStopVoicePreview();const icon=btn.querySelector(".mdi"),v=(window._voices||[]).find(x=>x.id===voiceId);if(!v||!v.path||typeof voiceFileUrl!="function"){toast("Voice file not found","error");return}btn.classList.add("loading"),icon&&(icon.className="mdi mdi-loading");try{_libVoicePreviewEl||(_libVoicePreviewEl=new Audio,_libVoicePreviewEl.addEventListener("ended",_libStopVoicePreview)),_libVoicePreviewEl.src=voiceFileUrl(v),await _libVoicePreviewEl.play(),btn.classList.remove("loading"),_libVoicePreviewBtn=btn,btn.classList.add("playing"),icon&&(icon.className="mdi mdi-stop")}catch(e){btn.classList.remove("loading"),icon&&(icon.className="mdi mdi-play"),toast("Preview failed: "+(e.message||e),"error")}}function _voiceExists(voiceId){if(!voiceId)return!0;const voices=window._voices||[];return voices.length?voices.some(function(v){return v.id===voiceId}):!0}function _charCardHtml(rec,allChars){const sh=rec.sheet||{},hue=_charHue(rec.name),hue2=(hue+40)%360,voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",voiceMissing=!!voiceId&&!_voiceExists(voiceId),tier=String(sh.tier||"").toLowerCase(),tierBadge=tier==="main"?'Haupt':tier==="supporting"?'Neben':"",gender=String(sh.gender||"").toLowerCase(),genderIcon=gender.startsWith("f")?"mdi-gender-female":gender.startsWith("m")?"mdi-gender-male":"mdi-gender-non-binary",snippet=_libStr(sh.mannerisms||sh.voice_pattern||sh.motivation||sh.backstory||"").slice(0,190),roleLine=_libStr(sh.profession||sh.archetype).trim(),ageLabel=_charAgeLabel(sh),langLabel=_charLangLabel(rec),genderLabel=_charGenderLabel(sh),bookLabel=_libStr(rec.book||""),lineLabel=sh.line_count!=null?String(sh.line_count)+" Zeilen":"",tagList=String(rec.tags||"").split(",").map(function(t){return t.trim()}).filter(Boolean),tagsHtml=tagList.length?'
'+tagList.map(function(t){return''+escHtml(t)+""}).join("")+"
":"",hasPhoto=!!rec.image,bannerStyle=hasPhoto?'style="background-image:linear-gradient(180deg, rgba(0,0,0,.05) 0%, rgba(0,0,0,.72) 100%), url("'+rec.image+'"); background-size:cover; background-position:center;"':'style="--ch1:hsl('+hue+",52%,35%);--ch2:hsl("+hue2+',56%,26%)"',avatarInner=hasPhoto?'':escHtml((rec.name||"?")[0].toUpperCase()),stat=function(label,value,icon){return value?'
'+escHtml(label)+''+escHtml(value)+"
":""},metaChips=[];return bookLabel&&metaChips.push(' '+escHtml(bookLabel)+""),'
'+avatarInner+'
'+(voiceId?'':"")+'
'+escHtml(rec.name)+''+tierBadge+"
"+(roleLine?'
'+escHtml(roleLine)+"
":"")+(_libStr(sh.title)?'
Titel: '+escHtml(_libStr(sh.title))+"
":"")+(_libStr(sh.aliases)?'
aka '+escHtml(_libStr(sh.aliases))+"
":"")+'
'+metaChips.join("")+'
'+stat("Occupation",_libStr(sh.profession),"mdi-briefcase-outline")+stat("Archetype",_libStr(sh.archetype),"mdi-shape-outline")+stat("Gender",genderLabel,"mdi-gender-male-female")+stat("Age",ageLabel,"mdi-cake-variant")+stat("Lines",lineLabel,"mdi-format-list-numbered")+"
"+_charAlignHtml(sh)+"
"}function _charsTableHtml(chars,sortMode,sortDir){const arrow=function(key){return sortMode===key?' ':""},th=function(key,label,title){return'"+label+arrow(key)+""},rows=chars.map(function(rec){const sh=rec.sheet||{},hue=_charHue(rec.name),voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",voiceMissing=!!voiceId&&!_voiceExists(voiceId),voiceLang=_charLangLabel(rec),tier=String(sh.tier||"").toLowerCase(),tierBadge=tier==="main"?'Haupt':tier==="supporting"?'Neben':"",gender=String(sh.gender||"").toLowerCase(),genderIcon=gender.startsWith("f")?"mdi-gender-female":gender.startsWith("m")?"mdi-gender-male":gender?"mdi-gender-non-binary":"",genderLabel=_charGenderLabel(sh),score=sh.moral_alignment_score,pct=score!=null?Math.max(0,Math.min(100,score)):null,tagList=String(rec.tags||"").split(",").map(function(t){return t.trim()}).filter(Boolean),avatarInner=rec.image?''+escHtml(rec.name)+'':escHtml((rec.name||"?")[0].toUpperCase()),occupation=_libStr(sh.profession),ageLabel=_charAgeLabel(sh),bookLabel=_libStr(rec.book||""),aliasLabel=_libStr(sh.aliases),archetype=_libStr(sh.archetype),titleLabel=_libStr(sh.title),tableMeta=[aliasLabel?"aka "+aliasLabel:"",occupation?"Occupation: "+occupation:"",titleLabel?"Title: "+titleLabel:"",archetype?"Archetype: "+archetype:""].filter(Boolean).join(" \xB7 ");return'
'+avatarInner+'
'+tierBadge+escHtml(rec.name)+"
"+(tableMeta?'
'+escHtml(tableMeta)+"
":"")+(bookLabel?'
'+escHtml(bookLabel)+"
":"")+""+(genderLabel?escHtml(genderLabel):'\u2014')+""+(ageLabel?escHtml(ageLabel):'\u2014')+""+(sh.line_count!=null?sh.line_count:'\u2014')+""+(voiceLang?escHtml(voiceLang):'\u2014')+""+(pct!=null?'
':'\u2014')+'
'+(voiceId?'"+(voiceMissing?' ':"")+escHtml(voiceId)+"":'Keine Stimme')+'
'+(voiceId?'':"")+''+(voiceId?'':"")+'
'+tagList.map(function(t){return''+escHtml(t)+""}).join("")+'
'}).join("");return'
'+th("alpha","Name")+th("gender","Geschlecht")+th("age","Alter","Estimated age")+th("lines","Zeilen","Anzahl Zeilen")+th("language","Sprache")+th("align","Gut/B\xF6se","Moralische Gesinnung")+th("voice","Stimme")+""+rows+"
TagsBook / Script
"}function _lcdSourcesHtml(sources){const list=Array.isArray(sources)?sources.filter(function(s){return s&&(s.quote||s.page!=null)}):[];return list.length?'
'+list.map(function(s){const page=s.page!=null?"Seite "+s.page:"",hint=_libStr(s.line_hint||s.hint||"");return'
'+(page||hint?'
'+escHtml([page,hint].filter(Boolean).join(" \xB7 "))+"
":"")+(s.quote?'
\u201E'+escHtml(_libStr(s.quote))+'"
':"")+"
"}).join("")+"
":""}function _lcdField(label,value,multiline){const v=_libStr(value);return v?'
'+label+'
'+escHtml(v)+"
":""}function _lcdSection(icon,label,fields){const body=fields.join("");return body?'
"+body+"
":""}function _lcdSectionFull(icon,label,fields){const body=fields.join("");return body?'
"+body+"
":""}function _lcdPromptBox(label,value,sheetKey){const has=!!(value&&String(value).trim());return'
'+escHtml(label)+(has?"":' \u2014 not generated yet')+'
'+escHtml(value||"")+'
"}function _lcdFieldEdit(label,value,sheetKey,sourceIdxs){const v=_libStr(value),links=(sourceIdxs||[]).map(function(idx){return''+(sourceIdxs.indexOf(idx)+1)+""}).join("");return'
'+(label||links?'
'+escHtml(label)+(links?' '+links+"":"")+"
":"")+'
'+escHtml(v)+"
"}function _jumpToReaderPage(pageNum){typeof navTo=="function"&&navTo("s-reader"),setTimeout(function(){var _a2,_b2;const pages=(_a2=window.readerState)==null?void 0:_a2.pages;if(pages&&pages.length>=pageNum){const pg=pages[pageNum-1];if(pg!=null&&pg.pageDiv){pg.pageDiv.scrollIntoView({behavior:"smooth",block:"start"});return}}const sentences=(_b2=window.readerState)==null?void 0:_b2.sentences;if(sentences&&sentences.length){const target0=pageNum-1,idx=sentences.findIndex(function(s){return(s.words||[]).some(function(w){var _a3,_b3;return((_b3=(_a3=w.page)!=null?_a3:w.para)!=null?_b3:0)>=target0})});if(idx>=0&&typeof readerJumpTo=="function"){readerJumpTo(idx);return}}toast('\xD6ffne das Buch in \u201EVorlesen" und klicke nochmal auf die Quelle',"info")},300)}async function _charDetailPage(rec,allChars,opts){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2,_j2;opts=opts||{};const container=opts.container||document.getElementById("lib-chars-list");if(!container)return;const goBack=typeof opts.onBack=="function"?opts.onBack:libraryRenderCharacters;window._libDetailRec=rec;const sh=rec.sheet||{},hue=_charHue(rec.name),hue2=(hue+40)%360,tier=String(sh.tier||"").toLowerCase(),tierLabel=tier==="main"?"Hauptcharakter":tier==="supporting"?"Nebencharakter":tier==="minor"?"Nebenfigur":"",gender=_libStr(sh.gender),genderIcon=gender.toLowerCase().startsWith("f")?"mdi-gender-female":gender.toLowerCase().startsWith("m")?"mdi-gender-male":"mdi-gender-non-binary",voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",score=sh.moral_alignment_score,pct=score!=null?Math.max(0,Math.min(100,score)):null,arcMap={"good-to-bad":{ch:"\u2198",label:"Entwicklung zum B\xF6sen",color:"#ff7043"},"bad-to-good":{ch:"\u2197",label:"Wandel zum Guten",color:"#66bb6a"},complex:{ch:"\u2195",label:"Komplex / unvorhersehbar",color:"#ab47bc"},"stable-good":{ch:"\u2192",label:"Stabil gut",color:"#66bb6a"},"stable-bad":{ch:"\u2192",label:"Stabil b\xF6se",color:"#888"},neutral:{ch:"\u2192",label:"Neutral / stabil",color:"#aaa"}},arcInfo=arcMap[sh.arc_direction||"neutral"]||arcMap.neutral,avatarHtml='
'+(rec.image?'
'+escHtml(rec.name)+'
':'
'+escHtml((rec.name||"?")[0].toUpperCase())+"
")+'
',conceptArtHtml='
"+(sh.concept_art_image?'
Concept art \u2014 '+escHtml(rec.name)+'
':'
'+(_libStr(sh.concept_art_prompt).trim()?"Kein Konzeptbild":"Kein Konzeptbild-Prompt \u2014 erst unten bei Generation Prompts erzeugen")+"
")+"
",alignHtml=pct!=null?'
B\xF6seGut'+pct+'/100
'+arcInfo.ch+" "+arcInfo.label+(pct>=70?" \xB7 Rechtschaffen ("+pct+"/100)":pct<=30?" \xB7 B\xF6se ("+pct+"/100)":" \xB7 Moralisch ambivalent ("+pct+"/100)")+"
"+(_libStr(sh.alignment)?'
'+escHtml(_libStr(sh.alignment))+"
":"")+"
":"",promptsHtml='
'+_lcdPromptBox("Voice Design Prompt",sh.voice_design_prompt,"voice_design_prompt")+_lcdPromptBox("Character Image Prompt",sh.image_prompt,"image_prompt")+_lcdPromptBox("SillyTavern Character Prompt",sh.silly_tavern_prompt,"silly_tavern_prompt")+_lcdPromptBox("Concept Art Prompt",sh.concept_art_prompt,"concept_art_prompt")+"
",relText=_libStr(sh.relationships).toLowerCase(),relHits=(allChars||[]).filter(function(c){return c.id!==rec.id&&(c.name||"").length>1}).map(function(c){const re=new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"gi");return{c,n:(relText.match(re)||[]).length}}).filter(function(x){return x.n>0}).sort(function(a,b){return b.n-a.n}).slice(0,8),relDotsHtml=relHits.length?'
'+relHits.map(function(x){return''+escHtml((x.c.name||"?")[0].toUpperCase())+""}).join("")+"
":"",sourcesList=Array.isArray(sh.sources)?sh.sources.filter(function(s){return s&&(s.quote||s.page!=null)}):[],sourcesByField={};sourcesList.forEach(function(s,idx){const key=_libStr(s.line_hint||s.hint||"").trim().toLowerCase();key&&(sourcesByField[key]=sourcesByField[key]||[]).push(idx)});const sourcesHtml=sourcesList.length?'
'+sourcesList.map(function(s,idx){const page=s.page!=null?"Seite "+s.page:"",hint=_libStr(s.line_hint||s.hint||"");return'
'+(page||hint?'
'+escHtml([page,hint].filter(Boolean).join(" \xB7 "))+"
":"")+(s.quote?'
\u201E'+escHtml(_libStr(s.quote))+'"
':"")+"
"}).join("")+"
":"",sidebarHtml=(allChars||[]).slice().sort(function(a,b){var _a3,_b3,_c3,_d3;return(((_b3=(_a3=b.sheet)==null?void 0:_a3.sources)==null?void 0:_b3.length)||0)-(((_d3=(_c3=a.sheet)==null?void 0:_c3.sources)==null?void 0:_d3.length)||0)}).map(function(c){var _a3;const h=_charHue(c.name),count=(((_a3=c.sheet)==null?void 0:_a3.sources)||[]).length;return'
'+escHtml((c.name||"?")[0].toUpperCase())+''+escHtml(c.name)+""+(count?''+count+"":"")+"
"}).join("");container.innerHTML="";const pg=document.createElement("div");pg.className="lib-char-page",pg.style.gridColumn="1 / -1",pg.innerHTML='
'+avatarHtml+'
'+escHtml(rec.name)+"
"+(_libStr(sh.full_name)&&_libStr(sh.full_name).toLowerCase()!==String(rec.name||"").toLowerCase()?'
'+escHtml(_libStr(sh.full_name))+"
":"")+(_libStr(sh.title)?'
'+escHtml(_libStr(sh.title))+"
":"")+'
'+escHtml(_libStr(sh.aliases))+'
'+escHtml(_libStr(sh.archetype))+'
'+(tierLabel?''+tierLabel+"":"")+(gender?' '+escHtml(gender)+"":"")+'
'+conceptArtHtml+'
'+(voiceId?_voiceExists(voiceId)?escHtml(voiceId):' '+escHtml(voiceId)+"":'Noch keine Stimme zugewiesen')+'
'+alignHtml+'
'+_lcdSection("mdi-card-account-details-outline","Identit\xE4t",[_lcdFieldEdit("Voller Name",sh.full_name,"full_name",sourcesByField.full_name),_lcdFieldEdit("Vorname",sh.first_name,"first_name",sourcesByField.first_name),_lcdFieldEdit("Nachname",sh.last_name,"last_name",sourcesByField.last_name),_lcdFieldEdit("Geschlecht",sh.gender,"gender",sourcesByField.gender),_lcdFieldEdit("Titel",sh.title,"title",sourcesByField.title),_lcdFieldEdit("Beruf / Rolle",sh.profession,"profession",sourcesByField.profession),_lcdFieldEdit("Auch bekannt als",sh.aliases,"aliases",sourcesByField.aliases)])+_lcdSection("mdi-account-outline","Erscheinung",[_lcdFieldEdit("K\xF6rperlich",sh.physical,"physical",sourcesByField.physical),_lcdFieldEdit("Kleidung & Aussehen",sh.clothing,"clothing",sourcesByField.clothing)])+_lcdSection("mdi-drama-masks","Pers\xF6nlichkeit",[_lcdFieldEdit("Eigenheiten & Verhalten",sh.mannerisms,"mannerisms",sourcesByField.mannerisms),_lcdFieldEdit("Stimme & Sprache",sh.voice_pattern,"voice_pattern",sourcesByField.voice_pattern)])+_lcdSection("mdi-book-open-outline","Geschichte",[_lcdFieldEdit("Hintergrund & Herkunft",sh.backstory,"backstory",sourcesByField.backstory),_lcdFieldEdit("Motivation",sh.motivation,"motivation",sourcesByField.motivation),_lcdFieldEdit("\xC4ngste",sh.fears,"fears",sourcesByField.fears)])+_lcdSection("mdi-sword","F\xE4higkeiten",[_lcdFieldEdit("Fertigkeiten",sh.skills,"skills",sourcesByField.skills),_lcdFieldEdit("Besondere F\xE4higkeiten",sh.capabilities,"capabilities",sourcesByField.capabilities),_lcdFieldEdit("St\xE4rkstes Attribut",sh.attribute_high,"attribute_high"),_lcdFieldEdit("Schw\xE4chstes Attribut",sh.attribute_low,"attribute_low")])+_lcdSectionFull("mdi-account-group-outline","Beziehungen",[_lcdFieldEdit("",sh.relationships,"relationships",sourcesByField.relationships),relDotsHtml])+_lcdSection("mdi-shield-sword-outline","Konflikt & Strategie",[_lcdFieldEdit("Konfliktstil",sh.conflict_style,"conflict_style",sourcesByField.conflict_style),_lcdFieldEdit("Siegbedingung",sh.win_condition,"win_condition",sourcesByField.win_condition)])+_lcdSection("mdi-eye-outline","Geheimnisse & Bogen",[_lcdFieldEdit("Dunkles Geheimnis / fataler Fehler",sh.secret,"secret"),_lcdFieldEdit("Charakterentwicklung",sh.arc_note,"arc_note")])+promptsHtml+"
"+sourcesHtml+(rec.analysis?'
'+escHtml(String(rec.analysis))+"
":"")+'
Charaktere \xB7 '+escHtml(rec.book||"")+"
"+sidebarHtml+"
",container.appendChild(pg),pg.querySelector(".lib-cpg-back").addEventListener("click",function(){goBack()});const _lcdUploadAvatar=function(){const inp=document.createElement("input");inp.type="file",inp.accept="image/*",inp.onchange=async function(){const file=inp.files[0];if(!file)return;const fr=new FileReader;fr.onload=async function(ev){typeof clSetImage=="function"&&await clSetImage(rec.id,ev.target.result),toast("Profilbild gespeichert","success"),rec.image=ev.target.result,_syncVoicePictureFromChar(rec);const av=pg.querySelector(".lcd-avatar-upload");av&&(av.innerHTML=''+escHtml(rec.name)+'')},fr.readAsDataURL(file)},inp.click()};pg.querySelector(".lcd-avatar-upload").addEventListener("click",_lcdUploadAvatar),(_a2=pg.querySelector(".lcd-avatar-upload-btn"))==null||_a2.addEventListener("click",function(e){e.stopPropagation(),_lcdUploadAvatar()}),(_b2=pg.querySelector(".lcd-avatar-online-btn"))==null||_b2.addEventListener("click",function(e){e.stopPropagation();const q=[rec.name,rec.book,sh.archetype,"character art"].filter(Boolean).join(" ");window.open("https://www.google.com/search?tbm=isch&q="+encodeURIComponent(q),"_blank","noopener")}),(_c2=pg.querySelector(".lcd-avatar-gen-btn"))==null||_c2.addEventListener("click",async function(e){e.stopPropagation();const btn=this,prompt=_libStr(sh.image_prompt).trim()||(typeof csBuildImagePrompt=="function"?csBuildImagePrompt(sh):"");if(!prompt){toast("No image prompt to work from \u2014 generate the Character Image Prompt below first","error");return}const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';try{const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt})});if(!r.ok)throw new Error((await r.json().catch(function(){return{}})).detail||r.statusText);const d=await r.json();typeof clSetImage=="function"&&await clSetImage(rec.id,d.image),rec.image=d.image,_syncVoicePictureFromChar(rec),toast("Profile picture generated","success"),_charDetailPage(rec,allChars,opts)}catch(err){toast("Image generation failed: "+(err.message||err),"error"),btn.disabled=!1,btn.innerHTML=orig}}),(_d2=pg.querySelector(".lcd-conceptart-gen"))==null||_d2.addEventListener("click",async function(e){e.stopPropagation();const btn=this;if(!_libStr(sh.concept_art_prompt).trim()){toast("No Concept Art Prompt yet \u2014 generate that first in Generation Prompts below","error");return}const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';try{await _charAutoGenerateConceptArt(rec),toast("Concept art generated","success"),_charDetailPage(rec,allChars,opts)}catch(err){toast("Concept art generation failed: "+(err.message||err),"error"),btn.disabled=!1,btn.innerHTML=orig}}),(_e2=pg.querySelector(".lcd-conceptart-img"))==null||_e2.addEventListener("click",function(){var _a3;(_a3=document.getElementById("conceptart-lightbox"))==null||_a3.remove();const ov=document.createElement("div");ov.id="conceptart-lightbox",ov.className="audiobook-overlay",ov.innerHTML='
'+escHtml(rec.name)+' \u2014 Konzeptbild
Concept art \u2014 '+escHtml(rec.name)+'
',document.body.appendChild(ov);const close=function(){ov.remove()};ov.querySelector("#calb-close").addEventListener("click",close),ov.addEventListener("click",function(e){e.target===ov&&close()})}),pg.querySelectorAll(".lib-cpg-sidebar-item").forEach(function(item){item.addEventListener("click",async function(){const target=(allChars||[]).find(function(c){return c.id===item.dataset.charId});target&&_charDetailPage(target,allChars,opts)})}),pg.querySelectorAll(".lcd-source-clickable").forEach(function(item){item.addEventListener("click",function(){const n=parseInt(item.dataset.page,10);isNaN(n)||_jumpToReaderPage(n)})}),(_f2=pg.querySelector(".lcd-pick-voice"))==null||_f2.addEventListener("click",async function(){_openVoicePicker(pg.querySelector(".lcd-voice-top"),rec,async function(){const all=await clGetAll().catch(()=>allChars),up=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(up,all.filter(function(r){return r.book===rec.book}),opts)})}),(_g2=pg.querySelector(".lcd-auto-voice"))==null||_g2.addEventListener("click",async function(){await _autoAssignVoice(rec);const all=await clGetAll().catch(()=>allChars),up=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(up,all.filter(function(r){return r.book===rec.book}),opts)}),(_h2=pg.querySelector(".lcd-online-voice"))==null||_h2.addEventListener("click",function(){_charSearchOnline(rec)}),(_i2=pg.querySelector(".lcd-gen-voice"))==null||_i2.addEventListener("click",function(){_charDesignVoiceInline(rec)}),(_j2=pg.querySelector(".lcd-clone-voice"))==null||_j2.addEventListener("click",function(){_charCloneVoice(rec)}),pg.querySelectorAll(".lcd-prompt-copy").forEach(function(btn){btn.addEventListener("click",async function(){var _a3;const box=btn.closest(".lcd-prompt-body"),text=((_a3=box==null?void 0:box.querySelector(".lcd-prompt-text"))==null?void 0:_a3.textContent.trim())||"";if(!text){toast("Nothing to copy yet \u2014 click Generate first","error");return}typeof copyText=="function"&&await copyText(text),toast("Prompt copied","success")})}),pg.querySelectorAll(".lcd-gen-prompt").forEach(function(btn){btn.addEventListener("click",async function(e){e.preventDefault();const key=btn.dataset.sheetKey,orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026';try{const sh2=rec.sheet||{},sample=[sh2.physical,sh2.backstory,sh2.motivation].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},r=await fetch("/api/character-generate-prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:rec.name,book:rec.book||"",sheet:sh2,language,llm_url:target.url,model:target.model,fields:[key]})});if(!r.ok)throw new Error((await r.json().catch(function(){return{}})).detail||r.statusText);const d=await r.json();if(!d[key])throw new Error("Empty response \u2014 try again");rec.sheet||(rec.sheet={}),rec.sheet[key]=d[key],rec.updated=new Date,typeof clPut=="function"&&await clPut(rec),toast("Prompt generated","success"),_charDetailPage(rec,allChars,opts)}catch(err){toast("Prompt generation failed: "+(err.message||err),"error"),btn.disabled=!1,btn.innerHTML=orig}})});const slider=pg.querySelector(".lcd-align-slider"),sliderVal=pg.querySelector(".lcd-align-slider-val"),arcEl=pg.querySelector(".lcd-align-arc");slider&&slider.addEventListener("input",async function(){const val=parseInt(slider.value,10);sliderVal&&(sliderVal.textContent=val+"/100"),arcEl&&(arcEl.textContent=arcInfo.ch+" "+arcInfo.label+(val>=70?" \xB7 Rechtschaffen ("+val+"/100)":val<=30?" \xB7 B\xF6se ("+val+"/100)":" \xB7 Moralisch ambivalent ("+val+"/100)")),arcEl&&(arcEl.style.color=arcInfo.color),rec.sheet.moral_alignment_score=val,rec.updated=new Date,typeof clPut=="function"&&await clPut(rec)});const _saveTimers=new Map;function _schedSave(key,value,isRecKey){clearTimeout(_saveTimers.get(key)),_saveTimers.set(key,setTimeout(async function(){isRecKey?rec[key]=value:(rec.sheet||(rec.sheet={}),rec.sheet[key]=value),rec.updated=new Date,typeof clPut=="function"&&await clPut(rec)},900))}pg.querySelectorAll("[contenteditable][data-sheet-key]").forEach(function(el){el.addEventListener("input",function(){_schedSave(el.dataset.sheetKey,el.textContent.trim(),!1)})}),pg.querySelectorAll("[contenteditable][data-rec-key]").forEach(function(el){el.addEventListener("input",function(){_schedSave(el.dataset.recKey,el.textContent.trim(),!0)})})}window._charDetailPage=_charDetailPage;function _charDetailModal(rec,allChars){const sh=rec.sheet||{},cov=libBookCover(rec.name),hue=_charHue(rec.name),tier=String(sh.tier||"").toLowerCase(),tierLabel=tier==="main"?"Hauptcharakter":tier==="supporting"?"Nebencharakter":tier==="minor"?"Nebenfigur":"",gender=_libStr(sh.gender),genderIcon=gender.toLowerCase().startsWith("f")?"mdi-gender-female":gender.toLowerCase().startsWith("m")?"mdi-gender-male":"mdi-gender-non-binary",score=sh.moral_alignment_score,pct=score!=null?Math.max(0,Math.min(100,score)):null,arc=sh.arc_direction||"neutral",arcMap={"good-to-bad":{ch:"\u2198",label:"Entwicklung zum B\xF6sen",color:"#ff7043"},"bad-to-good":{ch:"\u2197",label:"Wandel zum Guten",color:"#66bb6a"},complex:{ch:"\u2195",label:"Komplex / unvorhersehbar",color:"#ab47bc"},"stable-good":{ch:"\u2192",label:"Stabil gut",color:"#66bb6a"},"stable-bad":{ch:"\u2192",label:"Stabil b\xF6se",color:"#888"},neutral:{ch:"\u2192",label:"Neutral / stabil",color:"#aaa"}},arcInfo=arcMap[arc]||arcMap.neutral,voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",avatarHtml=rec.image?'
'+escHtml(rec.name)+'
':'
'+escHtml((rec.name||"?")[0].toUpperCase())+"
",alignHtml=pct!=null?'
B\xF6se
Gut
'+arcInfo.ch+" "+arcInfo.label+(pct>=70?" \xB7 Rechtschaffen ("+pct+"/100)":pct<=30?" \xB7 B\xF6se ("+pct+"/100)":" \xB7 Moralisch ambivalent ("+pct+"/100)")+"
"+(_libStr(sh.arc_note)?'
'+escHtml(_libStr(sh.arc_note))+"
":"")+(_libStr(sh.alignment)?'
'+escHtml(_libStr(sh.alignment))+"
":"")+"
":"",relText=_libStr(sh.relationships).toLowerCase(),relHits=(allChars||[]).filter(function(c){return c.id!==rec.id&&(c.name||"").length>1}).map(function(c){const re=new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"gi");return{c,n:(relText.match(re)||[]).length}}).filter(function(x){return x.n>0}).sort(function(a,b){return b.n-a.n}).slice(0,8),relDotsHtml=relHits.length?'
'+relHits.map(function(x){return''+escHtml((x.c.name||"?")[0].toUpperCase())+""}).join("")+"
":"",ov=document.createElement("div");ov.className="lib-char-detail-ov",ov.innerHTML='
'+avatarHtml+'
'+escHtml(rec.name)+"
"+(_libStr(sh.full_name)&&_libStr(sh.full_name).toLowerCase()!==String(rec.name||"").toLowerCase()?'
'+escHtml(_libStr(sh.full_name))+"
":"")+(_libStr(sh.title)?'
'+escHtml(_libStr(sh.title))+"
":"")+(_libStr(sh.aliases)?'
auch bekannt als '+escHtml(_libStr(sh.aliases))+"
":"")+(_libStr(sh.archetype)?'
'+escHtml(_libStr(sh.archetype))+"
":"")+'
'+(tierLabel?''+tierLabel+"":"")+(gender?' '+escHtml(gender)+"":"")+'
'+(voiceId?_voiceExists(voiceId)?escHtml(voiceId):' '+escHtml(voiceId)+"":'Noch keine Stimme zugewiesen')+'
'+alignHtml+'
'+_lcdSection("mdi-card-account-details-outline","Identit\xE4t",[_lcdField("Voller Name",sh.full_name,!0),_lcdField("Vorname",sh.first_name,!0),_lcdField("Nachname",sh.last_name,!0),_lcdField("Geschlecht",sh.gender,!0),_lcdField("Titel",sh.title,!0),_lcdField("Beruf / Rolle",sh.profession,!0),_lcdField("Auch bekannt als",sh.aliases,!0)])+_lcdSection("mdi-account-outline","Erscheinung",[_lcdField("K\xF6rperlich",sh.physical,!0),_lcdField("Kleidung & Aussehen",sh.clothing,!0)])+_lcdSection("mdi-drama-masks","Pers\xF6nlichkeit",[_lcdField("Eigenheiten & Verhalten",sh.mannerisms,!0),_lcdField("Stimme & Sprache",sh.voice_pattern,!0)])+_lcdSection("mdi-book-open-outline","Geschichte",[_lcdField("Hintergrund & Herkunft",sh.backstory,!0),_lcdField("Motivation",sh.motivation,!0),_lcdField("\xC4ngste",sh.fears,!0)])+_lcdSection("mdi-sword","F\xE4higkeiten",[_lcdField("Fertigkeiten",sh.skills,!0),_lcdField("Besondere F\xE4higkeiten",sh.capabilities,!0),_lcdField("St\xE4rkstes Attribut",sh.attribute_high,!1),_lcdField("Schw\xE4chstes Attribut",sh.attribute_low,!1)])+_lcdSectionFull("mdi-account-group-outline","Beziehungen",[_lcdField("",sh.relationships,!0),relDotsHtml])+_lcdSection("mdi-shield-sword-outline","Konflikt & Strategie",[_lcdField("Konfliktstil",sh.conflict_style,!0),_lcdField("Siegbedingung",sh.win_condition,!0)])+_lcdSection("mdi-eye-outline","Geheimnisse & Bogen",[_lcdField("Dunkles Geheimnis / fataler Fehler",sh.secret,!0),_lcdField("Charakterentwicklung",sh.arc_note,!0)])+"
"+_lcdSourcesHtml(sh.sources)+(rec.analysis?'
'+escHtml(String(rec.analysis))+"
":"")+"
",document.body.appendChild(ov);const close=function(){ov.remove()};ov.querySelector(".lcd-close-btn").addEventListener("click",close),ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector(".lcd-edit-btn").addEventListener("click",function(){close(),typeof clEdit=="function"&&clEdit(rec.id)}),ov.querySelector(".lcd-pick-voice").addEventListener("click",function(e){e.stopPropagation(),_openVoicePicker(e.currentTarget,rec,function(){close(),libraryRenderCharacters()})}),ov.querySelector(".lcd-auto-voice").addEventListener("click",async function(e){e.stopPropagation(),await _autoAssignVoice(rec),close(),libraryRenderCharacters()}),ov.querySelector(".lcd-online-voice").addEventListener("click",function(e){e.stopPropagation(),_charSearchOnline(rec)}),ov.querySelector(".lcd-gen-voice").addEventListener("click",function(e){e.stopPropagation(),_charDesignVoiceInline(rec)}),ov.querySelector(".lcd-avatar").addEventListener("click",function(){const inp=document.createElement("input");inp.type="file",inp.accept="image/*",inp.onchange=async function(){const file=inp.files[0];if(!file)return;const fr=new FileReader;fr.onload=async function(ev){typeof clSetImage=="function"&&await clSetImage(rec.id,ev.target.result),toast("Profile picture saved","success"),close(),libraryRenderCharacters()},fr.readAsDataURL(file)},inp.click()})}window._charDetailModal=_charDetailModal;function _openAvatarLightbox(rec,onSaved){var _a2;(_a2=document.getElementById("avatar-lightbox"))==null||_a2.remove();const sh=rec.sheet||{},currentPrompt=_libStr(sh.image_prompt).trim()||(typeof csBuildImagePrompt=="function"?csBuildImagePrompt(sh):""),ov=document.createElement("div");ov.id="avatar-lightbox",ov.className="audiobook-overlay",ov.innerHTML='
'+escHtml(rec.name)+' \u2014 Profilbild
'+(rec.image?''+escHtml(rec.name)+'':'
')+'
',document.body.appendChild(ov),ov.addEventListener("click",function(e){e.target===ov&&ov.remove()}),ov.querySelector("#alb-close").addEventListener("click",function(){ov.remove()});const setPreview=function(src){ov.querySelector(".alb-preview").innerHTML=''+escHtml(rec.name)+''},setStatus=function(msg,cls){const el=ov.querySelector("#alb-status");el.textContent=msg||"",el.className="llm-active-status"+(cls?" "+cls:"")},commitImage=async function(dataUri){typeof clSetImage=="function"&&await clSetImage(rec.id,dataUri),rec.image=dataUri,setPreview(dataUri),document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''+escHtml(rec.name)+''}),toast("Profilbild gespeichert","success"),_syncVoicePictureFromChar(rec),typeof onSaved=="function"&&onSaved()};ov.querySelector("#alb-file-input").addEventListener("change",function(){const file=this.files[0];if(!file)return;const fr=new FileReader;fr.onload=function(ev){commitImage(ev.target.result)},fr.readAsDataURL(file)}),ov.querySelector("#alb-url-btn").addEventListener("click",async function(){const url=ov.querySelector("#alb-url-input").value.trim();if(!url){toast("Bild-URL eingeben","error");return}const btn=this;btn.disabled=!0,setStatus("Wird heruntergeladen\u2026");try{const r=await fetch("/api/character-image-from-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url})}),d=await r.json();if(!r.ok)throw new Error(d.detail||r.statusText);await commitImage(d.image),setStatus("\u2713 Heruntergeladen","ok")}catch(e){setStatus("Fehlgeschlagen","err"),toast("Download fehlgeschlagen: "+e.message,"error")}finally{btn.disabled=!1}}),ov.querySelector("#alb-gen-btn").addEventListener("click",async function(){const prompt=ov.querySelector("#alb-prompt").value.trim();if(!prompt){toast("Prompt eingeben","error");return}const provider=ov.querySelector("#alb-provider").value,btn=this,orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generiere\u2026',setStatus("Generiere\u2026 (kann bei lokalen Modellen etwas dauern)");try{const body={prompt};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}),d=await r.json();if(!r.ok)throw new Error(d.detail||r.statusText);await commitImage(d.image),rec.sheet||(rec.sheet={}),rec.sheet.image_prompt!==prompt&&(rec.sheet.image_prompt=prompt,typeof clUpsert=="function"&&await clUpsert(rec.book,Object.assign({},rec.sheet,{name:rec.name}),rec.id)),setStatus("\u2713 Generiert","ok")}catch(e){setStatus("Fehlgeschlagen","err"),toast("Generierung fehlgeschlagen: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig}})}window._openAvatarLightbox=_openAvatarLightbox;async function _syncVoicePictureFromChar(rec){if(!rec||!rec.image||!rec.voice)return;const voiceId=typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice||"");if(voiceId)try{const existing=(window._voices||[]).find(function(v){return v.id===voiceId});if(existing&&existing.has_picture)return;const blob=await(await fetch(rec.image)).blob(),fd=new FormData;fd.append("voice_id",voiceId),fd.append("file",blob,"character.jpg"),await fetch("/api/voice/picture",{method:"POST",body:fd})}catch(e){console.warn("[voice picture sync]",e)}}window._syncVoicePictureFromChar=_syncVoicePictureFromChar;function _openVoicePicker(cardEl,rec,onDone){var _a2;document.querySelectorAll(".lib-voice-picker-popup").forEach(function(p){p.remove()});let voices=window._voices||[];const gender=String(((_a2=rec.sheet)==null?void 0:_a2.gender)||"").toLowerCase(),genderMatch=gender.startsWith("f")?"f":gender.startsWith("m")?"m":"",popup=document.createElement("div");popup.className="lib-voice-picker-popup",popup.innerHTML='
',popup.querySelector(".lib-vp-design-btn").addEventListener("click",function(){popup.remove(),typeof _charDesignVoiceInline=="function"&&_charDesignVoiceInline(rec)});function renderList(filter){let list=voices.filter(function(v){return v.enabled!==!1});if(filter){const f=filter.toLowerCase();list=list.filter(function(v){return(v.id||"").toLowerCase().includes(f)||(v.name||"").toLowerCase().includes(f)})}else genderMatch&&(list=list.filter(function(v){const vg=String(v.gender||"").toLowerCase();return vg.startsWith(genderMatch)||!vg}).concat(list.filter(function(v){const vg=String(v.gender||"").toLowerCase();return vg&&!vg.startsWith(genderMatch)})));const ul=popup.querySelector(".lib-vp-list");ul.innerHTML=list.slice(0,500).map(function(v){return'
'+escHtml(v.id||v.name||"")+(v.gender?' \xB7 '+escHtml(v.gender)+"":"")+"
"}).join("")+(list.length===0?'
No voices found
':""),ul.querySelectorAll(".lib-vp-item").forEach(function(item){item.addEventListener("click",async function(){const vid=item.dataset.vid;await clPut(Object.assign({},rec,{voice:vid,updated:new Date})),rec.voice=vid,_syncVoicePictureFromChar(rec),popup.remove(),onDone()})})}renderList(""),popup.querySelector(".lib-vp-input").addEventListener("input",function(e){renderList(e.target.value)}),voices.length===0&&typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().then(function(){popup.isConnected&&(voices=window._voices||[],renderList(popup.querySelector(".lib-vp-input").value||""))}).catch(function(){}),document.body.appendChild(popup);const rect=cardEl.getBoundingClientRect(),popupWidth=260;popup.style.position="fixed",popup.style.left=Math.max(8,Math.min(rect.left,window.innerWidth-popupWidth-8))+"px",popup.style.width=popupWidth+"px";const spaceBelow=window.innerHeight-rect.bottom;spaceBelow>300||spaceBelow>rect.top?popup.style.top=rect.bottom+4+"px":popup.style.bottom=window.innerHeight-rect.top+4+"px",setTimeout(function(){function close(e){popup.contains(e.target)||(popup.remove(),document.removeEventListener("click",close))}document.addEventListener("click",close)},0),popup.querySelector(".lib-vp-input").focus()}async function _findVoiceFromSameCharacterElsewhere(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey)return null;let all=[];try{all=await clGetAll()}catch{return null}const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null,match=all.find(function(r){if(r.id===rec.id||!r.voice||String(r.name||"").trim().toLowerCase()!==nameKey)return!1;const vId=typeof r.voice=="object"?r.voice.id:r.voice;return!(!_voiceExists(vId)||bookCode&&_voiceLangCode(vId)!==bookCode)});return match?{voiceId:typeof match.voice=="object"?match.voice.id:match.voice,book:match.book}:null}function _voiceLangCode(voiceId){const m=/^([A-Za-z]{2,3})_/.exec(String(voiceId||""));return m?m[1].toUpperCase():null}async function _findVoiceByCharacterName(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey||nameKey.length<3)return null;const hits=(window._voices||[]).filter(function(v){return v.enabled!==!1}).filter(function(v){return String(v.id||v.name||"").toLowerCase().includes(nameKey)});if(!hits.length)return null;const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null;if(bookCode){const langHits=hits.filter(function(v){return _voiceLangCode(v.id)===bookCode});return langHits.length?langHits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]:null}return hits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]}async function _autoAssignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}const named=await _findVoiceByCharacterName(rec);if(named){await clPut(Object.assign({},rec,{voice:named.id,updated:new Date})),rec.voice=named.id,_syncVoicePictureFromChar(rec),toast(named.id+" \u2192 "+rec.name+" (matching voice already in the library)","success");return}if(typeof _charAutoDesignVoice=="function"){await _charAutoDesignVoice(rec);return}toast("No matching voice found","error")}function _charLang(rec){const sh=rec.sheet||{},text=[sh.backstory,sh.voice_pattern,sh.mannerisms,sh.relationships,sh.motivation,sh.archetype].filter(Boolean).join(" ");return typeof detectLang=="function"?detectLang(text):""}const _bookProfileCache=new Map;async function _getBookProfile(book){const key=String(book||"").trim();if(!key)return{};if(_bookProfileCache.has(key))return _bookProfileCache.get(key);let profile={};try{const r=await fetch("/api/book-profile?book="+encodeURIComponent(key));r.ok&&(profile=await r.json())}catch(e){console.warn("[book profile]",e)}return _bookProfileCache.set(key,profile),profile}async function _saveBookProfile(book,profile){const key=String(book||"").trim(),r=await fetch("/api/book-profile",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Object.assign({book:key},profile))});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return _bookProfileCache.set(key,d.profile||profile),d.profile}const _bookLangCache=new Map;async function _resolveBookLang(rec){const book=rec.book||"",profile=await _getBookProfile(book);if(profile&&profile.language)return profile.language;const direct=_charLang(rec);if(direct)return direct;if(_bookLangCache.has(book))return _bookLangCache.get(book);let lang="";try{const siblings=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(book):[],counts={};siblings.forEach(function(s){const l=_charLang(s);l&&(counts[l]=(counts[l]||0)+1)});let best="",bestN=0;Object.keys(counts).forEach(function(l){counts[l]>bestN&&(best=l,bestN=counts[l])}),lang=best}catch(e){console.warn("[book lang]",e)}return _bookLangCache.set(book,lang),lang}const _VOICE_TEXTURE_POOL=["a warm, breathy timbre","a bright, clear timbre","a low, husky timbre","a crisp, silvery timbre","a soft, velvety timbre","a slightly nasal, reedy timbre","a rich, resonant timbre","a light, airy timbre"],_VOICE_PACE_POOL=["an unhurried, deliberate pace","a quick, energetic pace","a measured, even pace","a pace that quickens when excited or nervous"];function _hashPick(str,pool){let h=0;for(let i=0;i>>0;return pool[h%pool.length]}function _buildVoicePrompt(rec,profile,langName){const sh=rec.sheet||{},g=String(sh.gender||"").toLowerCase(),genderWord=g.startsWith("f")?"female":g.startsWith("m")?"male":"",bits=[],lang=String(langName||"").trim();lang&&lang.toLowerCase()!=="english"?bits.push("Speak with an authentic native "+lang+" accent \u2014 not American-accented, not an English speaker doing "+lang+"."):lang&&bits.push("English with a neutral British or international accent, explicitly not American/US-accented.");const settingBits=[profile&&profile.genre,profile&&profile.setting,profile&&profile.era].filter(Boolean);return settingBits.length&&bits.push("Setting: "+settingBits.join(", ")+"."),bits.push("A "+(genderWord?genderWord+" ":"")+"voice"+(sh.archetype?" for "+sh.archetype.toLowerCase():"")+","),bits.push("with "+_hashPick(rec.name||rec.id||"",_VOICE_TEXTURE_POOL)+" and "+_hashPick((rec.name||rec.id||"")+"_pace",_VOICE_PACE_POOL)+"."),sh.voice_pattern&&bits.push(sh.voice_pattern),sh.mannerisms&&bits.push("Mannerisms: "+sh.mannerisms),sh.physical&&bits.push(sh.physical),sh.alignment&&bits.push("Disposition: "+sh.alignment),bits.join(" ").slice(0,600)}function _selectLoose(sel,val){if(!sel||!val)return;const v=String(val).toLowerCase(),opt=[...sel.options].find(function(o){const ov=o.value.toLowerCase(),ot=o.textContent.toLowerCase();return ov===v||ot===v||ov.startsWith(v)||ot.startsWith(v)||v.startsWith(ov)});opt&&(sel.value=opt.value,sel.dispatchEvent(new Event("change")))}function _charSearchOnline(rec){typeof navTo=="function"&&navTo("s-studio");const lang=_charLang(rec);setTimeout(function(){const fishTab=document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');fishTab&&fishTab.click(),setTimeout(function(){const langSel=document.getElementById("fa-lang");langSel&&_selectLoose(langSel,lang);const search=document.getElementById("fa-search");search&&(search.value=rec.name,search.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:!0})))},120)},120),toast("Searching online voices for "+rec.name+(lang?" ("+lang+")":""),"info")}function _editBookProfile(book){_getBookProfile(book).then(function(profile){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Book context \u2014 '+escHtml(book)+`

Used in every voice design (and image) prompt for this book, so a fantasy story doesn't end up with 1920s-general portraits or English voices in a German book just because one character's own sheet was too sparse to tell.

',document.body.appendChild(ov);const close=function(){ov.remove()};ov.querySelector("#bctx-cancel").addEventListener("click",close),ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#bctx-save").addEventListener("click",async function(){const btn=this;btn.disabled=!0;try{await _saveBookProfile(book,{genre:ov.querySelector("#bctx-genre").value,setting:ov.querySelector("#bctx-setting").value,era:ov.querySelector("#bctx-era").value,language:ov.querySelector("#bctx-lang").value}),toast("Book context saved for "+book,"success"),close()}catch(e){toast("Failed to save: "+(e.message||e),"error"),btn.disabled=!1}})})}function _confirmVoiceReuse(rec,reuse){return new Promise(function(resolve){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Existing voice found for '+escHtml(rec.name)+'

"'+escHtml(reuse.voiceId)+'" is already used for '+escHtml(rec.name)+' in "'+escHtml(reuse.book)+'". Reuse it for series consistency, or design a brand-new voice just for this book?

',document.body.appendChild(ov);let audioEl=null;ov.querySelector("#cvr-play").addEventListener("click",async function(e){const btn=e.currentTarget,icon=btn.querySelector(".mdi");if(audioEl&&!audioEl.paused){audioEl.pause(),icon.className="mdi mdi-play";return}btn.disabled=!0,icon.className="mdi mdi-loading mdi-spin";try{const langHint=typeof _resolveBookLang=="function"?await _resolveBookLang(rec).catch(function(){return""}):"",text=typeof _charSampleTextFor=="function"&&_charSampleTextFor(rec,langHint)||"Hallo, ich bin "+rec.name+".",rv=(window._voices||[]).find(x=>x.id===reuse.voiceId),rBackend=rv&&(rv.origin==="designed"||!rv.has_ref)?"voice_design":"voice_clone",blob=await fetchTtsPreviewBlob(reuse.voiceId,text,"wav","",rBackend);audioEl||(audioEl=new Audio,audioEl.addEventListener("ended",function(){icon.className="mdi mdi-play"})),audioEl.src=URL.createObjectURL(blob),await audioEl.play(),icon.className="mdi mdi-pause"}catch(err){toast("Could not play sample: "+(err.message||err),"error"),icon.className="mdi mdi-play"}finally{btn.disabled=!1}});const cleanup=function(result){audioEl&&audioEl.pause(),ov.remove(),resolve(result)};ov.querySelector("#cvr-cancel").addEventListener("click",function(){cleanup("cancel")}),ov.querySelector("#cvr-use").addEventListener("click",function(){cleanup("use")}),ov.querySelector("#cvr-new").addEventListener("click",function(){cleanup("new")}),ov.addEventListener("click",function(e){e.target===ov&&cleanup("cancel")})})}async function _charDesignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){const choice=await _confirmVoiceReuse(rec,reuse);if(choice==="cancel")return;if(choice==="use"){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}}typeof navTo=="function"&&navTo("s-design");const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(sh.voice_design_prompt).trim();setTimeout(function(){_selectLoose(document.getElementById("design-gender"),sh.gender),_selectLoose(document.getElementById("design-language"),lang);const instruct=document.getElementById("design-instruct");instruct&&(instruct.value=savedPrompt||_buildVoicePrompt(rec,null,lang));const nm=document.getElementById("design-preset-name");nm&&(nm.value=rec.name)},140),toast("Voice design prepared for "+rec.name+(lang?" \xB7 "+lang:""),"info")}function _charDesignVoiceInline(rec){const sh=rec.sheet||{},lang=_charLang(rec),instruct=_libStr(sh.voice_design_prompt).trim()||_buildVoicePrompt(rec,null,lang),ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Voice design prompt \u2014 '+escHtml(rec.name)+'

Edit the description, then generate a new voice from it. This replaces '+(rec.voice?"the current voice":"this character\u2019s voice")+'.

',document.body.appendChild(ov);const close=function(){ov.remove()};ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#cdi-cancel").addEventListener("click",close),ov.querySelector("#cdi-generate").addEventListener("click",async function(e){const btn=e.currentTarget,text=ov.querySelector("#cdi-instruct").value.trim();if(!text){toast("Prompt is empty","error");return}btn.disabled=!0;const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-loading mdi-spin");try{await _charAutoDesignVoice(rec,!0,text),_schedulePendingTtsRestart(),close(),toast("New voice designed for "+rec.name,"success"),typeof libraryRenderCharacters=="function"&&libraryRenderCharacters(),typeof _libRefreshDetailModal=="function"&&_libRefreshDetailModal(rec)}catch(err){toast("Voice design failed: "+(err.message||err),"error"),btn.disabled=!1,icon&&(icon.className="mdi mdi-creation")}})}function _charCloneVoice(rec){typeof navTo=="function"&&navTo("s-clone"),setTimeout(function(){const nm=document.getElementById("clone-your-name");nm&&(nm.value=rec.name)},140),toast("Clone a Voice prepared for "+rec.name+" \u2014 pick a mic take, file, or YouTube URL","info")}const _DESIGN_SAMPLE_FALLBACK={German:"Ich habe lange auf diesen Moment gewartet, und jetzt, da er da ist, wei\xDF ich genau, was zu tun ist.",English:"I have waited a long time for this moment, and now that it is here, I know exactly what to do."};function _charRealLine(rec){const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook,nameLower=String(rec.name||"").trim().toLowerCase(),line=(ab&&ab.segments||[]).find(function(s){return s&&s.type==="dialogue"&&String(s.speaker||"").trim().toLowerCase()===nameLower&&s.text&&s.text.trim().length>=20&&s.text.trim().length<=200});if(line)return line.text.trim();const quotes=(Array.isArray(rec.sheet&&rec.sheet.sources)?rec.sheet.sources:[]).map(function(s){return s&&s.quote?String(s.quote).trim():""}).filter(function(q){return q.length>=20&&q.length<=240}),spoken=quotes.find(function(q){return/[""„"]/.test(q)});return spoken||(quotes.length?quotes[0]:null)}function _charSampleTextFor(rec,langNameHint){const lang=_charLang(rec)||langNameHint||"English",greeting=lang==="German"?`Hallo, ich bin ${rec.name}.`:`Hello, I am ${rec.name}.`,line=_charRealLine(rec);return line?`${greeting} ${line}`:_DESIGN_SAMPLE_FALLBACK[lang]||_DESIGN_SAMPLE_FALLBACK.English}const _GENERIC_NAME_GENDER={frau:"female",dame:"female",junge_frau:"female",m\u00E4dchen:"female",maedchen:"female",mann:"male",herr:"male",junge:"male",knabe:"male"};function _genderFromGenericName(name){const key=String(name||"").trim().toLowerCase().replace(/\s+/g,"_");return _GENERIC_NAME_GENDER[key]||""}let _voiceRestartPending=!1;async function _flushPendingTtsRestart(){if(_voiceRestartPending){_voiceRestartPending=!1;try{const r=await fetch("/api/tts/restart",{method:"POST"});r.ok?toast("TTS backend restarted to pick up the newly designed voice(s)","success"):console.warn("[tts restart] failed:",r.status)}catch(e){console.warn("[tts restart]",e)}}}let _voiceRestartDebounceTimer=null;function _schedulePendingTtsRestart(){clearTimeout(_voiceRestartDebounceTimer),_voiceRestartDebounceTimer=setTimeout(_flushPendingTtsRestart,4e3)}async function _fetchRetryingNetworkErrors(url,opts,tries){tries=tries||3;for(let i=1;i<=tries;i++)try{return await fetch(url,opts)}catch(e){if(i===tries)throw e;await new Promise(function(r){setTimeout(r,2500*i)})}}function _designBenchmarkWpmBad(b){if(!b||!b.ok||!b.audio_sec||!b.text)return!1;const wpm=String(b.text).trim().split(/\s+/).length/(b.audio_sec/60);return wpm<80||wpm>400}async function _charAutoDesignVoice(rec,force,instructOverride){const reuse=force||instructOverride?null:await _findVoiceFromSameCharacterElsewhere(rec);if(reuse&&reuse.voiceId!==rec.voice){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec);return}const sh=rec.sheet||{},langName=await _resolveBookLang(rec)||"English",instruct=instructOverride||_buildVoicePrompt(rec,await _getBookProfile(rec.book),langName);if(!instruct.trim())throw new Error("No character description to design a voice from yet");const langCode=typeof DESIGN_LANG_CODE!="undefined"&&DESIGN_LANG_CODE[langName]||"EN",genderWord=String(sh.gender||"").toLowerCase()||_genderFromGenericName(rec.name),genderLetter=genderWord.startsWith("f")?"F":genderWord.startsWith("m")?"M":"N",sampleText=_charSampleTextFor(rec,langName),dialogue=typeof isDialogueDesign=="function"?isDialogueDesign(instruct,sampleText,null):!1,baseName=typeof designSafeName=="function"?designSafeName(rec.name):(typeof _umlautSafe=="function"?_umlautSafe(rec.name||"VoiceDesign"):String(rec.name||"VoiceDesign")).replace(/[^A-Za-z0-9]+/g,"_"),voiceId=(langCode+"_"+genderLetter+"_"+baseName).slice(0,96),maxAttempts=3;let saved=null;for(let attempt=1;attempt<=maxAttempts;attempt++){const r1=await _fetchRetryingNetworkErrors("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({instruct,sample_text:sampleText,language:langName,gender:genderLetter,dialogue})});if(!r1.ok){const e=await r1.json().catch(function(){return{}});throw new Error(e.detail||r1.statusText)}const designed=await r1.json(),tryId=(voiceId+"__try"+attempt).slice(0,96),r2=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:tryId,transcript:sampleText})});if(!r2.ok){const e=await r2.json().catch(function(){return{}});throw new Error(e.detail||r2.statusText)}await r2.json();let bad=!1;if(typeof runVoiceBenchmark=="function")try{const d=await runVoiceBenchmark(tryId,{text:sampleText}),hit=(d&&d.voices||[]).find(function(x){return x.voice_id===tryId}),b=hit&&hit.benchmark;!b||!b.ok&&/connection (refused|reset|aborted)|max retries exceeded|newconnectionerror|econnrefused|timed? ?out/i.test(String(b.error||""))?console.warn("[voice design] benchmark unreachable, accepting unverified:",b&&b.error):bad=!!(b.clipped||_designBenchmarkWpmBad(b))}catch(e){console.warn("[voice benchmark]",e)}if(!bad){const r3=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:voiceId,transcript:sampleText})});if(!r3.ok){const e=await r3.json().catch(function(){return{}});throw new Error(e.detail||r3.statusText)}saved=await r3.json(),saved.needs_tts_restart&&(_voiceRestartPending=!0),await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){});break}if(await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){}),attempt===maxAttempts)throw new Error('Voice design for "'+voiceId+'" produced broken audio after '+maxAttempts+" attempts \u2014 left the previous voice in place, try again later")}return typeof saveMeta=="function"&&await saveMeta(saved.voice_id,{gender:genderLetter,flag:typeof LANG_FLAG_DEFAULT!="undefined"?LANG_FLAG_DEFAULT[langCode]:void 0,origin:"designed",group:rec.book||void 0,tag:rec.book||void 0,transcript:sampleText,note:"Voice Design: "+instruct.slice(0,240)}).catch(function(){}),rec.voice=saved.voice_id,await clUpsert(rec.book,Object.assign({},rec.sheet,{name:rec.name,voice:saved.voice_id}),rec.id),_syncVoicePictureFromChar(rec),saved.voice_id}async function _charAutoGenerateImage(rec,provider){const sh=rec.sheet||{},hasExplicitPrompt=!!_libStr(sh.image_prompt).trim();if(!hasExplicitPrompt&&[sh.archetype,sh.physical,sh.clothing].filter(Boolean).join(" ").trim().length<20)throw new Error("Not enough character detail to generate a meaningful portrait \u2014 skipped instead of using a generic placeholder");const prompt=hasExplicitPrompt?_libStr(sh.image_prompt).trim():typeof csBuildImagePrompt=="function"?csBuildImagePrompt(sh):"";if(!prompt)throw new Error("No image prompt to work from yet");const body={prompt};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return typeof clSetImage=="function"&&await clSetImage(rec.id,d.image),rec.image=d.image,document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''+escHtml(rec.name)+''}),_syncVoicePictureFromChar(rec),d.image}async function _charAutoGenerateConceptArt(rec,provider){const sh=rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim();if(!prompt)throw new Error("No concept art prompt to work from yet \u2014 generate the prompt first");const body={prompt};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json(),target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;return target.sheet=Object.assign({},target.sheet||{},{concept_art_image:d.image}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),sh.concept_art_image=d.image,rec.sheet=sh,d.image}window._charSearchOnline=_charSearchOnline,window._charDesignVoice=_charDesignVoice,window._charAutoDesignVoice=_charAutoDesignVoice,window._charAutoGenerateImage=_charAutoGenerateImage,window._charAutoGenerateConceptArt=_charAutoGenerateConceptArt,window.libraryRenderCharacters=libraryRenderCharacters;let _stuActive=1;const _stuHomes=new Map;function _stuBorrow(id,slotId){const el=document.getElementById(id),slot=document.getElementById(slotId);!el||!slot||(_stuHomes.has(id)||_stuHomes.set(id,{parent:el.parentNode,next:el.nextSibling}),slot.appendChild(el))}function _stuReturnAll(){_stuIsActive=!1,typeof _stuRestoreCastFoot=="function"&&_stuRestoreCastFoot(),_stuHomes.forEach(function(home,id){const el=document.getElementById(id);el&&home.parent&&home.parent.insertBefore(el,home.next)}),_stuHomes.clear()}window._stuReturnAll=_stuReturnAll;let _stuIsActive=!1,_stuAllowNextNav=!1;const _STU_BORROWED_FROM={"s-reader":1,"s-library":1,"s-rehearser":1};let _stuNavGuardInstalled=!1;function _stuInstallNavGuardOnce(){if(_stuNavGuardInstalled)return;_stuNavGuardInstalled=!0;const realNavTo=window.navTo;window.navTo=function(id){if(!(_stuIsActive&&!_stuAllowNextNav&&_STU_BORROWED_FROM[id]))return _stuAllowNextNav=!1,realNavTo(id)};const realShowReaderView=window.showReaderView;let _stuInShowReaderView=!1;window.showReaderView=function(view){const result=typeof realShowReaderView=="function"?realShowReaderView(view):void 0;if(_stuIsActive&&!_stuInShowReaderView&&(view==="cast"||view==="chars")){const target=view==="chars"?"sheets":"identify";if(_stuCastView!==target){_stuInShowReaderView=!0;try{_stuShowCastView(target)}finally{_stuInShowReaderView=!1}}}return result},document.querySelectorAll('[data-nav-section="s-reader"], #nav-reader-tree, [data-nav-section="s-library"], #nav-library-tree, [data-nav-section="s-rehearser"], #nav-rehearser-tree').forEach(function(el){el.addEventListener("click",function(){_stuAllowNextNav=!0},!0)})}function _stuCallSuppressingNav(fn){return fn()}function _stuEnterPhase(n){if(n===1)_stuBorrow("reader-main-view","stu-source-slot"),typeof window.readerOnShow=="function"&&_stuCallSuppressingNav(window.readerOnShow),_stuBorrow("lib-books-list","stu-books-slot"),_stuCallSuppressingNav(function(){typeof window.libraryRenderBooks=="function"&&window.libraryRenderBooks()});else if(n===2)_stuShowCastView(_stuCastView);else if(n===3)_stuBorrow("lib-chars-list","stu-voices-slot"),(async()=>{let title=null;if(window._audiobook&&window._audiobook.bookId)try{const r=await fetch("/api/reader/docs/"+encodeURIComponent(window._audiobook.bookId));r.ok&&(title=(await r.json()).title||null)}catch{}window._libCharsScrollToBook=title||window.readerState&&readerState.title||null,_stuCallSuppressingNav(function(){typeof window.libraryRender=="function"&&window.libraryRender("characters")})})();else if(n===4){_stuBorrow("reh-phase-3","stu-stage-slot"),_stuBorrow("reh-cast-list","stu-mecast-slot"),_stuCallSuppressingNav(async function(){if(window.rehState&&rehState.lines&&rehState.lines.length){typeof buildScriptPage=="function"&&buildScriptPage(),typeof showPhase=="function"&&showPhase(3),typeof highlightCurrentLine=="function"&&highlightCurrentLine();return}if(!(window._audiobook&&window._audiobook.segments&&window._audiobook.segments.length)&&typeof _abLoadDraftServer=="function"&&typeof _abBookId=="function"){const bookId=_abBookId(),draft=bookId?await _abLoadDraftServer(bookId):null;draft&&(_audiobook.segments=draft.segments||[],_audiobook.roster=draft.roster||[],_audiobook.pageMarks=draft.pageMarks||[],_audiobook.rehId=draft.rehId||_audiobook.rehId||null)}if(typeof window.audiobookOpenCurrentInRehearser=="function"&&(window._audiobook&&window._audiobook.segments||[]).length)return window.audiobookOpenCurrentInRehearser()});const rp3=document.getElementById("reh-phase-3");rp3&&(rp3.hidden=!1),_stuSyncModeToggle()}}function _stuSyncModeToggle(){const cb=document.getElementById("stu-mode-audiobook"),details=document.getElementById("stu-mecast-details");!cb||!window.rehState||(cb.checked=!rehState.skipDescriptions,details&&(details.hidden=cb.checked))}(_rc=document.getElementById("stu-mode-audiobook"))==null||_rc.addEventListener("change",function(){const audiobookMode=this.checked;if(window.rehState){rehState.skipDescriptions=!audiobookMode;const t=document.getElementById("reh-skip-desc-toggle");t&&(t.checked=rehState.skipDescriptions)}const details=document.getElementById("stu-mecast-details");details&&(details.hidden=audiobookMode)});let _stuCastView="identify";function _stuShowCastView(view){_stuCastView=view,document.querySelectorAll("#stu-cast-inner-tabs .stu-inner-tab").forEach(function(t){t.classList.toggle("active",t.dataset.stuCastView===view)});const identifySlot=document.getElementById("stu-cast-slot"),sheetsSlot=document.getElementById("stu-castchars-slot");if(identifySlot&&(identifySlot.hidden=view!=="identify"),sheetsSlot&&(sheetsSlot.hidden=view!=="sheets"),view==="identify")_stuBorrow("reader-audiobook-panel","stu-cast-slot"),_stuCallSuppressingNav(function(){if(typeof window.audiobookOpenCastView=="function")return window.audiobookOpenCastView();typeof window.showReaderView=="function"&&window.showReaderView("cast")}),_stuRelocateCastFoot();else if(view==="sheets"){_stuBorrow("reader-charsheets-panel","stu-castchars-slot");const panel=document.getElementById("reader-charsheets-panel");if(panel&&!panel.innerHTML.trim()){panel.innerHTML='
Character sheets

Optional \u2014 let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.

';const goBtn=document.getElementById("stu-goto-cast-menu");goBtn&&goBtn.addEventListener("click",function(){typeof window.csForReader=="function"&&window.csForReader()})}}}document.querySelectorAll("#stu-cast-inner-tabs .stu-inner-tab").forEach(function(tab){tab.addEventListener("click",function(){_stuShowCastView(tab.dataset.stuCastView)})});let _stuCastFootObserver=null;function _stuRelocateCastFoot(){if(_stuTryRelocateCastFoot(),_stuCastFootObserver)return;const slot=document.getElementById("stu-cast-slot");slot&&(_stuCastFootObserver=new MutationObserver(function(){_stuTryRelocateCastFoot()}),_stuCastFootObserver.observe(slot,{childList:!0,subtree:!0}))}function _stuTryRelocateCastFoot(){const slot=document.getElementById("stu-cast-slot"),tabs=document.getElementById("stu-cast-inner-tabs");if(!tabs)return;const freshFoot=slot?slot.querySelector("#ab-cv-foot"):null,alreadyRelocated=tabs.querySelector("#ab-cv-foot");if(!freshFoot&&!alreadyRelocated){document.querySelectorAll("#stu-cast-inner-tabs > .stu-inner-tab").forEach(function(t){t.hidden=!1});return}if(!freshFoot||freshFoot.parentElement===tabs)return;tabs.querySelectorAll("#ab-cv-foot").forEach(function(stale){stale.remove()}),freshFoot.style.borderTop="none",freshFoot.style.padding="0",freshFoot.style.justifyContent="flex-start",tabs.appendChild(freshFoot),document.querySelectorAll("#stu-cast-inner-tabs > .stu-inner-tab").forEach(function(t){t.hidden=!0});const openReh=freshFoot.querySelector("#ab-cv-open-reh");openReh&&(openReh.hidden=!0)}function _stuRestoreCastFoot(){_stuCastFootObserver&&(_stuCastFootObserver.disconnect(),_stuCastFootObserver=null);const tabs=document.getElementById("stu-cast-inner-tabs"),panel=document.getElementById("reader-audiobook-panel"),foot=tabs?tabs.querySelector("#ab-cv-foot"):null;if(foot&&panel){foot.style.borderTop="",foot.style.padding="",foot.style.justifyContent="";const openReh=foot.querySelector("#ab-cv-open-reh");openReh&&(openReh.hidden=!1),panel.appendChild(foot)}document.querySelectorAll("#stu-cast-inner-tabs > .stu-inner-tab").forEach(function(t){t.hidden=!1})}function showStudioPhase(n){_stuActive=n;for(let i=1;i<=4;i++){const el=document.getElementById("stu-phase-"+i);el&&(el.hidden=i!==n)}document.querySelectorAll(".stu-subtab").forEach(function(tab){tab.classList.toggle("active",parseInt(tab.dataset.stuPhase,10)===n)}),document.querySelectorAll("#nav-caststudio-tree [data-stu-phase]").forEach(function(item){item.classList.toggle("is-active",parseInt(item.dataset.stuPhase,10)===n)});const prevBtn=document.getElementById("stu-phase-prev"),nextBtn=document.getElementById("stu-phase-next");prevBtn&&(prevBtn.disabled=n<=1),nextBtn&&(nextBtn.disabled=n>=4),typeof _stuEnterPhase=="function"&&_stuEnterPhase(n)}window.showStudioPhase=showStudioPhase,document.querySelectorAll(".stu-subtab").forEach(function(tab){tab.addEventListener("click",function(){showStudioPhase(parseInt(tab.dataset.stuPhase,10))})}),(_sc=document.getElementById("stu-phase-prev"))==null||_sc.addEventListener("click",function(){_stuActive>1&&showStudioPhase(_stuActive-1)}),(_tc=document.getElementById("stu-phase-next"))==null||_tc.addEventListener("click",function(){_stuActive<4&&showStudioPhase(_stuActive+1)});function studioOnShow(){_stuInstallNavGuardOnce(),_stuIsActive=!0,showStudioPhase(_stuActive)}window.studioOnShow=studioOnShow; diff --git a/static/index.html b/static/index.html index ab0ccd5..47302db 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -157,6 +157,17 @@ -
- +
+ - - + +
`; @@ -1245,16 +1536,17 @@ ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv — arbeite wie ei FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS: - speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge. - type: 'narration' oder 'dialogue' -- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen). +- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' BEHÄLTST du die umschließenden Anführungszeichen als Teil des Texts (z.B. »Hallo!« bleibt »Hallo!«) — entferne sie NICHT und lasse niemals nur eines der beiden übrig. - emotion: Bei Dialogen 1-2 deutsche Wörter, die den Tonfall beschreiben (z.B. wütend, flüsternd, ängstlich). Bei Narration leer lassen (''). STRIKTE FORMAT- UND TEXTREGELN: -- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt."). +- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("»Nein«"), narration (", sagte sie, "), dialogue ("»halt.«"). - »Text?« und »Text!« sind vollständige Dialoge — das ?« bzw. !« schließt das Zitat ab, auch wenn es ungewohnt aussieht. - PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("flüsterte er", "sagte sie", "rief Uriens") oder normale Erzählerhandlung weitergeht. -- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. "Der Tod trägt rot. «"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text. +- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. "Der Tod trägt rot. «"), behandle den Satz davor als Dialog und hänge das schließende « an dessen Ende an, statt es als eigenes Segment stehen zu lassen — ein Anführungszeichen darf NIE ein eigenes Segment für sich bilden. - Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'. -- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!`; +- Anführungszeichen gehören IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schließende « am Ende einer Figurenrede gehört ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das öffnende » am Anfang einer Figurenrede gehört an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen », „, ‚ oder › beginnen oder enden, und NIE mit einem einzelnen «, ", ' oder ‹ enden oder beginnen — verschiebe das Zeichen ins richtige Nachbar-Segment. +- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!`; const normalizeCastingPrompt = (prompt) => { let p = prompt || AB_DEFAULT_PROMPT; @@ -1270,6 +1562,23 @@ STRIKTE FORMAT- UND TEXTREGELN: "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!", "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!" ); + // Policy flip: quote marks used to be stripped out of dialogue text + // entirely, which was landing as stray orphaned »/« characters on their + // own narrator-only segments when the model didn't fully comply. Keeping + // them attached to the dialogue text (as they already are in the source) + // sidesteps that failure mode instead of just asking more firmly. + p = p.replace( + "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen).", + "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' BEHÄLTST du die umschließenden Anführungszeichen als Teil des Texts (z.B. »Hallo!« bleibt »Hallo!«) — entferne sie NICHT und lasse niemals nur eines der beiden übrig." + ); + p = p.replace( + "- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.", + "- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und hänge das schließende « an dessen Ende an, statt es als eigenes Segment stehen zu lassen — ein Anführungszeichen darf NIE ein eigenes Segment für sich bilden." + ); + p = p.replace( + "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!", + "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!" + ); // Upgrade older saved prompts to the deduction-rule set (colon rule, // post-quote inquits, pronoun resolution, strict ping-pong, role names) // that cut down false "Unknown"/"Narrator" attributions. @@ -1323,6 +1632,15 @@ STRIKTE FORMAT- UND TEXTREGELN: p += `\n${newRules}`; } } + // Quote marks belong to the dialogue segment, never the adjacent + // narration segment — fixes a stray »/« ending up as its own orphaned + // "NARRATOR" row right before/after a real dialogue turn. + if (!/gehören IMMER zum Dialog-Segment/.test(p)) { + p = p.replace( + "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!", + "- Anführungszeichen gehören IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schließende « am Ende einer Figurenrede gehört ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das öffnende » am Anfang einer Figurenrede gehört an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen », „, ‚ oder › beginnen oder enden, und NIE mit einem einzelnen «, \", ' oder ‹ enden oder beginnen — verschiebe das Zeichen ins richtige Nachbar-Segment.\n- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!" + ); + } return p; }; @@ -1550,8 +1868,12 @@ STRIKTE FORMAT- UND TEXTREGELN: recastUnkBtn.style.display = 'inline-block'; recastBtn.style.display = 'inline-block'; recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown)); - recastBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast)); - panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to define characters.'; + recastBtn.addEventListener('click', async () => { + const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true }); + if (!ok) return; + applyPromptAndRun(audiobookCast); + }); + panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to identify characters.'; } else { const castBtn = panel.querySelector('#ab-cv-start-cast'); castBtn.style.display = 'inline-block'; @@ -1599,7 +1921,7 @@ STRIKTE FORMAT- UND TEXTREGELN: const img = recordForName(name)?.image; const c = color || colorFor(name); return img - ? `` + ? `` : `${escHtml((name || '?')[0].toUpperCase())}`; }; const setCharacterColor = (name, color) => { @@ -1748,7 +2070,7 @@ STRIKTE FORMAT- UND TEXTREGELN: _abNavIdx = ((_abNavIdx + dir) + rows.length) % rows.length; feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus')); rows[_abNavIdx].classList.add('ab-char-focus'); - rows[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' }); + _abScrollIntoView(rows[_abNavIdx], { block: 'center' }); const pos = _abBar.querySelector('.ab-char-bar-pos'); if (pos) pos.textContent = (_abNavIdx + 1) + ' / ' + rows.length; }; @@ -1798,8 +2120,8 @@ STRIKTE FORMAT- UND TEXTREGELN: _abDetailEl = detail; if (!rec) { - detail.innerHTML = '' - + '
Kein Charakterblatt – zuerst „Cast Characters" ausführen.
'; + detail.innerHTML = '' + + '
Kein Charakterblatt – zuerst „Cast all character roles“ ausführen.
'; feedWrap.appendChild(detail); detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile); return; @@ -1812,6 +2134,7 @@ STRIKTE FORMAT- UND TEXTREGELN: const tier = String(sh.tier || '').toLowerCase(); const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur'; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; + const lineCount = _abStr(sh.line_count); const pct = rec.sheet?.moral_alignment_score != null ? Math.max(0, Math.min(100, rec.sheet.moral_alignment_score)) : null; const arcMap = { 'good-to-bad':'↘ Entwicklung zum Bösen','bad-to-good':'↗ Wandel zum Guten','complex':'↕ Komplex','stable-good':'→ Stabil gut','stable-bad':'→ Stabil böse','neutral':'→ Neutral' }; const avatarHtml = rec.image @@ -1838,14 +2161,12 @@ STRIKTE FORMAT- UND TEXTREGELN: ${avatarHtml}
${escHtml(rec.name)}
- ${_abStr(sh.full_name) && _abStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? `
${escHtml(_abStr(sh.full_name))}
` : ''} ${_abStr(sh.title) ? `
${escHtml(_abStr(sh.title))}
` : ''} - ${_abStr(sh.aliases) ? `
auch bekannt als ${escHtml(_abStr(sh.aliases))}
` : ''} ${_abStr(sh.archetype) ? `
${escHtml(_abStr(sh.archetype))}
` : ''} -
${tierLabel}
+
${tierLabel}${lineCount ? `${escHtml(lineCount)} lines` : ''}
- +
@@ -1858,7 +2179,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
${pct != null ? `
Böse
Gut
${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)
` : ''}
-
${field('Voller Name',sh.full_name,'full_name')}${field('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Geschlecht',sh.gender,'gender')}${field('Titel',sh.title,'title')}${field('Beruf / Rolle',sh.profession,'profession')}${field('Auch bekannt als',sh.aliases,'aliases')}
+
${field('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Geschlecht',sh.gender,'gender')}${field('Titel',sh.title,'title')}${field('Beruf / Rolle',sh.profession,'profession')}${field('Auch bekannt als',sh.aliases,'aliases')}
${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}
${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}
${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}
@@ -1908,7 +2229,12 @@ STRIKTE FORMAT- UND TEXTREGELN: }); // Pencil edit toggle - let _saveTimer = null; + // One timer PER FIELD, not a single shared one — a shared timer meant + // editing field A then switching to field B within 900ms cancelled A's + // still-pending save with nothing to replace it, silently dropping that + // edit (same bug already fixed once this session in the Library's own + // detail page — this is the casting view's separate copy of it). + const _saveTimers = new Map(); const editBtn = detail.querySelector('.ab-cd-edit-btn'); editBtn?.addEventListener('click', function () { const editing = detail.classList.toggle('ab-cd-editing'); @@ -1918,13 +2244,14 @@ STRIKTE FORMAT- UND TEXTREGELN: el.contentEditable = editing ? 'true' : 'false'; if (editing) { el.addEventListener('input', function onInput() { - clearTimeout(_saveTimer); - _saveTimer = setTimeout(async () => { + const sk = el.dataset.sk; + clearTimeout(_saveTimers.get(sk)); + _saveTimers.set(sk, setTimeout(async () => { if (!rec.sheet) rec.sheet = {}; - rec.sheet[el.dataset.sk] = el.textContent.trim(); - if (['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(el.dataset.sk)) registerCharacterRecord(rec); + rec.sheet[sk] = el.textContent.trim(); + if (['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(sk)) registerCharacterRecord(rec); if (typeof clPut === 'function') await clPut(rec); - }, 900); + }, 900)); }); } }); @@ -1940,7 +2267,7 @@ STRIKTE FORMAT- UND TEXTREGELN: setTimeout(() => { const pages = window.readerState?.pages; if (pages && pages.length >= pg && pages[pg-1]?.pageDiv) { - pages[pg-1].pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); + _abScrollIntoView(pages[pg-1].pageDiv, { block: 'start' }); } else if (typeof toast === 'function') { toast('Buch im „Vorlesen"-Bereich öffnen, dann nochmal klicken', 'info'); } @@ -1984,7 +2311,7 @@ STRIKTE FORMAT- UND TEXTREGELN: _abCharRows.forEach(r => r.classList.add('ab-char-hl')); if (_abCharRows.length) { _abCharRows[0].classList.add('ab-char-focus'); - _abCharRows[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + _abScrollIntoView(_abCharRows[0], { block: 'center' }); } _abBar.querySelector('.ab-char-bar-prev').addEventListener('click', () => _abNav(-1)); @@ -2007,7 +2334,7 @@ STRIKTE FORMAT- UND TEXTREGELN: if (jump) _abNavIdx = (_abNavIdx + 1) % pool.length; else _abNavIdx = 0; pool[_abNavIdx].classList.add('ab-char-focus'); - pool[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' }); + _abScrollIntoView(pool[_abNavIdx], { block: 'center' }); _abBar.querySelector('.ab-char-bar-pos').textContent = (_abNavIdx + 1) + ' / ' + pool.length; } else { _abBar.querySelector('.ab-char-bar-pos').textContent = '0 Treffer'; @@ -2023,7 +2350,15 @@ STRIKTE FORMAT- UND TEXTREGELN: const q = _abRosterFilter.trim().toLowerCase(); let items = [...roster.entries()].filter(([n, info]) => info.count > 0 && (!q || n.toLowerCase().includes(q))); items.sort(_abRosterSort === 'alpha' ? (a, b) => a[0].localeCompare(b[0]) : (a, b) => b[1].count - a[1].count); - if (!items.length) { chars.innerHTML = `${q ? 'No matches' : 'reading…'}`; return; } + if (!items.length) { + // A bare "reading…" label reads as static text, not as "still working" — + // reuse the same skeleton rows shown on initial panel load so a live cast + // with no characters found yet visibly looks like it's loading. + chars.innerHTML = q ? `No matches` : `
${ + [38,62,45,28,54,35].map(w => `
`).join('') + }
`; + return; + } const prev = _abBar.hidden ? null : _abBar.dataset.charName; chars.innerHTML = items.map(([n, info]) => { const color = info.color || colorFor(n); @@ -2104,24 +2439,25 @@ STRIKTE FORMAT- UND TEXTREGELN: const mergeTarget = otherByLower.get(alias.toLowerCase()); _abCloseAliasPopup(); if (mergeTarget) { - // Merging rewrites every matching segment (fast) then has to rebuild - // the whole feed DOM — on a big book (thousands of rows, each running - // highlightText's regex pass) that redraw alone blocks the main - // thread long enough to trigger the browser's own "Page Unresponsive" - // dialog, not just look a bit slow. A spinner overlay by itself can't - // fix that — it would freeze right along with everything else, since - // it's all one synchronous JS turn. The redraw has to actually be - // chunked across animation frames so the browser can keep painting, - // which is also what makes a real (not fake) progress bar possible. + // Merging rewrites every matching segment (fast, plain array loop) — + // only THOSE rows need to be redrawn (a name change/recolor), not the + // whole feed. Rebuilding all thousands of rows for a 2-line merge is + // what used to make even a tiny merge take as long as opening a + // freshly cast book, and freeze the tab along the way. The affected + // rows are scattered across the feed rather than contiguous, so + // they're patched in place (_abPatchScatteredRows) instead of a full + // redraw, still chunked across animation frames for the rare case of + // a merge touching hundreds of lines. const busy = _abShowBusyOverlay(`Merging "${mergeTarget}" into ${name}…`, true); await new Promise(r => requestAnimationFrame(r)); try { const book = window.readerState?.title || ''; const rec = await clUpsert(book, { name, aliases: alias }); if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; } - const { changed: n, arr } = _abMergeCharacters(mergeTarget, name); + const { changed: n, segs } = _abMergeCharacters(mergeTarget, name); if (n) { - await _abRedrawSegmentsChunked(arr, (done, total) => busy.setProgress(done, total)); + const patched = await _abPatchScatteredRows(segs, (done, total) => busy.setProgress(done, total)); + if (!patched) await _abRedrawSegmentsChunked(_abActiveSegments().arr, (done, total) => busy.setProgress(done, total)); _abPersistManualEdit(); } toast(n ? `Merged "${mergeTarget}" into ${name} (${n} line${n !== 1 ? 's' : ''})` : `"${alias}" added as an alias for ${name}`, 'success'); @@ -2169,16 +2505,18 @@ STRIKTE FORMAT- UND TEXTREGELN: // visible progress instead of blocking the main thread outright. function _abMergeCharacters(fromName, intoName) { const active = _abActiveSegments(); - if (!active.arr.length) return { changed: 0, arr: active.arr }; + if (!active.arr.length) return { changed: 0, arr: active.arr, segs: [] }; _abPushEditState(active.key, active.arr); let changed = 0; + const segs = []; for (const s of active.arr) { if (s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() === fromName.toLowerCase()) { s.speaker = intoName; changed++; + segs.push(s); } } - return { changed, arr: active.arr }; + return { changed, arr: active.arr, segs }; } (async () => { @@ -2228,13 +2566,23 @@ STRIKTE FORMAT- UND TEXTREGELN: } const trim = () => { if (_userScrolled) return; // don't touch the feed while user is reading/editing - let rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); - while (rows.length > MAXROWS) { - const first = rows[0]; - const page = first.closest('.ab-cv-page'); - first.remove(); - if (page && !page.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) page.remove(); - rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); + // MAXROWS is permanently Infinity (see above — the full cast is kept for + // review), so the removal loop below can never actually run. It still + // used to query the ENTIRE feed (`querySelectorAll` over every row/note/ + // divider in the whole book so far) on every single call regardless — + // confirmed live as a real O(n) cost repeated on every ~80-segment + // batch for the whole rest of the book, compounding into the kind of + // quadratic slowdown that crashed the tab partway through a long book. + // Only do that work at all if MAXROWS could ever actually be finite. + if (Number.isFinite(MAXROWS)) { + let rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); + while (rows.length > MAXROWS) { + const first = rows[0]; + const page = first.closest('.ab-cv-page'); + first.remove(); + if (page && !page.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) page.remove(); + rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); + } } _scrollToBottom(); }; @@ -2258,7 +2606,7 @@ STRIKTE FORMAT- UND TEXTREGELN: const pageDiv = window.readerState?.pages?.[pg - 1]?.pageDiv; if (pageDiv) { if (typeof window.readerRenderPage === 'function') await window.readerRenderPage(pg - 1); - pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); + _abScrollIntoView(pageDiv, { block: 'start', inline: 'nearest' }); } else if (typeof toast === 'function') toast('Source page is not loaded in Reader yet', 'info'); }, 250); @@ -2326,7 +2674,7 @@ STRIKTE FORMAT- UND TEXTREGELN: _userScrolled = true; if (jumpBtn) jumpBtn.hidden = false; const firstPage = label?.closest('.ab-cv-page') || feed.firstElementChild; - if (firstPage) firstPage.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (firstPage) _abScrollIntoView(firstPage, { block: 'start' }); else feed.scrollTo({ top: 0, behavior: 'smooth' }); _abSetCurrentPage(pg); return; @@ -2338,7 +2686,7 @@ STRIKTE FORMAT- UND TEXTREGELN: } _userScrolled = true; if (jumpBtn) jumpBtn.hidden = false; - label.closest('.ab-cv-page')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + _abScrollIntoView(label.closest('.ab-cv-page'), { block: 'start' }); _abSetCurrentPage(pg); }; const _abStepPage = (dir) => { @@ -2444,6 +2792,58 @@ STRIKTE FORMAT- UND TEXTREGELN: // delegation on `feed` instead (see _abStartRowEdit and its callers below). return row; }; + // Like _abPatchRowRange, but for segments scattered across the feed instead + // of one contiguous block (e.g. every line a merged character spoke, which + // can be anywhere across a thousand-segment book). _abPatchRowRange inserts + // all replacements before the FIRST old row, which would bunch scattered + // rows together in the wrong place — this replaces each row in-place + // instead, so a merge only touches the handful of rows that actually + // changed rather than redrawing the entire feed. + const _abPatchScatteredRows = (segs, onProgress) => new Promise(resolve => { + if (!segs || !segs.length) { resolve(true); return; } + const bySeg = new Set(segs); + const targets = [...feed.querySelectorAll('.ab-cv-row')].filter(r => r.__seg && bySeg.has(r.__seg)); + if (!targets.length) { resolve(false); return; } + let i = 0; + const step = () => { + const end = Math.min(i + 150, targets.length); + for (; i < end; i++) targets[i].replaceWith(_abRowFromSegment(targets[i].__seg)); + if (onProgress) onProgress(i, targets.length); + if (i < targets.length) { + requestAnimationFrame(step); + } else { + _abRecountRoster(_abActiveSegments().arr); + _abClearHL(); + _abUpdatePageNav(); + resolve(true); + } + }; + requestAnimationFrame(step); + }); + const _abPatchRowRange = (oldRows, newSegs, activeArr) => { + const rows = (oldRows || []).filter(Boolean); + if (!rows.length) return false; + const parent = rows[0].parentNode; + if (!parent || rows.some(r => r.parentNode !== parent)) return false; + const beforeCounts = _abRosterCountsFromSegs(rows.map(r => r.__seg).filter(Boolean)); + const afterCounts = _abRosterCountsFromSegs(newSegs || []); + const frag = document.createDocumentFragment(); + for (const seg of newSegs || []) frag.appendChild(_abRowFromSegment(seg)); + parent.insertBefore(frag, rows[0]); + for (const row of rows) row.remove(); + _abApplyRosterDelta(beforeCounts, afterCounts); + _abQueueRosterRender(); + _abClearHL(); + _abUpdatePageNav(); + return true; + }; + const _abAdjacentRow = (row, dir) => { + let cur = dir < 0 ? row?.previousElementSibling : row?.nextElementSibling; + while (cur && !(cur.classList && cur.classList.contains('ab-cv-row'))) { + cur = dir < 0 ? cur.previousElementSibling : cur.nextElementSibling; + } + return cur || null; + }; // Swap a row's text for a textarea (edit button click or double-click), // delegated from a single feed-level listener instead of per-row ones. const _abStartRowEdit = (row) => { @@ -2475,16 +2875,50 @@ STRIKTE FORMAT- UND TEXTREGELN: }); ta.addEventListener('blur', () => commit(true)); }; - const _abRecountRoster = (segs) => { - for (const info of roster.values()) info.count = 0; + const _abRosterCountsFromSegs = (segs) => { + const counts = new Map(); for (const s of segs || []) { const { speakerName } = _abRowSpeaker(s || {}); - const c = colorFor(speakerName); - if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c }); - roster.get(speakerName).count++; + counts.set(speakerName, (counts.get(speakerName) || 0) + 1); } - _abSyncRosterList(segs || []); - renderRoster(); + return counts; + }; + const _abApplyRosterCounts = (counts) => { + for (const info of roster.values()) info.count = 0; + for (const [name, count] of counts || []) { + const info = roster.get(name) || { count: 0, color: colorFor(name) }; + info.count = count; + roster.set(name, info); + } + _audiobook.roster = [...roster.entries()] + .filter(([n, info]) => info.count > 0 && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n)) + .map(([n]) => n); + }; + const _abApplyRosterDelta = (beforeCounts, afterCounts) => { + const names = new Set([...(beforeCounts || new Map()).keys(), ...(afterCounts || new Map()).keys()]); + for (const name of names) { + const before = beforeCounts?.get(name) || 0; + const after = afterCounts?.get(name) || 0; + if (before === after) continue; + const info = roster.get(name) || { count: 0, color: colorFor(name) }; + info.count = Math.max(0, (info.count || 0) + (after - before)); + roster.set(name, info); + } + _audiobook.roster = [...roster.entries()] + .filter(([n, info]) => info.count > 0 && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n)) + .map(([n]) => n); + }; + let _abRosterRenderRaf = 0; + const _abQueueRosterRender = () => { + if (_abRosterRenderRaf) return; + _abRosterRenderRaf = requestAnimationFrame(() => { + _abRosterRenderRaf = 0; + renderRoster(); + }); + }; + const _abRecountRoster = (segs) => { + _abApplyRosterCounts(_abRosterCountsFromSegs(segs || [])); + _abQueueRosterRender(); }; const _abRedrawSegments = (segs) => { const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : ''; @@ -2518,13 +2952,27 @@ STRIKTE FORMAT- UND TEXTREGELN: // Unresponsive" warning. Used by operations that redraw the WHOLE feed at // once (character merge); the normal single-segment edits stay on the // plain synchronous path since those are cheap regardless. + // Two overlapping calls used to both survive: each one's own closured + // `i`/`list` kept its rAF chain running independently of the OTHER call's + // `feed.innerHTML = ''`, so a second (perfectly legitimate, non-recursive) + // trigger — e.g. Studio re-entering the Characters phase while an earlier + // restore's rAF batches hadn't finished yet — raced the first and both + // ended up appending their own full set of rows into the same feed. + // Confirmed live: every row duplicated exactly once (same segment object, + // two DOM nodes), which is exactly what two full, uncoordinated passes + // over the same segment list would produce. A generation token lets each + // in-flight chain notice it's been superseded and stop appending instead + // of racing the newer one. + let _abRedrawGen = 0; const _abRedrawSegmentsChunked = (segs, onProgress, batchSize = 150) => new Promise(resolve => { const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : ''; + const myGen = ++_abRedrawGen; feed.innerHTML = ''; _abCurPage = null; const list = segs || []; let i = 0, lastPage = null; const step = () => { + if (myGen !== _abRedrawGen) { resolve(); return; } // superseded by a newer redraw — stop const end = Math.min(i + batchSize, list.length); for (; i < end; i++) { const s = list[i]; @@ -2796,12 +3244,18 @@ STRIKTE FORMAT- UND TEXTREGELN: toast('No adjacent segment to merge', 'info'); return; } + const leftRow = dir < 0 ? _abAdjacentRow(row, -1) : row; + const rightRow = dir < 0 ? row : _abAdjacentRow(row, 1); _abPushEditState(active.key, active.arr); const merged = _abMergedSegment(active.arr[leftIdx], active.arr[rightIdx]); active.arr.splice(leftIdx, 2, merged); if (active.key === 'segments') _audiobook.segments = active.arr; else _audiobook.liveSegments = active.arr; - _abRedrawSegments(active.arr); + if (!leftRow || !rightRow || leftRow.parentNode !== rightRow.parentNode) { + _abRedrawSegmentsChunked(active.arr); + } else if (!_abPatchRowRange([leftRow, rightRow], [merged], active.arr)) { + _abRedrawSegmentsChunked(active.arr); + } _abPersistManualEdit(); toast('Segments merged', 'success'); }; @@ -3014,7 +3468,7 @@ STRIKTE FORMAT- UND TEXTREGELN: if (globalIdx !== -1) { arr.splice(globalIdx, 1, ...newSegs); - _abRedrawSegments(arr); + if (!_abPatchRowRange([row], newSegs, arr)) _abRedrawSegments(arr); _abPersistManualEdit(); } else { // DOM-only split — will be reconciled once casting finishes @@ -3071,7 +3525,7 @@ STRIKTE FORMAT- UND TEXTREGELN: let target = rows.find(r => (r.offsetTop - feed.offsetTop) > currentY + 10); if (!target) target = rows[0]; // loop around to the top - target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + _abScrollIntoView(target, { block: 'center' }); // Highlight briefly target.style.transition = 'background 0.3s'; @@ -3092,7 +3546,10 @@ STRIKTE FORMAT- UND TEXTREGELN: this.clearProcessing(); feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); - _abRedrawSegments(segs || []); + // Chunked — this runs on every draft restore of a previously-cast book, + // so a large cast (thousands of rows, each running highlightText's regex + // pass) must not block the main thread synchronously (see complete()). + _abRedrawSegmentsChunked(segs || []); }, update(done) { const castBar = panel.querySelector('.ab-castpanel-bar'); @@ -3286,7 +3743,12 @@ STRIKTE FORMAT- UND TEXTREGELN: const completedSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length ? _audiobook.segments : (typeof allSegments !== 'undefined' && allSegments.length ? allSegments : null); - if (completedSegments) _abRedrawSegments(completedSegments); + // Chunked (not the plain synchronous redraw) — reopening a big already-cast + // book means thousands of rows each running highlightText's regex pass, + // which on the synchronous path blocks the main thread long enough to + // trigger the browser's "Page Unresponsive" warning and reads to the user + // as if casting silently restarted on its own. + if (completedSegments) _abRedrawSegmentsChunked(completedSegments); const cancelBtn = panel.querySelector('#ab-cv-cancel'); if (cancelBtn) cancelBtn.hidden = true; const foot = panel.querySelector('#ab-cv-foot'); @@ -3311,66 +3773,178 @@ STRIKTE FORMAT- UND TEXTREGELN: const continueBtnHtml = resumable ? `` : ''; - foot.innerHTML = `${continueBtnHtml}`; + // Three grouped flyout buttons instead of eight flat ones — Identify + // (speaker attribution passes), Cast Characters (sheet generation), + // Cast (view/export) — plus Continue casting when resumable and Open + // Script Rehearser stay as direct one-click actions since they're the + // most common next step, not variants of each other. + foot.innerHTML = `${continueBtnHtml}`; const runVerificationPass = () => { - const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern. + // Deliberately NOT a copy of the first-pass attribution prompt. Pass 1 + // classifies from scratch; this pass receives a segment that ALREADY + // has a label (a character's name, or Narrator) and has to judge + // whether that label actually holds up — a plausibility/confidence + // check rather than fresh attribution. It also runs on every Narrator + // segment (not just Unknown dialogue), specifically to catch spoken + // lines that got swallowed into narration on the first pass. + const verificationPrompt = `Du bist ein Qualitätsprüfer für die Sprecherzuordnung eines bereits analysierten deutschen Hörbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT — du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters gehört, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer könnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erzähltext hineingezogen?". -AUFGABE (2. Qualitätslauf): -1. LÖSE 'Unknown'-Segmente auf: Nutze umgebenden Kontext, Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, Reihenfolge der Sprecher, Ping-Pong-Wechsel in Dialogen und bekannte Figuren. -2. KORRIGIERE ein Segment zu 'Narrator' nur dann, wenn es eindeutig Erzählertext, Handlung, Beschreibung oder ein Sprecher-Tag ist. -3. BEHALTE vorhandene klare Sprecher-Zuweisungen im Kontext bei. Nutze sie als Anker für die Unknown-Zeilen. -4. 'Unknown' ist NUR erlaubt, wenn der Sprecher trotz Kontext absolut nicht bestimmbar ist. +WORAUF DU PRÜFST — IN DIESER REIHENFOLGE (höchste Priorität zuerst): +1. ZUERST alle 'Unknown'-Zeilen: Löse den Sprecher über Kontext auf (Inquit-Formeln, Ping-Pong-Wechsel in Zwiegesprächen, Adressaten-Bezug, zuletzt genannte Person). 'Unknown' bleibt nur, wenn wirklich keine Ableitung möglich ist. Das ist die dringendste Kategorie — eine Zeile ohne jeden Sprecher ist schlimmer als eine falsch zugeordnete. +2. DANACH Zeilenketten mit demselben Sprecher in Folge (2 oder mehr aufeinanderfolgende Zeilen — egal ob 'Narrator' oder ein Charakter): Das ist die zweithäufigste Fehlerquelle. Prüfe jede Zeile in einer solchen Kette einzeln: Ist das wirklich durchgehend derselbe Sprecher, oder wurde eine Sprecherwechsel-Zeile (z.B. eine Antwort einer anderen Figur, oder gesprochene Rede ohne erhaltene Anführungszeichen) fälschlich in die Kette hineingezogen? Prüfe besonders bei 'Narrator'-Ketten: Ist wirklich jede Zeile neutrale Erzählerbeschreibung, oder ist tatsächlich eine Aussage dabei, die ein Charakter so gesagt haben könnte — nur ohne erhaltene Anführungszeichen (typisch bei PDF/OCR-Extraktion)? Prüfe Tonfall, Wortwahl und Perspektive: Klingt eine der Zeilen wie eine direkte Aussage/Reaktion einer Person in der Szene (Ich-Form, Anrede, Ausruf, Frage), nicht wie neutrale Erzählerbeschreibung? Dann ist SIE Dialog, nicht Narration — auch wenn keine » « vorhanden sind, und auch wenn die Zeilen davor/danach in derselben Kette echt narrativ sind. +3. ZULETZT ein allgemeiner Plausibilitäts-Check bei allen übrigen, bereits vermuteten Sprechern: Passt Tonfall/Wortwahl der Zeile zu dem, was über diese Figur im bisherigen Text bekannt ist (Sprechweise, Haltung, Beziehung zu anderen Figuren)? Wenn eine Zeile im Kontext eindeutig zu einer ANDEREN, im Text erkennbaren Person passt, korrigiere sie dorthin. +4. Echte Narration NICHT anfassen: Reine Beschreibung, Handlung, Übergänge, Kapitelanfänge bleiben 'Narrator'. Nur weil eine Figur erwähnt wird, wird der Satz nicht zu ihrem Dialog. -DEDUKTIONS-WERKZEUGE (wende sie in dieser Reihenfolge an): -- Doppelpunkt-Regel: Endet der Erzählersatz vor dem Zitat mit ":", spricht dessen Subjekt das Zitat ("Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter; "Ein anderer fragte verschlafen:" → dieser andere). -- Nachgestellte Zuordnung: Der Erzählersatz NACH dem Zitat verrät den Sprecher — auch bei unpersönlicher Formel ("»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach). -- Pronomen-Auflösung: er/sie/es in Inquit-Formeln und Action Beats meint die zuletzt genannte Person passenden Geschlechts ("Mit einem Stoß schob sie Uriens zur Seite" → sie = die zuletzt genannte Frau, und die umliegenden Zitate sind ihre). -- Adressaten-Regel: "X wandte sich an Y" → X spricht das nächste Zitat, Y ist der wahrscheinlichste Antwortende. -- Ping-Pong-Prinzip: Zwei Personen im Gespräch wechseln sich strikt ab — auch über viele Zitate ohne Tags hinweg. Verfolge die Kette zur letzten eindeutigen Nennung zurück und führe sie fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch. -- Rollenbezeichnungen sind gültige Sprecher — nutze sie statt 'Unknown' (z.B. 'Ork', 'Der Fremde', 'Nachbar', 'Wächter', 'Junge'). - -DIALOG-ERKENNUNG BEI PDF/OCR-TEXTEN: -Viele PDF-Extraktionen verlieren Anführungszeichen oder Guillemets. Ein kurzer Satz kann also trotzdem Dialog sein, auch wenn »...« oder „..." im übergebenen Segment fehlen. Entscheide nach Satzform, Antwortstruktur, Sprecherwechsel, Inquit-Formeln und Szene. Markiere eine Zeile NICHT allein deshalb als Narration, weil sichtbare Anführungszeichen fehlen. - -ABSATZ- UND KAPITELSTRUKTUR: -Eine Leerzeile markiert einen Absatzwechsel oder Kapitel-/Szenenanfang. Eine sehr kurze, alleinstehende Zeile vor einer Leerzeile ist eine Kapitelüberschrift — 'narration'/'Narrator', niemals Dialog. - -GRAMMATIK-CHECK FÜR NARRATION: -- Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name. -- Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration. -- Action Beats sind narration: blickte, ging, schwieg, lachte, hob die Hand, wandte sich ab, usw. -- Nur die tatsächlich gesprochenen Wörter innerhalb der Anführungszeichen bleiben dialogue; alle grammatischen Rahmen- und Berichtssätze sind narration. +DEDUKTIONS-WERKZEUGE für die Sprecherzuordnung: +- Doppelpunkt-Regel: Endet der Satz davor mit ":", spricht dessen Subjekt das Folgende. +- Nachgestellte Zuordnung: Folgt einer als 'Narrator' markierten Zeile direkt eine kurze Inquit-Formel (Sprechverb + Name/Pronomen, z.B. "entgegnete Oberst von Blautann.", ", meldete sich Lysandra zu Wort.", "murmelte er."), dann war die VORHERIGE Zeile in Wahrheit das Zitat dieser Person — auch ohne Anführungszeichen. Das ist der häufigste Fehler der ersten Zuordnung: gesprochene Rede wird fälschlich als Narration markiert, weil die Anführungszeichen bei der Extraktion verloren gingen. +- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile handelt (aufblickt, sich umdreht, den Kopf schüttelt), ist der wahrscheinlichste Sprecher dieser Zeile. +- Ping-Pong-Prinzip: Zwei Personen im Gespräch wechseln sich strikt ab, auch ohne Tags — verfolge die Kette zur letzten eindeutigen Nennung zurück. +- Pronomen-Auflösung: er/sie/es meint die zuletzt genannte Person passenden Geschlechts. +- Rollenbezeichnungen sind gültige Sprecher (z.B. 'Ork', 'Der Fremde', 'Wächter') statt 'Unknown'. FÜR JEDES SEGMENT AUSGABE: -- speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters. +- speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters (nutze den in der bekannten Charakterliste geführten Namen, nicht einen Spitznamen/Alias, falls der Kontext eindeutig zuordnet). - type: 'narration' oder 'dialogue' - text: EXAKT der WORTWÖRTLICHE Originaltext — KEINE Änderungen, KEINE Auslassungen, KEINE Ergänzungen. - emotion: Bei Dialogen 1-2 deutsche Wörter für den Tonfall. Bei Narration leer (''). ABSOLUTE REGELN: - Alle Segmente zusammen MÜSSEN den Originaltext exakt, lückenlos und wortgetreu rekonstruieren. -- PDF-/OCR-SCHUTZ: Ein fehlendes » oder « darf niemals bewirken, dass die restliche Passage als Dialog markiert wird. Bei einem offenen »-Zitat vor einer Inquit-Formel oder Erzählerhandlung endet der Dialog am ersten plausiblen Satzende (? ! .). Bei einem einzelnen schließenden « nach einem kurzen Satz ist dieser Satz davor der Dialog. - Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu. -- Mische NIEMALS Narration und Dialog in einem Segment.`; +- Mische NIEMALS Narration und Dialog in einem Segment. +- Sei konservativ: ändere eine Zeile nur, wenn du nach dieser Prüfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend wäre — nicht jede Zeile muss sich ändern.`; const choice = audiobookCurrentCastLlm(panel); const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); closePanel(); - audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt }); + audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt, includeNarrator: true }); }; - - foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass); + + // 3rd quality pass — deliberately NOT chunk-by-chunk like the two + // passes above (both re-read the source text in ~4000-char windows, + // since a whole book is far bigger than any context window). This one + // works purely over ALREADY-ATTRIBUTED lines already sitting in memory: + // for each character, gather every line credited to them from anywhere + // in the book and send that whole bundle to the LLM in one call, asking + // it to judge each line against the REST of that character's own + // established voice — catches a line that got misattributed to the + // right-sounding-in-isolation-but-wrong-in-context character, which a + // single passage window could never expose since it only ever sees + // that one line's immediate neighbours. + const runConsistencyPass = async () => { + const active = _abActiveSegments(); + const segs = active.arr; + if (!segs.length) { toast('Nothing cast yet to check', 'error'); return; } + + const byName = new Map(); + segs.forEach((s, idx) => { + if (s.type !== 'dialogue' || !s.speaker) return; + if (/^Narrator$/i.test(s.speaker) || /^Unknown|Unbekannt/i.test(s.speaker)) return; + if (!byName.has(s.speaker)) byName.set(s.speaker, []); + byName.get(s.speaker).push(idx); + }); + const MIN_LINES = 4; // can't judge a "pattern" from fewer lines than this + const MAX_LINES_PER_CALL = 60; // context-size ceiling for one character + const candidates = [...byName.entries()].filter(([, idxs]) => idxs.length >= MIN_LINES); + if (!candidates.length) { toast('No characters with enough lines yet to check for consistency', 'error'); return; } + + const choice2 = audiobookCurrentCastLlm(panel); + const savedChoice2 = audiobookSaveLlmChoice(choice2.url, choice2.model); + closePanel(); + + _abPushEditState(active.key, segs); + const busy = _abShowBusyOverlay(`Checking voice consistency for ${candidates.length} character${candidates.length !== 1 ? 's' : ''}…`, true); + let checked = 0, flaggedTotal = 0, failedNames = []; + const touchedSegs = []; + const knownNames = [...roster.keys()]; + try { + for (const [name, idxs] of candidates) { + busy.setProgress(checked, candidates.length); + // Evenly sample across the WHOLE arc (not just the first N + // appearances) when a major character has more lines than fit in + // one call, so their voice late in the book is represented too. + let sampleIdxs = idxs; + if (idxs.length > MAX_LINES_PER_CALL) { + const step = idxs.length / MAX_LINES_PER_CALL; + sampleIdxs = Array.from({ length: MAX_LINES_PER_CALL }, (_, i) => idxs[Math.floor(i * step)]); + } + const lines = sampleIdxs.map(i => ({ index: i, text: segs[i].text })); + try { + const r = await fetch('/api/audiobook-consistency-check', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + character: name, lines, + known_characters: knownNames.filter(n => n.toLowerCase() !== name.toLowerCase()), + llm_url: savedChoice2.url, model: savedChoice2.model, + }), + }); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const data = await r.json(); + const outliers = Array.isArray(data?.outliers) ? data.outliers : []; + for (const o of outliers) { + // The model is asked to echo back the real bracketed index, + // but sometimes returns its own sequential count instead + // (confirmed happening live) — verify against the quoted + // excerpt it was also asked to provide, and fall back to a + // text search across this character's OTHER lines if the + // index it gave doesn't actually land on one of them. Safer + // to skip a real correction than to misapply one to the + // wrong (innocent) segment. + let idx = o.index; + const quote = String(o.quote || '').trim().toLowerCase(); + const matchesQuote = (i) => !quote || (segs[i]?.text || '').toLowerCase().includes(quote.slice(0, 40)); + if (typeof idx !== 'number' || !segs[idx] || segs[idx].speaker !== name || !matchesQuote(idx)) { + idx = quote ? sampleIdxs.find(i => segs[i].speaker === name && matchesQuote(i)) : undefined; + } + if (typeof idx !== 'number' || !segs[idx] || segs[idx].speaker !== name) continue; + const suggested = String(o.suggested_speaker || '').trim(); + let newSpeaker = null; + if (!suggested || /^unknown|unbekannt$/i.test(suggested)) newSpeaker = 'Unknown'; + else newSpeaker = knownNames.find(n => n.toLowerCase() === suggested.toLowerCase()) || null; + // Only ever reassign to an ALREADY-known name (or 'Unknown') + // — this pass never invents a new character. + if (!newSpeaker || newSpeaker === name) continue; + segs[idx].speaker = newSpeaker; + if (newSpeaker === 'Unknown') segs[idx].type = 'dialogue'; + touchedSegs.push(segs[idx]); + flaggedTotal++; + } + } catch (err) { + failedNames.push(name); + console.error('[consistency check]', name, err); + } + checked++; + busy.setProgress(checked, candidates.length); + } + } finally { + busy.remove(); + } + + if (flaggedTotal) { + const patched = await _abPatchScatteredRows(touchedSegs, () => {}); + if (!patched) await _abRedrawSegmentsChunked(active.arr); + _abPersistManualEdit(); + } + const failSuffix = failedNames.length ? ` (${failedNames.length} character${failedNames.length !== 1 ? 's' : ''} failed to check: ${failedNames.slice(0, 5).join(', ')})` : ''; + toast( + (flaggedTotal + ? `Consistency check: ${flaggedTotal} line${flaggedTotal !== 1 ? 's' : ''} reassigned across ${checked} character${checked !== 1 ? 's' : ''}` + : `Consistency check: no mismatches found across ${checked} character${checked !== 1 ? 's' : ''}`) + failSuffix, + failedNames.length && !flaggedTotal ? 'error' : 'success' + ); + }; + foot.querySelector('#ab-cv-open-reh').addEventListener('click', async () => { closePanel(); await audiobookOpenCurrentInRehearser(); }); - foot.querySelector('#ab-cv-export-md')?.addEventListener('click', audiobookExportCastMd); - + const applyPromptAndRun = (callback) => { const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; const choice = audiobookCurrentCastLlm(panel); const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); - + if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) }) .finally(() => { @@ -3378,9 +3952,181 @@ ABSOLUTE REGELN: if (callback) callback(savedChoice.url, savedChoice.model); }); }; - - foot.querySelector('#ab-cv-recast').addEventListener('click', () => applyPromptAndRun(audiobookCast)); - foot.querySelector('#ab-cv-recast-unk').addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown)); + const runIdentifyAll = async () => { + const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true }); + if (!ok) return; + applyPromptAndRun(audiobookCast); + }; + const runIdentifyUnknown = () => applyPromptAndRun(audiobookRecastUnknown); + const runCastAll = () => { + if (typeof window.csForReader === 'function') window.csForReader(); + else if (typeof csForReader === 'function') csForReader(); + else toast('Character sheets not loaded yet', 'error'); + }; + const runCastFresh = async () => { + const ok = await confirmDialog('Discard every generated character sheet for this book and rebuild every profile from a blank slate? This cannot be undone.', { title: 'Discard all character sheets?', okLabel: 'Discard & rebuild', danger: true }); + if (!ok) return; + if (typeof window.csForReader === 'function') window.csForReader({ fresh: true }); + else if (typeof csForReader === 'function') csForReader({ fresh: true }); + else toast('Character sheets not loaded yet', 'error'); + }; + const runCastSelected = () => { + if (!existing.length) { + toast('No character sheets found yet for this book', 'error'); + return; + } + _abOpenRecastSelectPopup(existing); + }; + // "Continue uncasted characters" — same completeness check the live + // progress UI already uses ("N profiles with details") to find anyone + // with zero real detail fields (never cast, or only ever picked up as + // a bare name), and runs the targeted/selective pass (evidence-window + // per character, not a full book re-scan) on just those — skips + // everyone who's already got a real profile instead of re-touching + // the whole cast like "Cast all character roles" does. + const runCastContinueUncasted = async () => { + if (typeof clGetAllByTagOrBook !== 'function' || typeof csForReaderSelective !== 'function' || typeof CS_DETAIL_FIELDS === 'undefined') { + toast('Character sheets not loaded yet', 'error'); + return; + } + let recs = []; + try { recs = await clGetAllByTagOrBook(bookTitle); } catch (_) {} + // Indexed by canonical name ONLY used to miss every alias — a roster + // name that's really just an already-known alternate name for a + // MORE complete record (e.g. "Fremder" once a character-sheet pass + // has proven it's the same person as the already-detailed "Zerwas") + // looked like "never cast at all" and got endlessly re-queued as its + // own separate target, recreating the same split every time this ran. + const recByName = new Map(); + recs.forEach(r => { + const name = String(r.name || '').trim().toLowerCase(); + if (name) recByName.set(name, r); + if (typeof clSplitIdentityTokens === 'function') { + for (const a of clSplitIdentityTokens(r?.sheet?.aliases, { aliases: true })) { + const key = a.toLowerCase(); + if (!recByName.has(key)) recByName.set(key, r); + } + } + }); + const roster = (_audiobook.roster || []).filter(n => n && !/^unknown$|^unbekannt$/i.test(n.trim())); + // A character with only a handful of lines usually just doesn't have + // enough text for the LLM to say anything real about them — sending + // them through the same pass as everyone else burns a call that + // almost always comes back blank anyway (confirmed live: a whole- + // roster run filled in the one prominent character and left every + // <20-line character empty). Skip anyone under this line count by + // default rather than pretending they're "uncasted" forever. + const MIN_LINES_TO_TRY = 10; + const lineCounts = new Map(); + (_audiobook.segments || []).forEach(s => { + if (s?.type !== 'dialogue' || !s.speaker) return; + const k = String(s.speaker).trim().toLowerCase(); + lineCounts.set(k, (lineCounts.get(k) || 0) + 1); + }); + const tooSparse = []; + const incomplete = roster.filter(n => { + const rec = recByName.get(String(n).trim().toLowerCase()); + const hasDetail = rec && CS_DETAIL_FIELDS.some(f => String((rec.sheet || {})[f] || '').trim()); + if (hasDetail) return false; + const lc = lineCounts.get(String(n).trim().toLowerCase()) || 0; + if (lc < MIN_LINES_TO_TRY) { tooSparse.push(n); return false; } + return true; + }); + if (!incomplete.length) { + toast(tooSparse.length + ? `Nothing left to try — ${tooSparse.length} character${tooSparse.length !== 1 ? 's have' : ' has'} fewer than ${MIN_LINES_TO_TRY} lines and ${tooSparse.length !== 1 ? 'were' : 'was'} skipped` + : 'Every character already has a profile with detail — nothing to continue', 'success'); + return; + } + toast(`Continuing ${incomplete.length} uncasted character${incomplete.length !== 1 ? 's' : ''}` + + (tooSparse.length ? ` (skipped ${tooSparse.length} with fewer than ${MIN_LINES_TO_TRY} lines)` : '') + '…', 'info'); + csForReaderSelective(incomplete); + }; + const runViewCast = () => { + // navTo('s-library') is silently swallowed by Studio's nav guard + // while Studio is the active section (same pattern that broke "Open + // Script Rehearser" — see studio.js's _stuInstallNavGuardOnce) — + // confirmed live: the button fired with no visible effect. Studio's + // own Voices phase already shows this exact roster (it borrows the + // same #lib-chars-list the old Library page uses), so redirect + // there instead of navigating away. + if (document.getElementById('s-caststudio')?.classList.contains('is-active') && typeof window.showStudioPhase === 'function') { + window.showStudioPhase(3); + return; + } + try { sessionStorage.setItem('ttsvc_cast_return', 'reader'); } catch (_) {} + if (typeof navTo === 'function') navTo('s-library'); + if (typeof navLibraryView === 'function') navLibraryView('characters'); + }; + // The Character Library accumulates a record for every name any past + // casting run has ever produced for this book, with no cleanup tied to + // the CURRENT roster — confirmed live: 65+ saved records for a book + // whose current cast is 46 names, including spelling-variant + // duplicates ("Globo Brohm" / "Gombo Brohm" / "Gernot Brohm") and a + // character split three ways ("Kolon" / "Kolon der Zwerg" / "Kolon + // Tunneltreiber") that a past, less-accurate pass never got merged + // away. The current roster (this run's own attribution, not the + // Library) is the ground truth for who's actually in the cast now. + const runLibraryCleanup = async () => { + if (typeof clGetAllByTagOrBook !== 'function' || typeof clDelete !== 'function') { + toast('Character library is not available right now', 'error'); + return; + } + const roster = (_audiobook.roster || []).filter(n => n && !/^unknown$|^unbekannt$/i.test(n.trim())); + if (!roster.length) { toast('No current roster to clean up against', 'error'); return; } + const rosterSet = new Set(roster.map(n => n.trim().toLowerCase())); + let recs = []; + try { recs = await clGetAllByTagOrBook(bookTitle); } catch (_) {} + const toDelete = recs.filter(r => !rosterSet.has(String(r.name || '').trim().toLowerCase())); + if (!toDelete.length) { + toast(`Library already matches the current ${roster.length}-name roster — nothing to remove`, 'success'); + return; + } + const preview = toDelete.slice(0, 12).map(r => r.name).join(', ') + (toDelete.length > 12 ? `, +${toDelete.length - 12} more` : ''); + const ok = await confirmDialog( + `Remove ${toDelete.length} character record${toDelete.length !== 1 ? 's' : ''} that aren't in the current ${roster.length}-name roster? This cannot be undone.\n\n${preview}`, + { title: 'Clean up character library?', okLabel: `Remove ${toDelete.length}`, danger: true } + ); + if (!ok) return; + let removed = 0; + for (const r of toDelete) { + try { await clDelete(r.id); removed++; } catch (_) {} + } + toast(`Removed ${removed} character record${removed !== 1 ? 's' : ''} not in the current roster`, 'success'); + }; + + const bookTitle = window.readerState?.title || ''; + let existing = []; + // If this book's already been cast before, "Cast selected character + // roles" can reuse the loaded character list instead of starting blind. + (async () => { + if (!bookTitle || typeof clGetAllByTagOrBook !== 'function') return; + try { existing = await clGetAllByTagOrBook(bookTitle); } catch (_) {} + })(); + + const identifyMenu = foot.querySelector('#ab-cv-menu-identify'); + const castMenu = foot.querySelector('#ab-cv-menu-cast'); + const viewCastMenu = foot.querySelector('#ab-cv-menu-viewcast'); + identifyMenu?.addEventListener('click', () => _abToggleFootMenu(identifyMenu, [ + { icon: 'mdi-refresh', label: 'Identify all characters', title: 'Scan the text and build the cast list from scratch', onClick: runIdentifyAll, danger: true }, + { icon: 'mdi-account-question-outline', label: 'Identify unknown characters', title: 'Re-scan only the unknown segments with the current prompt', onClick: runIdentifyUnknown }, + { icon: 'mdi-shield-check-outline', label: 'Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass }, + { icon: 'mdi-account-search-outline', label: 'Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass }, + ])); + castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [ + { icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll }, + { icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted }, + { icon: 'mdi-account-check-outline', label: 'Cast selected character roles', title: existing.length ? 'Generate / refresh the character sheets for selected cast characters' : 'No character sheets found yet for this book', disabled: !existing.length, onClick: runCastSelected }, + { divider: true }, + { icon: 'mdi-refresh', label: 'New recast (discard & rebuild all)', title: 'Discard every generated character sheet and rebuild every profile from scratch', onClick: runCastFresh, danger: true }, + ])); + viewCastMenu?.addEventListener('click', () => _abToggleFootMenu(viewCastMenu, [ + { icon: 'mdi-eye-outline', label: 'View cast', title: 'View the cast overview in the library', onClick: runViewCast }, + { icon: 'mdi-folder-zip-outline', label: 'Export cast archive (.zip)', title: 'Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character', onClick: audiobookExportCastMd }, + { divider: true }, + { icon: 'mdi-broom', label: 'Clean up library to current roster', title: 'Remove saved character records that aren\'t in the current roster — old spelling-variant duplicates and superseded names from past casting runs', onClick: runLibraryCleanup, danger: true }, + ])); + foot.querySelector('#ab-cv-continue')?.addEventListener('click', () => applyPromptAndRun((u, m) => audiobookCast(u, m, { startIndex: _audiobook.completedChunks, segments: _audiobook.segments, @@ -3388,38 +4134,6 @@ ABSOLUTE REGELN: narrationOnly: _audiobook.narratedPassages, degraded: _audiobook.degraded }))); - foot.querySelector('#ab-cv-cast-chars').addEventListener('click', () => { - if (typeof window.csForReader === 'function') window.csForReader(); - else if (typeof csForReader === 'function') csForReader(); - else toast('Character sheets not loaded yet', 'error'); - }); - // If this book's already been cast before, "Cast Characters" blindly - // re-running the full generation over again isn't the useful default - // anymore — offer to jump straight to the existing overview, or recast - // just a hand-picked subset, instead of only "do it all again". - (async () => { - const bookTitle = window.readerState?.title || ''; - if (!bookTitle || typeof clGetAllByTagOrBook !== 'function') return; - let existing = []; - try { existing = await clGetAllByTagOrBook(bookTitle); } catch (_) {} - if (!existing.length) return; - const castBtn = foot.querySelector('#ab-cv-cast-chars'); - if (!castBtn) return; - const wrap = document.createElement('span'); - wrap.className = 'ab-cv-castchars-group'; - wrap.innerHTML = - '' + - ''; - castBtn.replaceWith(wrap); - wrap.querySelector('.ab-cv-castchars-view').addEventListener('click', () => { - if (typeof navTo === 'function') navTo('s-library'); - if (typeof navLibraryView === 'function') navLibraryView('characters'); - }); - wrap.querySelector('.ab-cv-castchars-menu').addEventListener('click', (e) => { - e.stopPropagation(); - _abOpenRecastCharsMenu(e.currentTarget, bookTitle, existing); - }); - })(); panel.classList.add('ab-castpanel-done'); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, @@ -3454,7 +4168,7 @@ ABSOLUTE REGELN: const foot = panel.querySelector('#ab-cv-foot'); const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0; foot.hidden = false; - foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; + foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel); foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', async () => { @@ -3479,7 +4193,7 @@ ABSOLUTE REGELN: panel.classList.add('ab-castpanel-done'); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, - // Show fresh "Ready to cast" state (used when server draft fetch returns empty) + // Show a fresh "ready" state (used when server draft fetch returns empty) loadingRestore() { feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); @@ -3498,7 +4212,7 @@ ABSOLUTE REGELN: const castBtn = panel.querySelector('#ab-cv-start-cast'); if (castBtn) castBtn.style.display = 'none'; }, - setFreshState(message = 'Ready to cast.') { + setFreshState(message = 'Ready to identify characters.') { feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); if (!feed.querySelector('.ab-cv-row, .ab-cv-divider, .ab-cv-page')) { @@ -3543,16 +4257,22 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) const preResolved = audiobookResolveUnknowns(segs, [], _audiobook.roster || []); if (preResolved.length) toast(`${preResolved.length} Unknown line${preResolved.length !== 1 ? 's' : ''} resolved by grammar rules`, 'success'); + // The verify pass (options.includeNarrator) also re-checks every Narrator + // line, not just Unknown dialogue — that's the only way to catch dialogue + // that got hidden inside narration on the first pass. Confirmed + // non-Unknown dialogue stays out of scope either way; re-litigating lines + // that are already clearly attributed isn't what was asked for. const unknownIdxs = []; for (let i = 0; i < segs.length; i++) { - if (segs[i].type === 'dialogue' && (!segs[i].speaker || /^Unknown|Unbekannt/i.test(segs[i].speaker))) { - unknownIdxs.push(i); - } + const s = segs[i]; + const isUnknownDialogue = s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)); + const isNarratorCandidate = options.includeNarrator && s.type === 'narration'; + if (isUnknownDialogue || isNarratorCandidate) unknownIdxs.push(i); } - if (!unknownIdxs.length) { - toast('No Unknown speakers found', 'success'); + if (!unknownIdxs.length) { + toast('No Unknown speakers found', 'success'); audiobookShowPreview(); - return; + return; } _audiobook.running = true; _audiobook.cancel = false; @@ -3568,6 +4288,24 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) const view = audiobookCastView(unknownIdxs.length, llm_url, model); view.recountRoster(segs); // sidebar shows the whole cast's roster, not just the lines being checked + // Resolve a speaker name the LLM returns to its canonical character — + // deterministic lookup against saved aliases (e.g. "Vampire" -> "Zerwas"), + // rather than relying on the model to remember/output the canonical name + // itself. Built once up front so every segment write-back stays consistent. + const _rcAliasMap = new Map(); + try { + const bookTitle = window.readerState?.title || ''; + const records = typeof clGetAllByTagOrBook === 'function' ? await clGetAllByTagOrBook(bookTitle) : []; + for (const rec of records || []) { + if (!rec?.name) continue; + _rcAliasMap.set(rec.name.toLowerCase(), rec.name); + if (typeof clSplitIdentityTokens === 'function') { + for (const a of clSplitIdentityTokens(rec.aliases, { aliases: true })) _rcAliasMap.set(a.toLowerCase(), rec.name); + } + } + } catch (_) {} + const canonicalizeSpeaker = (name) => (name && _rcAliasMap.get(name.toLowerCase())) || name; + view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…'); try { await audiobookFetchWithTimeout('/api/attribute-dialogue', { @@ -3596,7 +4334,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) const pendingReplacements = new Map(); const normalizeRecastSegment = (seg, fallback) => { const type = seg?.type === 'narration' ? 'narration' : 'dialogue'; - const speaker = type === 'narration' ? 'Narrator' : (seg?.speaker || fallback?.speaker || 'Unknown'); + const speaker = type === 'narration' ? 'Narrator' : canonicalizeSpeaker(seg?.speaker || fallback?.speaker || 'Unknown'); return { speaker, type, @@ -3698,7 +4436,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) } const match = audiobookFindReturnedSegment(targetSeg, data.segments, used); if (match && match.seg.speaker) { - const speaker = match.seg.type === 'narration' ? 'Narrator' : match.seg.speaker; + const speaker = match.seg.type === 'narration' ? 'Narrator' : canonicalizeSpeaker(match.seg.speaker); if (!speaker || /^Unknown|Unbekannt/i.test(speaker)) continue; used.add(match.idx); targetSeg.type = match.seg.type === 'narration' ? 'narration' : 'dialogue'; @@ -3732,6 +4470,16 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) .sort((a, b) => b[0] - a[0]) .forEach(([idx, repl]) => segs.splice(idx, 1, ...repl)); } + // Splicing multi-segment replacements back in at scattered indices can + // reintroduce a passage that a neighbouring recast group's overlapping + // context window already restated correctly a few segments earlier — + // confirmed live (a paragraph appearing twice with a stray leading quote + // mark on the second copy, separated by an unrelated dialogue block). + const { segments: deduped, removed: dupRemoved } = _audiobookDedupNearbyDuplicates(segs); + if (dupRemoved) { + segs.splice(0, segs.length, ...deduped); + view.note(`Removed ${dupRemoved} duplicated line${dupRemoved !== 1 ? 's' : ''} introduced by this verification pass.`); + } const afterUnknownCount = countUnknownDialogue(segs); if (!_audiobook.cancel && afterUnknownCount > beforeUnknownCount) { @@ -3814,7 +4562,7 @@ async function audiobookOpenCastView() { const wasDone = draftTotal > 0 && draftDone >= draftTotal; const canContinue = draftDone > 0 && draftTotal > 0 && draftDone < draftTotal; const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt'; - view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Define characters" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`); + view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Identify all characters" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`); const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); @@ -4124,7 +4872,28 @@ async function audiobookCast(overrideUrl, overrideModel, resume) { } mergedSegs.push(s); } - segs = mergedSegs; + // The LLM can correctly split SOME dialogue in a chunk while + // leaving OTHER »...« lines merged into a narration segment right + // next to it — the `hasDialogue` check above only asks "did this + // chunk produce ANY dialogue at all", so it's satisfied by the + // first correct split and never re-examines the rest (confirmed + // live: "»Ich glaube, ich bin in dich verliebt.«" got attributed + // to Alrik correctly while "»Halt, bleib stehen.«" and "»Ich liebe + // dich«" a few lines later stayed silently merged into narration + // with no speaker at all). Re-scan every leftover narration + // segment individually and pull out anything still embedded — + // audiobookResolveUnknowns right after this already exists to + // firm up any "Unknown" speaker this isolated re-split can't infer + // from the surrounding chunk context. + let respiltSegs = []; + for (const s of mergedSegs) { + if (s.type === 'narration' && audiobookHasDialogue(s.text)) { + respiltSegs.push(...audiobookSplitByQuotes(s.text)); + } else { + respiltSegs.push(s); + } + } + segs = respiltSegs; } } catch (chunkErr) { if (chunkErr.name === 'AbortError') throw chunkErr; // propagate Stop Casting @@ -4173,6 +4942,15 @@ async function audiobookCast(overrideUrl, overrideModel, resume) { return; } + // allSegments is declared const further up — replace its contents in + // place rather than rebinding, since it's captured by closures above. + // Fix stray quote-mark boundaries BEFORE merging same-speaker segments, + // so a narration row that's about to be merged away doesn't carry a + // misplaced guillemet into its neighbour first. + const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments); + const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_quoteFixedSegs); + allSegments.length = 0; + allSegments.push(..._mergedSegs); _audiobook.segments = allSegments; _audiobook.lastText = text; _audiobook.roster = roster; @@ -4198,25 +4976,49 @@ async function audiobookOpenCurrentInRehearser() { const segs = _audiobook.segments || []; if (!segs.length) { toast('No cast to open', 'error'); return false; } - await audiobookSaveAsRehearsal({ silent: true }); - - if (_audiobook.rehId && typeof window.rehDbGetById === 'function' && typeof window.rehLoadRecord === 'function') { + // Build the record straight from the CURRENT in-memory segments and load + // it directly — a prior version saved this to IndexedDB and then + // re-fetched it by id before loading, which could hand back a stale + // record (a read-after-write race, or a stale `_audiobook.rehId` left + // over from an earlier pass) instead of the just-corrected data. Building + // once and loading that same object removes the possibility entirely; + // the DB write below is now just for persistence, not the source of what + // gets loaded. + if (typeof window.rehLoadRecord === 'function' && typeof _audiobookBuildRehRecord === 'function') { try { - const rec = await window.rehDbGetById(_audiobook.rehId); - if (rec) { - document.getElementById('audiobook-preview')?.remove(); - if (typeof navTo === 'function') navTo('s-rehearser'); - window.rehLoadRecord(rec); - toast('Opened cast in Script Rehearser', 'success'); - return true; - } + const rec = await _audiobookBuildRehRecord(segs); + if (_audiobook.rehId) rec.id = _audiobook.rehId; + document.getElementById('audiobook-preview')?.remove(); + if (typeof navTo === 'function') navTo('s-rehearser'); + window.rehLoadRecord(rec); + // rehLoadRecord always lands on the Cast phase, but by the time you're + // opening the Rehearser from the Audiobook pipeline, voices were already + // assigned in Assign Voices — Cast there is pure redundant re-work. + // Jump straight to Stage (phase 3), the line-by-line editing view. + // showPhase() alone only toggles which phase
is visible — the + // actual script/character panes are built by buildScriptPage(), same + // as the Stage sub-tab's own click handler does. + if (rehState.lines.length && typeof buildScriptPage === 'function') { + buildScriptPage(); + if (typeof showPhase === 'function') showPhase(3); + if (typeof highlightCurrentLine === 'function') highlightCurrentLine(); + } else if (typeof showPhase === 'function') showPhase(3); + toast('Opened cast in Script Rehearser', 'success'); + audiobookSaveAsRehearsal({ silent: true }).catch(() => {}); + return true; } catch (err) { - console.warn('[audiobook] failed to open saved rehearser record:', err); + console.warn('[audiobook] failed to build rehearser record directly, falling back:', err); } } + await audiobookSaveAsRehearsal({ silent: true }); const { script, emotions } = audiobookBuildScript(segs); await audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions); + if (rehState.lines.length && typeof buildScriptPage === 'function') { + buildScriptPage(); + if (typeof showPhase === 'function') showPhase(3); + if (typeof highlightCurrentLine === 'function') highlightCurrentLine(); + } else if (typeof showPhase === 'function') showPhase(3); return true; } @@ -4375,18 +5177,56 @@ async function audiobookExport() { _audiobook.running = true; _audiobook.cancel = false; const prog = audiobookProgress(allIdx.length); - const mp3 = new Map(); + // WAV, not mp3, per line: each clip needs to be losslessly merged before + // any lossy encoding happens (see the merge step below for why) — encoding + // to mp3 here, one clip at a time, is exactly what produced a file most + // players couldn't play past the first clip. + const wavClips = new Map(); + const failedLines = []; let done = 0; const queue = allIdx.slice(); const worker = async () => { while (queue.length && !_audiobook.cancel) { const i = queue.shift(); + // "Synth all" already pre-synthesizes every line into rehState.synthCache + // for instant playback — this export used to ignore that entirely and + // re-hit the TTS API for every single line from scratch regardless, + // turning what should be an instant export (for anyone who already + // pre-synthesized) into a full re-synthesis of the whole book, easily + // 20+ minutes for a real novel with no visible sign anything had gone + // wrong — confirmed as the actual cause behind "clicked Audiobook and + // got no file": it was still working, just silently redoing work that + // was already done. A cached, non-stale clip is reused as-is; a stale + // one (tone edited, or the speaker's voice reassigned — see + // _rehInvalidateCastVoice) is skipped so the branch below re-synthesizes. + if (rehState.synthCache.has(i) && !rehState.staleLines.has(i)) { + wavClips.set(i, rehState.synthCache.get(i)); + prog.update(++done, `Synthesising line ${done} / ${allIdx.length}…`); + continue; + } const l = rehState.lines[i]; const { voice, instruct } = audiobookLineVoice(l); const text = (typeof _rehInlineTone === 'function') ? _rehInlineTone(stripMarkdown(l.text), l.emotion) : (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text); - try { mp3.set(i, await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, rehState.backend)); } catch (_) {} + try { + // Beyond rehState.synthCache (this browser tab's memory, gone on + // reload), lines synthesized via "Synth all" in an EARLIER session + // persist to disk too — check there before hitting TTS at all. + let blob = null, cacheKey = null, book = null; + if (typeof _lineAudioCacheKey === 'function') { + book = _lineAudioBookName(); + cacheKey = await _lineAudioCacheKey(text, voice, instruct); + blob = await _lineAudioCacheGet(book, cacheKey); + } + if (!blob) { + blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, rehState.backend); + if (cacheKey) _lineAudioCachePut(book, cacheKey, blob); + } + wavClips.set(i, blob); + rehState.synthCache.set(i, blob); // benefits a retry/re-export too + } + catch (e) { failedLines.push(i); console.error('[audiobook export] synth failed for line', i, e); } prog.update(++done, `Synthesising line ${done} / ${allIdx.length}…`); } }; @@ -4394,24 +5234,113 @@ async function audiobookExport() { finally { prog.done(); _audiobook.running = false; } if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; } + // A line whose synthesis failed used to just be missing from `mp3`, so + // `buckets[c].idx.map(...).filter(Boolean)` silently dropped it out of the + // merged file — the export "succeeded" while quietly missing dialogue/ + // narration, discoverable only by listening to the whole thing. Stop + // instead of merging a file with silent gaps in it. + if (failedLines.length) { + toast(failedLines.length + ' line(s) failed to synthesise — retry them (Script Rehearser → re-synthesise stale) before exporting, or they will silently drop from the audiobook', 'error'); + return; + } const title = (typeof readerSafeName === 'function' ? readerSafeName($('reh-script-title')?.value || 'Audiobook') : ($('reh-script-title')?.value || 'Audiobook')); const realChapters = buckets.filter(b => b.title).length > 0; let files = 0; + const savedFiles = []; // {name, url} — for the results panel below + const bookForExport = _lineAudioBookName(); + const encMsg = $('audiobook-msg'); for (let c = 0; c < buckets.length; c++) { - const blobs = buckets[c].idx.map(i => mp3.get(i)).filter(Boolean); + const blobs = buckets[c].idx.map(i => wavClips.get(i)).filter(Boolean); if (!blobs.length) continue; - const blob = new Blob(blobs, { type: 'audio/mpeg' }); + // mergeWavBlobs (generation.js) properly concatenates raw PCM sample + // data under one RIFF header — unlike naively Blob-concatenating + // separately-encoded mp3 clips (each with its own frame/ID3 headers), + // this produces one genuinely continuous, seekable audio stream. + if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${buckets.length}…`; + const mergedWav = await mergeWavBlobs(blobs); + // One real mp3 encode pass over the whole merged chapter, at an + // explicit bitrate (routes/tts.py's /api/audio/encode-mp3) — the old + // per-line encoding left the bitrate at ffmpeg/lame's unset default, + // which came out to 32kbps, well below what even a 24kHz mono voice + // source needs to avoid audible compression artifacts. + if (encMsg) encMsg.textContent = `Encoding chapter ${c + 1} / ${buckets.length}…`; + let blob = mergedWav; + try { + const encResp = await fetch('/api/audio/encode-mp3', { method: 'POST', body: mergedWav }); + if (encResp.ok) blob = await encResp.blob(); + else console.error('[audiobook export] mp3 encode failed, shipping wav instead:', encResp.status); + } catch (e) { console.error('[audiobook export] mp3 encode request failed, shipping wav instead:', e); } + const ext = blob === mergedWav ? 'wav' : 'mp3'; const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : ''; const name = (realChapters || buckets.length > 1) - ? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.mp3` - : `${title}.mp3`; + ? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.${ext}` + : `${title}.${ext}`; if (typeof readerDownload === 'function') readerDownload(blob, name); + // Also persist server-side — the browser download alone lands wherever + // the user's browser settings put it, with nothing in the app itself + // saying where, and re-finding it later means re-running the whole + // export from scratch. Best-effort: the browser download above already + // succeeded either way. + try { + const saveResp = await fetch(`/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}`, { method: 'POST', body: blob }); + if (saveResp.ok) savedFiles.push({ name, url: `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}` }); + } catch (_) {} files++; await new Promise(r => setTimeout(r, 400)); } - toast('Exported audiobook · ' + files + (realChapters ? ' chapter MP3 file(s)' : ' MP3 file(s)'), 'success'); + toast('Exported audiobook · ' + files + (realChapters ? ' chapter file(s)' : ' file(s)'), 'success'); + if (savedFiles.length && typeof _abShowExportResults === 'function') _abShowExportResults(bookForExport, savedFiles); } +// A small results panel after "Audiobook" finishes — separate from the +// success toast, which is plain text and disappears after a few seconds +// with no way to actually get back to the files. Lists each saved chapter +// with a real download link and shows the server-side folder they live in, +// so the export doesn't just vanish into whatever the browser's download +// settings did with it. +function _abShowExportResults(book, files, opts = {}) { + document.getElementById('ab-export-results')?.remove(); + const ov = document.createElement('div'); + ov.id = 'ab-export-results'; + ov.className = 'audiobook-overlay'; + const zipUrl = `/api/audiobook-export/${encodeURIComponent(book)}/zip`; + ov.innerHTML = `
+
${opts.browsing ? 'Saved audiobook exports' : 'Audiobook exported'}
+

Saved on the server${opts.browsing ? '' : ', in case the browser’s own download went somewhere you don’t check'} — come back and download any of these again any time, without re-exporting.

+
+ ${files.map(f => ` +
+ + ${escHtml(f.name)} + Download +
`).join('')} +
+ ${files.length > 1 ? `` : ''} +
`; + document.body.appendChild(ov); + const close = () => ov.remove(); + ov.querySelector('#ab-export-close').addEventListener('click', close); + ov.addEventListener('click', e => { if (e.target === ov) close(); }); +} + +// "Browse" the exports already saved for the current book, without +// re-running the export — the entry point for finding a chapter you +// downloaded in an earlier session and can't find in your Downloads folder. +async function audiobookBrowseExports() { + const book = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : ($('reh-script-title')?.value.trim() || 'Untitled'); + try { + const r = await fetch(`/api/audiobook-export/${encodeURIComponent(book)}`); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); + const d = await r.json(); + if (!d.files || !d.files.length) { toast('No saved exports yet for this book — run "Audiobook" first', 'info'); return; } + const files = d.files.map(f => ({ name: f.name, url: `/api/audiobook-export/${encodeURIComponent(book)}/${encodeURIComponent(f.name)}` })); + _abShowExportResults(book, files, { browsing: true }); + } catch (e) { + toast('Could not load saved exports: ' + (e.message || e), 'error'); + } +} +$('reh-tb-browse-exports')?.addEventListener('click', audiobookBrowseExports); + // ── Wiring ─────────────────────────────────────────────────────────────────── $('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView); @@ -4504,3 +5433,4 @@ function _audiobookDebouncedSave() { clearTimeout(_abRehSaveTimer); _abRehSaveTimer = setTimeout(() => audiobookSaveAsRehearsal({ silent: true }), 1500); } + diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index 11f2f21..b473683 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -16,14 +16,19 @@ const CS_DEFAULT_PROMPT = `You are an expert dramaturge, developmental editor, a PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or blank out a field you cannot improve. In this cast-character pass, ONLY refine the already-casted roster and do NOT invent new profiles, places, or institutions. The text may be a focused evidence window around a mention, so use the nearby paragraphs as context. Leave a field empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'. For each character output these fields: - name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title. -- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain. - - first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty. +- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain. + - first_name, last_name: split the character identity when known. Leave unknown parts empty. - title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter'). - profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest'). +- age_estimate: estimated age or age range, if inferable +- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant +- languages: spoken languages / dialects / tongues, comma-separated if multiple +- nationality_background: homeland, culture, origin, or social background if known +- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.) - archetype: a two-word role summary (e.g. 'Ruthless Scholar') - gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable. -- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system. -- clothing: distinctive clothing, armour, accessories — as observed in the text +- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known. +- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look - alignment: strict moral code + the one line they will never cross - moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous - arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex' @@ -36,6 +41,7 @@ For each character output these fields: - motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition) - fears: their deepest fears, phobias or dread - mannerisms: habitual gestures, tics, body language, habits and quirks +- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc. - voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting) - voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers. - image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'. @@ -43,12 +49,15 @@ For each character output these fields: - secret: dark secret or fatal flaw - conflict_style: fight, flight, or manipulate — how they act when cornered - win_condition: the specific event that would make them feel they have won +- reputation: how other characters or society see them +- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it +- notes: brief catch-all notes for useful details that do not fit elsewhere - tier: 'main' or 'supporting' - sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition. -IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name. +IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Marcus', 'the Executioner', and 'Bloodfang' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title and keep one canonical name. Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships. Respond with STRICT JSON only: -{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]} +{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]} /no-think`; // Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the @@ -96,28 +105,46 @@ function csRehearserText() { // ── Generation (chunked + merged by character) ─────────────────────────────── -const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'profession', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note', +const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'profession', 'age_estimate', 'race_species', 'languages', 'nationality_background', 'social_class', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note', 'attribute_high', 'attribute_low', 'skills', 'capabilities', - 'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern', - 'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt', + 'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'communication_style', 'voice_pattern', + 'secret', 'conflict_style', 'win_condition', 'reputation', 'religious_beliefs', 'notes', 'voice_design_prompt', 'image_prompt', 'silly_tavern_prompt', 'concept_art_prompt']; const CS_DETAIL_FIELDS = CS_SCALAR_FIELDS.filter(f => !['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(f)); -const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; +// Used to decide whether two generated sheets are the SAME character and +// should be merged (csFindMergeKey) or whether an incoming sheet matches an +// expected roster name (csSheetMatchesRoster). Must stay limited to genuine +// name/alias fields — it used to also include title/profession/age/race/ +// languages/nationality/social class, all purely descriptive text that +// different characters routinely share verbatim (e.g. several villains here +// share the exact title "Verweser der von den Orks eroberten +// Reichsprovinzen"). Treating a shared job title as proof of shared identity +// merged unrelated characters' sheets into one, corrupting both. +const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name']; const CS_ALIAS_MAX_TOKENS = 12; const CS_ALIAS_MAX_CHARS = 500; const CS_SOURCE_FIELDS = { physical: ['physical', 'appearance', 'body', 'look'], clothing: ['clothing', 'appearance', 'armour', 'armor', 'item'], + age_estimate: ['age', 'older', 'young', 'teen', 'adult'], + race_species: ['race', 'species', 'kind', 'orc', 'elf', 'human', 'vampire'], + languages: ['language', 'languages', 'tongue', 'dialect', 'speech'], + nationality_background: ['nationality', 'background', 'origin', 'homeland', 'culture'], + social_class: ['class', 'social', 'status', 'rank'], relationships: ['relationship', 'ally', 'rival', 'enemy', 'family'], motivation: ['motivation', 'intention', 'goal', 'desire'], fears: ['fear', 'dread'], mannerisms: ['mannerism', 'habit', 'gesture', 'voice'], + communication_style: ['communication', 'communicate', 'demeanor', 'style'], voice_pattern: ['voice', 'speech', 'dialogue'], backstory: ['backstory', 'origin', 'history'], alignment: ['alignment', 'ethos', 'morality'], profession: ['profession', 'occupation', 'job', 'role'], skills: ['skill', 'capability', 'ability'], capabilities: ['capability', 'ability', 'combat', 'magic'], + reputation: ['reputation', 'known as', 'status', 'perceived'], + religious_beliefs: ['religion', 'faith', 'belief', 'priest', 'god', 'gods', 'temple'], + notes: ['note', 'misc', 'miscellaneous'], secret: ['secret', 'flaw'], conflict_style: ['conflict', 'fight', 'flight', 'manipulate'], win_condition: ['win', 'goal'], @@ -127,11 +154,11 @@ function csExistingSummary(map) { if (!map.size) return ''; // Keep the summary compact — just name + archetype + which key fields are still blank. // Long "still needs:" lists were blowing up context windows on early passages. - const KEY_FIELDS = ['physical', 'profession', 'backstory', 'motivation', 'voice_pattern', 'relationships']; + const KEY_FIELDS = ['age_estimate', 'race_species', 'languages', 'nationality_background', 'social_class', 'profession', 'physical', 'clothing', 'communication_style', 'voice_pattern', 'backstory', 'motivation', 'relationships', 'reputation', 'religious_beliefs', 'notes']; return [...map.values()].slice(0, 40).map(s => { const missing = KEY_FIELDS.filter(f => !(s[f] || '').trim()); const suffix = missing.length < KEY_FIELDS.length ? ` | needs: ${missing.join(', ')}` : ' | complete'; - const aliases = [s.full_name, s.title, s.profession, s.aliases].filter(Boolean).join(', '); + const aliases = [s.title, s.profession, s.aliases].filter(Boolean).join(', '); return `- ${s.name}${aliases ? ` aka ${aliases}` : ''}${s.archetype ? ` (${s.archetype})` : ''}${suffix}`; }).join('\n'); } @@ -197,10 +224,11 @@ function csKnownReaderRoster() { function csBlankSheet(name) { return { name, aliases: '', first_name: '', last_name: '', full_name: '', title: '', profession: '', + age_estimate: '', race_species: '', languages: '', nationality_background: '', social_class: '', archetype: '', gender: '', physical: '', clothing: '', alignment: '', arc_note: '', attribute_high: '', attribute_low: '', skills: '', capabilities: '', - backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', voice_pattern: '', - secret: '', conflict_style: '', win_condition: '', voice_design_prompt: '', image_prompt: '', + backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', communication_style: '', voice_pattern: '', + secret: '', conflict_style: '', win_condition: '', reputation: '', religious_beliefs: '', notes: '', voice_design_prompt: '', image_prompt: '', inventory: [], sources: [], moral_alignment_score: 50, arc_direction: 'neutral', tier: 'supporting', }; } @@ -209,6 +237,12 @@ function csSeedSheet(raw) { const name = _csStr(raw?.name).trim(); const seed = csBlankSheet(name); if (!name) return seed; + // A character with an already-saved profile picture (rec.image, set at the + // top level of the library record, not inside .sheet) used to always show + // the plain letter placeholder in this live view — nothing carried the + // image forward from the seed into the in-memory sheet used here, even + // though the picture was sitting right there in the saved record. + if (raw?.image) seed._image = raw.image; const src = raw?.sheet && typeof raw.sheet === 'object' ? raw.sheet : raw; CS_SCALAR_FIELDS.forEach(f => { if (src && src[f] != null) seed[f] = _csStr(src[f]); @@ -275,7 +309,6 @@ function csRecordNeedles(rec) { }; add(rec?.name); add(rec?.sheet?.aliases); - add(rec?.sheet?.full_name); add([rec?.sheet?.first_name, rec?.sheet?.last_name].filter(Boolean).join(' ')); return out; } @@ -393,6 +426,65 @@ function csMerge(map, sheets) { return changed; } +// Both the casting roster (Identify Characters) and this generation pass can +// independently pre-seed the SAME person under two different names — e.g. a +// pre-reveal descriptive alias like "der Fremde" alongside an already- +// established dialogue-attribution name like "Zerwas" (its own roster entry +// with its own line count). The incremental per-passage merge above only ever +// checks an INCOMING sheet's identity tokens against the map, never checks +// two ALREADY-SEEDED map entries against each other — so once the LLM proves +// (via its own aliases field) that two pre-seeded entries are one person, +// nothing reconciles them, and both persist as permanently separate library +// records. Runs once at the end of a full generation pass: whenever an +// entry's aliases name another entry still in the map, folds the two +// together — keeping whichever name was ALREADY an established record before +// this run (settled by Identify Characters or an earlier cast pass) as +// canonical, with the other name demoted to an alias — rather than letting +// the character-sheet step invent a competing identity. +function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated) { + let changed = true; + while (changed) { + changed = false; + for (const [key, s] of map) { + const aliasKeys = csSplitIdentityTokens(s.aliases, { aliases: true }).map(a => a.toLowerCase()); + const dupKey = aliasKeys.find(a => a !== key && map.has(a)); + if (!dupKey) continue; + const other = map.get(dupKey); + // Established (pre-existing library record) beats a same-run stub. If + // both (or neither) qualify, prefer whichever is more detailed; if + // that's also tied (e.g. both still blank stubs from the same seeding + // pass), fall back to whichever record is OLDER — the one Identify + // Characters / the original cast settled on first. + const sEst = establishedKeys.has(key), oEst = establishedKeys.has(dupKey); + let keepKey = key, dropKey = dupKey; + if (oEst && !sEst) { keepKey = dupKey; dropKey = key; } + else if (sEst === oEst) { + const filled = (sh) => CS_DETAIL_FIELDS.filter(f => _csStr(sh[f]).trim()).length; + const sFilled = filled(s), oFilled = filled(other); + if (oFilled > sFilled) { keepKey = dupKey; dropKey = key; } + else if (oFilled === sFilled && establishedCreated) { + const sT = establishedCreated.get(key), oT = establishedCreated.get(dupKey); + if (sT != null && oT != null && oT < sT) { keepKey = dupKey; dropKey = key; } + } + } + const keep = map.get(keepKey), drop = map.get(dropKey); + const oldAliases = keep.aliases; + CS_SCALAR_FIELDS.forEach(f => { const dv = _csStr(drop[f]); if (dv.length > (keep[f] || '').length) keep[f] = dv; }); + keep.aliases = csMergeAliases({ ...keep, aliases: oldAliases }, drop); + if (drop.tier === 'main') keep.tier = 'main'; + if (drop.line_count != null) keep.line_count = Math.max(keep.line_count || 0, drop.line_count); + (drop.inventory || []).forEach(it => { if (it && !keep.inventory.includes(it) && keep.inventory.length < 3) keep.inventory.push(it); }); + (drop.sources || []).forEach(src => { + if (src && src.quote && keep.sources.length < 12 && !keep.sources.some(x => x.quote === src.quote)) keep.sources.push(src); + }); + map.set(keepKey, keep); + map.delete(dropKey); + changed = true; + break; // map mutated — restart the scan + } + } +} + function csProgressSnapshot(map, changed = []) { const changedByKey = new Map(changed.map(x => [x.key, x.status])); return [...map.entries()].map(([key, s]) => { @@ -400,10 +492,13 @@ function csProgressSnapshot(map, changed = []) { return { key, name: s.name || key, - alias: [s.full_name, s.title, s.profession].filter(Boolean).join(' · '), + alias: [s.title, s.profession].filter(Boolean).join(' · '), status: changedByKey.get(key) || '', filled, main: s.tier === 'main', + gender: s.gender || '', + lineCount: Number(s.line_count) || 0, + image: s._image || '', }; }).sort((a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name)); } @@ -413,6 +508,13 @@ function csProgressSnapshot(map, changed = []) { // received chunk) rather than one fixed overall timeout, since generation // can legitimately take a while but a truly stalled stream should still // give up. Falls back to the blocking endpoint on any failure. +// +// Confirmed live this endpoint never delivers a single visible delta for +// character-sheet generation, however long you wait (reproduced identically +// at 60s/300s/560s) — some models only flush content once reasoning is +// done, so there's nothing to gain from a long idle-timeout here; it only +// delays falling through to the (working) blocking endpoint below. Kept +// short so that fallback happens quickly rather than as a rare last resort. async function csGenerateStream(body, onDelta, outerSignal, idleTimeoutMs = 60000) { const ctl = new AbortController(); const onAbort = () => ctl.abort(); @@ -462,6 +564,14 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { if (!text) { toast('Nothing to analyse', 'error'); return null; } const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text]; _cs.running = true; _cs.cancel = false; + // Everything below used to run outside any try/finally that resets + // _cs.running — a thrown error anywhere in setup (progress overlay + // creation, DOM lookups, etc.) left _cs.running stuck true forever, so + // every future recast attempt (in this tab, indefinitely) silently + // returned null at the guard above with no error shown at all. Wrapping + // the whole body guarantees the flag always clears and any crash is at + // least visible instead of a silent no-op. + try { const prog = csProgress(chunks.length, opts.pageHost || null); const llm_url = csLlmUrl(), model = csLlmModel(); const selectedLanguage = csLang(); @@ -475,12 +585,34 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { } } const map = new Map(); + // Line counts come from the already-cast dialogue, known before generation + // even starts — attach them at seed time so "Lines" shows immediately in + // the live preview instead of staying blank until the whole run finishes. + const lineCounts = opts.lineCounts instanceof Map ? opts.lineCounts : new Map(); const seedSheets = Array.isArray(opts.seedSheets) ? opts.seedSheets : []; + // Names that already had a saved library record BEFORE this run — the + // identity Identify Characters (casting) and any earlier cast pass already + // settled on. Tracked so a later identity-merge discovery (the LLM proving + // two roster names are the same person, e.g. a pre-reveal alias like "der + // Fremde" for an already-established "Zerwas") always folds the OTHER name + // in as an alias on this established record, instead of leaving two + // permanently separate character-library entries for one person. + const establishedKeys = new Set(); + // When two records are BOTH already established (this book's very first + // recast can seed several roster names as separate saved stubs before any + // of them have detail), fall back to whichever was created earliest — + // that's the one Identify Characters / the original cast settled on first. + const establishedCreated = new Map(); seedSheets.forEach(seedRaw => { const seed = csSeedSheet(seedRaw); const name = String(seed.name || '').trim(); if (!name) return; + const lc = lineCounts.get(name.toLowerCase()); + if (lc != null) seed.line_count = lc; map.set(name.toLowerCase(), seed); + establishedKeys.add(name.toLowerCase()); + const created = seedRaw?.created ? new Date(seedRaw.created).getTime() : NaN; + if (!isNaN(created)) establishedCreated.set(name.toLowerCase(), created); }); // Pre-seed the roster with known characters from the casting run so the LLM // fills those profiles instead of treating the cast list as aliases to invent. @@ -489,7 +621,12 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { const knownTotal = targetMode ? roster.length : null; roster.forEach(name => { const key = name.toLowerCase(); - if (!map.has(key)) map.set(key, csBlankSheet(name)); + if (!map.has(key)) { + const blank = csBlankSheet(name); + const lc = lineCounts.get(key); + if (lc != null) blank.line_count = lc; + map.set(key, blank); + } }); try { for (let i = 0; i < chunks.length; i++) { @@ -500,58 +637,137 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { : `${map.size} characters found`; prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel, csProgressSnapshot(map), [...map.values()]); prog.startPassage(chunks[i]); - try { - // Read fresh each passage so an edit made mid-run (via the Prompt - // panel) takes effect starting from the next request, not just on - // the next full run. - const character_sheets_prompt = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT; - const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model, character_sheets_prompt }; - let data = null; - let sawDelta = false; + // Rate-limit (429) errors used to permanently drop that passage's + // data — the loop just logged it and moved on, so a burst of + // requests hitting the provider's per-minute cap could silently + // leave large chunks of the book with no character detail. Retry + // with backoff specifically for rate-limit errors (a handful of + // attempts, growing delay) before giving up; other error types + // still fail fast as before, since retrying a genuinely broken + // request just wastes time. + const maxAttempts = 4; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - data = await csGenerateStream(body, (delta) => { sawDelta = true; prog.thinking(delta); }); - } catch (streamErr) { - if (!sawDelta) prog.noStream(); - const r = await fetch('/api/character-sheets', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!r.ok) { - let detail = `HTTP ${r.status}`; - try { const e = await r.json(); detail = e.detail || e.error || detail; } catch (_) {} - throw new Error(detail); + // Read fresh each passage so an edit made mid-run (via the Prompt + // panel) takes effect starting from the next request, not just on + // the next full run. + const character_sheets_prompt = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT; + const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model, character_sheets_prompt }; + let data = null; + let sawDelta = false; + try { + data = await csGenerateStream(body, (delta) => { sawDelta = true; prog.thinking(delta); }); + // The streaming request can succeed (no exception, valid final + // result) while still never having delivered a single visible + // delta — some models only send control/reasoning frames over SSE + // and buffer the whole answer into the final "done" frame. That + // used to leave the "Waiting for streamed JSON output…" text stuck + // forever even though passages kept completing normally, because + // noStream() was only ever called from the catch/fallback branch. + if (!sawDelta) prog.noStream(); + } catch (streamErr) { + if (!sawDelta) prog.noStream(); + // Deliberately NO client-side timeout on this request. Confirmed + // live (three attempts at 60s/300s/560s) that aborting it does + // NOT save any time — the server's upstream call sits in a + // blocking socket read a dropped connection can't interrupt (see + // routes/conversation.py's `_watchdog_close`), so an abort only + // orphans a thread that keeps holding the shared LLM lock until + // its own ~600s watchdog force-closes it, and the very next + // attempt then queues up behind THAT thread instead of making + // progress — a self-inflicted stall, not a recovery. This + // request reliably completes in a couple of minutes when left + // alone (confirmed live), same as the old Read Aloud page's + // still-working "arrives at once when each passage finishes" + // behavior for the same non-streaming model. + const r = await fetch('/api/character-sheets', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) { + let detail = `HTTP ${r.status}`; + try { const e = await r.json(); detail = e.detail || e.error || detail; } catch (_) {} + throw new Error(detail); + } + const raw = await r.text(); + try { data = JSON.parse(raw); } + catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } } - const raw = await r.text(); - try { data = JSON.parse(raw); } - catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } - } - const incomingSheets = targetMode ? (data.sheets || []).filter(s => csSheetMatchesRoster(s, roster)) : (data.sheets || []); - const changed = csMerge(map, incomingSheets); - if (changed.length) prog.focus(changed[0].name); - if (!targetMode) { - (data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); - (data.sheets || []).forEach(s => { - csNameTokens(s).forEach(n => { - const display = [s.name, s.full_name, s.title, s.profession, s.aliases].filter(Boolean).join(', ').split(/[,;/|]/).map(x => x.trim()).find(x => x.toLowerCase() === n) || n; - if (display && !roster.some(r => r.toLowerCase() === display.toLowerCase())) roster.push(display); + const incomingSheets = targetMode ? (data.sheets || []).filter(s => csSheetMatchesRoster(s, roster)) : (data.sheets || []); + // Confirmed live: a passage featuring two characters together can + // make the model write one character's real name into ANOTHER + // character's first_name/last_name field (e.g. a "Darrag" sheet + // with first_name/last_name both set to "Riedmar" — a completely + // different, separately-cast character, not a revealed alias). + // Riedmar's own record was blank at the time, so this wasn't a + // merge bug — the model itself confused whose name belonged where. + // A character's real name being IDENTICAL to a DIFFERENT known + // character's name is a strong contamination signal, so that + // field gets dropped rather than trusted. + incomingSheets.forEach(s => { + const ownName = String(s.name || '').trim().toLowerCase(); + ['first_name', 'last_name', 'full_name'].forEach(f => { + const v = String(s[f] || '').trim().toLowerCase(); + if (v && v !== ownName && roster.some(n => n.toLowerCase() === v)) { + console.warn(`[character sheets] dropped ${f}="${s[f]}" from "${s.name}" — matches a different known character`); + s[f] = ''; + } }); }); + const changed = csMerge(map, incomingSheets); + if (changed.length) prog.focus(changed[0].name); + if (!targetMode) { + (data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); + (data.sheets || []).forEach(s => { + csNameTokens(s).forEach(n => { + const display = [s.name, s.title, s.profession, s.aliases].filter(Boolean).join(', ').split(/[,;/|]/).map(x => x.trim()).find(x => x.toLowerCase() === n) || n; + if (display && !roster.some(r => r.toLowerCase() === display.toLowerCase())) roster.push(display); + }); + }); + } + const newDetailed = [...map.values()].filter(s => CS_DETAIL_FIELDS.some(f => (s[f] || '').trim())).length; + const newCharLabel = knownTotal + ? `${knownTotal} cast characters queued · ${newDetailed} profiles with details` + : `${map.size} characters found`; + prog.update(i + 1, `Passage ${i + 1} / ${chunks.length}…`, newCharLabel, csProgressSnapshot(map, changed), [...map.values()]); + break; + } catch (e) { + const msg = e.message || String(e); + const isRateLimit = /429|too many requests|rate.?limit/i.test(msg); + // "Failed to fetch" (a dropped connection, a momentary proxy/DNS + // blip, the backend restarting) used to fail this passage + // permanently on the very first hit — no retry at all, unlike + // rate limits — silently leaving that passage's characters with + // whatever detail they already had. These are exactly the kind of + // transient error retrying helps with, so they now get the same + // backoff-and-retry treatment (just a shorter wait, since a + // network blip usually clears faster than a rate-limit window). + const isNetworkError = /failed to fetch|network\s*error|load failed|timed?\s*out|timeout|\b50[234]\b|econnreset|econnrefused|socket hang up|connection (refused|reset|closed)/i.test(msg); + if ((isRateLimit || isNetworkError) && attempt < maxAttempts) { + const waitMs = attempt * (isRateLimit ? 8000 : 4000); + toast(`Passage ${i + 1} — ${isRateLimit ? 'rate-limited' : 'network error'} — retrying in ${waitMs / 1000}s (attempt ${attempt + 1}/${maxAttempts})…`, 'info'); + await new Promise(res => setTimeout(res, waitMs)); + continue; + } + console.error('Character sheets passage', i + 1, 'failed:', e); + toast('Passage ' + (i + 1) + ' failed: ' + msg, 'error'); + break; } - const newDetailed = [...map.values()].filter(s => CS_DETAIL_FIELDS.some(f => (s[f] || '').trim())).length; - const newCharLabel = knownTotal - ? `${knownTotal} cast characters queued · ${newDetailed} profiles with details` - : `${map.size} characters found`; - prog.update(i + 1, `Passage ${i + 1} / ${chunks.length}…`, newCharLabel, csProgressSnapshot(map, changed), [...map.values()]); - } catch (e) { - console.error('Character sheets passage', i + 1, 'failed:', e); - toast('Passage ' + (i + 1) + ' failed: ' + (e.message || String(e)), 'error'); } } - } finally { prog.done(); _cs.running = false; } + } finally { prog.done(); } if (_cs.cancel) { toast('Cancelled', 'error'); return null; } + csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated); const sheets = [...map.values()]; if (cacheKey) _cs.cache[cacheKey] = sheets; return sheets; + } catch (e) { + console.error('[character sheets] generation crashed:', e); + toast('Character sheet generation crashed: ' + (e && e.message ? e.message : String(e)), 'error'); + return null; + } finally { + _cs.running = false; + } } function csProgress(total, hostEl = null) { @@ -589,7 +805,6 @@ function csProgress(total, hostEl = null) {
-
@@ -597,11 +812,11 @@ function csProgress(total, hostEl = null) {
Select a character to preview the sheet as it fills.
+
Passage / Live output watching…
-
@@ -614,7 +829,6 @@ function csProgress(total, hostEl = null) {
-
@@ -624,12 +838,21 @@ function csProgress(total, hostEl = null) {
+
-
+
`; root.querySelector('#cs-progress-cancel')?.addEventListener('click', () => { _cs.cancel = true; }); @@ -729,6 +952,11 @@ function csProgress(total, hostEl = null) { let currentSheets = []; let selectedName = ''; let searchQuery = ''; + // Default to most-lines-first — main characters (the ones worth seeing + // fleshed out first) naturally have the most dialogue, so this surfaces + // them at the top as generation starts instead of whatever's alphabetically + // first or happens to have just been touched. + let rosterSort = (() => { try { return localStorage.getItem('ttsvc_cs_side_sort') || 'lines'; } catch (_) { return 'lines'; } })(); const keyFor = v => String(v || '').trim().toLowerCase(); const findSheet = (name, sheets = currentSheets) => (Array.isArray(sheets) ? sheets : []).find(s => keyFor(s?.name) === keyFor(name)) || null; let outputCollapsed = false; @@ -765,8 +993,9 @@ function csProgress(total, hostEl = null) { const cur = parseInt(outputPanel.dataset.csHeight || '', 10) || parseInt(outputPanel.style.height || '', 10) || outputPanel.getBoundingClientRect().height; if (cur) outputExpandedHeight = clampOutputHeight(cur); } - outputPanel.style.height = '44px'; - outputPanel.style.maxHeight = '44px'; + const collapsedH = 138; + outputPanel.style.height = `${collapsedH}px`; + outputPanel.style.maxHeight = `${collapsedH}px`; } else { const h = outputExpandedHeight || parseInt(outputPanel.dataset.csHeight || '', 10) || 260; outputPanel.style.height = `${clampOutputHeight(h)}px`; @@ -806,7 +1035,11 @@ function csProgress(total, hostEl = null) { const onMove = (e) => { if (!dragging) return; if (outputCollapsed) applyOutputCollapse(false); - setOutputHeight(startH + (e.clientY - startY)); + // The handle sits on the panel's TOP edge (moved there from the old + // bottom-right corner button) — dragging up should grow the panel, + // dragging down should shrink it, the opposite sign from when the + // handle tracked the bottom edge. + setOutputHeight(startH - (e.clientY - startY)); e.preventDefault(); }; const stopDrag = () => { @@ -834,10 +1067,67 @@ function csProgress(total, hostEl = null) { preview.innerHTML = '
Select a character to preview the sheet as it fills.
'; return; } - preview.innerHTML = csCardHtml(sheet); + preview.innerHTML = csCardHtml(sheet, { voice: sheet.voice, line_count: sheet.line_count }); preview.querySelector('.cs-card')?.classList.add('cs-progress-preview-card'); - preview.querySelectorAll('.cs-head-btns, .cs-sources').forEach(el => el.remove()); + preview.querySelectorAll('.cs-head-btns, .cs-header-actions, .cs-sources').forEach(el => el.remove()); preview.querySelectorAll('.cs-avatar').forEach(el => { el.style.pointerEvents = 'none'; el.style.cursor = 'default'; }); + + // Voice-picking and the highlight-color swatch both need a persisted + // Library record (voice/color live there, not on the plain sheet) — this + // preview can show sheets that haven't been saved yet (generation still + // running), so create/reuse the record on first interaction rather than + // eagerly for every character as it streams in. + const book = (typeof readerState !== 'undefined' && readerState.title) || ''; + const ensureRecord = async () => (typeof clUpsert === 'function') ? await clUpsert(book, sheet) : null; + preview.querySelector('.lcd-pick-voice')?.addEventListener('click', async () => { + const rec = await ensureRecord(); + if (rec && typeof _openVoicePicker === 'function') _openVoicePicker(preview.querySelector('.lcd-voice-top'), rec, () => renderPreview()); + }); + preview.querySelector('.lcd-auto-voice')?.addEventListener('click', async () => { + const rec = await ensureRecord(); + if (rec && typeof _autoAssignVoice === 'function') { await _autoAssignVoice(rec); renderPreview(); } + }); + preview.querySelector('.lcd-online-voice')?.addEventListener('click', async () => { + const rec = await ensureRecord(); + if (rec && typeof _charSearchOnline === 'function') _charSearchOnline(rec); + }); + preview.querySelector('.lcd-gen-voice')?.addEventListener('click', async () => { + const rec = await ensureRecord(); + if (rec && typeof _charDesignVoice === 'function') _charDesignVoice(rec); + }); + preview.querySelector('.cs-header-color input')?.addEventListener('input', async (e) => { + const rec = await ensureRecord(); + if (!rec) return; + sheet.color = e.target.value; + rec.color = e.target.value; + if (typeof clPut === 'function') await clPut(rec); + }); + + // Each field's little numbered mark links back to the exact sentence the + // LLM drew that value from — showing it here (instead of only in a hover + // tooltip) makes it easy to spot a misread/hallucinated field at a glance. + const sourceBox = preview.querySelector('.cs-source-preview'); + preview.querySelectorAll('.cs-source-mark:not(.cs-source-mark-sample)').forEach(btn => { + btn.addEventListener('click', () => { + if (!sourceBox) return; + preview.querySelectorAll('.cs-source-mark.is-active').forEach(b => b.classList.remove('is-active')); + btn.classList.add('is-active'); + const quote = btn.dataset.quote || ''; + const hint = btn.dataset.lineHint || ''; + const page = btn.dataset.page || ''; + sourceBox.innerHTML = `
${hint ? escHtml(hint) : 'Source sentence'}${page ? ` · p.${escHtml(page)}` : ''}
+
${quote ? escHtml(quote) : 'No exact sentence was saved for this field.'}
`; + sourceBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + }); + }; + const rosterSorters = { + // Default: unsaved/just-changed entries first, then most-filled, then + // name — keeps whatever the model is actively working on near the top. + progress: (a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name), + alpha: (a, b) => a.name.localeCompare(b.name), + lines: (a, b) => (b.lineCount || 0) - (a.lineCount || 0) || a.name.localeCompare(b.name), + gender: (a, b) => (a.gender || '￿').localeCompare(b.gender || '￿') || a.name.localeCompare(b.name), }; const renderChars = () => { if (!chars) return; @@ -846,18 +1136,20 @@ function csProgress(total, hostEl = null) { const name = keyFor(c.name); const alias = keyFor(c.alias); return name.includes(searchQuery) || alias.includes(searchQuery); - }); + }).slice().sort(rosterSorters[rosterSort] || rosterSorters.progress); chars.innerHTML = list.length ? list.map(c => `
- ${escHtml((c.name || '?')[0].toUpperCase())} + ${c.image + ? `` + : `${escHtml((c.name || '?')[0].toUpperCase())}`} ${escHtml(c.name || 'Unknown')} - - ${escHtml(String(c.filled || 0))} - - ${CS_DETAIL_FIELDS.length} + + ${c.lineCount ? escHtml(String(c.lineCount)) + ' lines' : '—'} + + ${escHtml(String(c.filled || 0))}/${CS_DETAIL_FIELDS.length}
`).join('') : '
reading…
'; @@ -872,6 +1164,15 @@ function csProgress(total, hostEl = null) { searchQuery = search.value.trim().toLowerCase(); renderChars(); }); + const sortSel = root.querySelector('#cs-progress-sort'); + if (sortSel) { + sortSel.value = rosterSort; + sortSel.addEventListener('change', () => { + rosterSort = sortSel.value; + try { localStorage.setItem('ttsvc_cs_side_sort', rosterSort); } catch (_) {} + renderChars(); + }); + } renderPreview(); return { // Called once per passage before its request starts, so both panes are @@ -895,6 +1196,12 @@ function csProgress(total, hostEl = null) { }, noStream() { if (liveStatus) { liveStatus.textContent = 'no live output for this model'; liveStatus.className = 'cs-progress-live-status'; } + // Otherwise this stays on "Waiting for streamed JSON output…" forever + // for models that never emit deltas, even though passages are still + // completing normally in the background — reads as a hung/frozen UI. + if (livePre && livePre.textContent === 'Waiting for streamed JSON output…') { + livePre.textContent = 'This model doesn\'t stream incremental output — the full result arrives at once when each passage finishes.'; + } }, focus(name) { if (name) { @@ -954,6 +1261,9 @@ function csAlignmentBar(score, arcDirection, arcNote) { // ── Image & Voice prompt generators ────────────────────────────────────────── +// Fallback only — the LLM-generated image_prompt (routes/conversation.py +// character_generate_prompts) is preferred and asks for the same turnaround- +// sheet format; this covers the case where that hasn't been generated yet. function csBuildImagePrompt(s) { if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim(); const parts = []; @@ -965,7 +1275,16 @@ function csBuildImagePrompt(s) { parts.push(pct >= 70 ? 'benevolent expression' : pct <= 30 ? 'dark and menacing presence' : 'ambiguous expression'); if (s.arc_direction === 'bad-to-good') parts.push('redemptive aura'); if (s.arc_direction === 'good-to-bad') parts.push('ominous aura, turning to darkness'); - return `Portrait of ${s.name}, ${parts.filter(Boolean).join(', ')}, fantasy character art, detailed face, dramatic lighting, high detail.`; + return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. ` + + `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — ` + + `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" ` + + `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual ` + + `choice should fit the world implied by the description, not the real one, unless the description itself is ` + + `explicitly modern/contemporary. ` + + `Include a full-body front view as the anchor, a turnaround panel (side and back views), an expression sheet ` + + `with 3-5 headshots matching their personality, a color palette swatch for hair/eyes/outfit, and labeled callouts ` + + `for signature props or clothing details. Clean production concept-art layout, plain neutral background, ` + + `original character not based on any copyrighted character.`; } function csBuildVoicePrompt(s) { @@ -1052,35 +1371,66 @@ function csSourcesForField(s, field) { } function csSourceBadgeHtml(s, field) { - const src = csSourcesForField(s, field)[0]; - if (!src) return ''; - const idx = (s.sources || []).findIndex(x => x === src); - const n = idx >= 0 ? idx + 1 : 1; - return ``; + // A field can legitimately be backed by several quotes (e.g. a + // relationship mentioned across multiple passages) — used to only ever + // show the FIRST matching source, silently hiding the rest with no + // indication more existed. Now renders one small numbered mark per + // matching source, each numbered to match its position in the full + // "Sources / Evidence" list below so the two can be cross-referenced. + const all = s.sources || []; + const matches = csSourcesForField(s, field); + if (!matches.length) return ''; + return matches.map(src => { + const idx = all.findIndex(x => x === src); + const n = idx >= 0 ? idx + 1 : 1; + return ``; + }).join(''); } function csField(label, value, sheet, field) { if (!value) return ''; const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : ''; - return `
${label}
${escHtml(String(value))}${badge ? ` ${badge}` : ''}
`; + const key = field ? ` data-sheet-key="${escHtml(field)}"` : ''; + // Value text lives in its own span, separate from the source-citation + // badge buttons — the whole
used to be the only option, which would + // have made the badges themselves editable/deletable text once this + // became contenteditable. + return `
${label}
${escHtml(String(value))}${badge ? ` ${badge}` : ''}
`; } function csIdentityCell(label, value, sheet, field) { if (!value) return ''; const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : ''; - return `
${label}
${escHtml(String(value))}${badge ? ` ${badge}` : ''}
`; + const key = field ? ` data-sheet-key="${escHtml(field)}"` : ''; + return `
${label}
${escHtml(String(value))}${badge ? ` ${badge}` : ''}
`; } -function csPromptBox(label, value, sheetKey) { +function csPromptBox(label, value, sheetKey, emptyNote, extraImage) { const text = _csStr(value); const has = !!text.trim(); + // Copy/Regenerate only ever acted on the PROMPT TEXT itself — actually + // using it (designing a voice, generating a portrait) meant copying it + // out and pasting it somewhere else by hand. One click now does that + // directly with whatever prompt is already sitting in the box. + const actionBtn = !has ? '' : sheetKey === 'voice_design_prompt' + ? `` + : sheetKey === 'image_prompt' + ? `` + : sheetKey === 'concept_art_prompt' + ? `` + : ''; + const imageHtml = extraImage + ? `
Concept art
` + : ''; return `
- ${escHtml(label)}${has ? '' : ' — not generated yet'} + ${escHtml(label)}${has ? '' : ' — ' + escHtml(emptyNote || 'not generated yet') + ''}
+ ${imageHtml}
${escHtml(text)}
+ ${actionBtn}
`; @@ -1109,31 +1459,126 @@ function csIdentityTagList(s) { if (tags.some(t => t.key === key)) return; tags.push({ label, text, key }); }; - if (s.full_name && String(s.full_name).trim() && String(s.full_name).trim() !== String(s.name || '').trim()) add('full name', s.full_name); const firstLast = [s.first_name, s.last_name].filter(Boolean).join(' ').trim(); - if (firstLast && firstLast !== String(s.full_name || '').trim() && firstLast !== String(s.name || '').trim()) add('first / last', firstLast); + if (firstLast && firstLast !== String(s.name || '').trim()) add('first / last', firstLast); return tags; } -function csCardHtml(s) { - const inv = (s.inventory || []).filter(Boolean); - const fullName = String(s.full_name || '').trim(); - const metaTags = csIdentityTagList(s); - const identityHtml = `
- ${csIdentityCell('Name', s.name || fullName, s, 'name')} - ${csIdentityCell('First Name', s.first_name, s, 'first_name')} - ${csIdentityCell('Last Name', s.last_name, s, 'last_name')} - ${csIdentityCell('Full Name', fullName && fullName.toLowerCase() !== String(s.name || '').toLowerCase() ? fullName : '', s, 'full_name')} - ${csIdentityCell('Gender', s.gender, s, 'gender')} - ${csIdentityCell('Title', s.title, s, 'title')} - ${csIdentityCell('Occupation', s.profession, s, 'profession')} - ${csIdentityCell('Also Known As', s.aliases, s, 'aliases')} +function csNameHue(name) { + return Math.abs((name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0)); +} + +function csHeaderPill(label, value, icon = '') { + const text = String(value || '').trim(); + if (!text) return ''; + return `${icon ? `` : ''}${escHtml(label)}${escHtml(text)}`; +} + +function csProfileHeaderHtml(s, meta = {}, actionsHtml = '') { + const hue = csNameHue(s.name); + const accent2 = `hsl(${(hue + 38) % 360}, 58%, 34%)`; + // Occupation/archetype/aliases used to appear TWICE — once as plain text + // right under the name, again as pills further down. Keep only the pill + // style (the one that reads better at a glance) and put it where the + // plain-text lines used to be, instead of duplicating in a second row. + const primaryPills = [ + csHeaderPill('Occupation', s.profession, 'briefcase-outline'), + csHeaderPill('Archetype', s.archetype, 'shape-outline'), + csHeaderPill('Also known as', s.aliases, 'tag-multiple-outline'), + ].filter(Boolean).join(''); + const chips = [ + csHeaderPill('Lines', meta.line_count ?? s.line_count, 'format-list-numbered'), + csHeaderPill('Voice', meta.voice, 'account-voice'), + csHeaderPill('Book', meta.book, 'book-open-page-variant-outline'), + csHeaderPill('Tags', meta.tags, 'tag-outline'), + csHeaderPill('Gender', s.gender, 'human-male-female'), + ].filter(Boolean).join(''); + const color = typeof clNormalizeColor === 'function' ? clNormalizeColor(s.color, s.name) : `hsl(${hue},58%,42%)`; + return `
+ ${csAvatarHtml(s)} +
+
${escHtml(s.name || 'Unnamed')}
+ ${primaryPills ? `
${primaryPills}
` : ''} +
${s.tier ? `${escHtml(String(s.tier).toLowerCase() === 'main' ? 'Hauptcharakter' : 'Nebencharakter')}` : ''}${chips ? `
${chips}
` : ''}
+
+ + + ${actionsHtml ? `
${actionsHtml}
` : ''}
`; +} + +function csDetailActionsHtml(s) { + return ` + + + `; +} + +// Deliberately minimal — this is a browsing card (click it to open the full +// profile), not the profile itself. Only: full name, occupation, archetype, +// main/side, lines, gender, voice (explicitly "Not assigned" rather than +// omitted), and the good/evil rating. Everything else lives behind a click. +function csOverviewCardHtml(rec) { + const s = rec.sheet || {}; + const score = s.moral_alignment_score != null ? Math.max(0, Math.min(100, parseInt(s.moral_alignment_score, 10) || 50)) : 50; + const scoreLabel = score >= 70 ? 'Good' : score <= 30 ? 'Evil' : 'Neutral'; + const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; + const fullName = s.full_name || rec.name || ''; + const tierLabel = String(s.tier || '').toLowerCase() === 'main' ? 'Main character' : 'Side character'; + const facts = [ + ['Occupation', s.profession], + ['Archetype', s.archetype], + ['Gender', s.gender], + ['Lines', s.line_count], + ['Voice', voiceId || 'Not assigned'], + ]; + const hue = csNameHue(rec.name); + return `
+
+ ${csAvatarHtml(s.name ? s : { ...s, name: rec.name, _image: rec.image })} +
+
${escHtml(fullName)}
+ ${escHtml(tierLabel)} +
+
+
+
+
Evil${scoreLabel} · ${score}/100Good
+
+
+
+ ${facts.filter(([,v]) => String(v || '').trim()).map(([label, value]) => `
${escHtml(label)}${escHtml(String(value))}
`).join('')} +
+
+
`; +} + +// Field/section helpers matching the richer 2-column "lcd-*" layout (same +// classes the audiobook casting Profil panel and the full Library character +// editor use), instead of csCardHtml's old flat
of rows. +function csLcdField(label, value, sheet, field) { + if (!value) return ''; + const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : ''; + // A compact list row (label left, value right, thin separator) instead of + // the stacked label-above-value block — reads as a scannable list, closer + // to the old flat field list, while still living inside the grouped + // 2-column sections. + if (!label) return `
${escHtml(String(value))}${badge ? ` ${badge}` : ''}
`; + return `
${escHtml(label)}
${escHtml(String(value))}${badge ? ` ${badge}` : ''}
`; +} +function csLcdSection(icon, label, fieldsHtml, full = false) { + const inner = (fieldsHtml || []).filter(Boolean).join(''); + if (!inner) return ''; + return `
${inner}
`; +} + +function csCardHtml(s, meta = {}) { + const inv = (s.inventory || []).filter(Boolean); const invHtml = inv.length - ? `
Signature Items
    ${inv.map(i => `
  • ${escHtml(i)}
  • `).join('')}
` + ? `
Signature Items
${inv.map(escHtml).join(', ')}
` : ''; const attrs = (s.attribute_high || s.attribute_low) - ? `
Core Attributes
▲ ${escHtml(s.attribute_high || '—')}  ·  ▼ ${escHtml(s.attribute_low || '—')}
` + ? `
Core Attributes
▲ ${escHtml(s.attribute_high || '—')}  ·  ▼ ${escHtml(s.attribute_low || '—')}
` : ''; const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null || x.line_hint)); const srcHtml = sources.length @@ -1146,52 +1591,96 @@ function csCardHtml(s) { }
` : ''; - const promptHtml = `
- ${csPromptBox('Voice Design Prompt', s.voice_design_prompt, 'voice_design_prompt')} - ${csPromptBox('Character Image Prompt', s.image_prompt, 'image_prompt')} - ${csPromptBox('SillyTavern Character Prompt', s.silly_tavern_prompt, 'silly_tavern_prompt')} - ${csPromptBox('Character Concept Art Prompt', s.concept_art_prompt, 'concept_art_prompt')} -
`; + // Voice Design / Image Prompt are filled in live, passage by passage — a + // wall of "not generated yet" placeholders before they've had a chance to + // populate reads as broken, so those two stay hidden until non-empty. + // SillyTavern / Concept Art only ever run as a background pass AFTER the + // whole recast finishes (they need the character's complete profile, not + // a partial mid-scan one) — hiding those the same way looked like they'd + // silently vanished, so they always show, with a note explaining why + // they're empty during an active run instead of the generic message. + const laterNote = _cs.running ? 'generates after casting finishes' : 'not generated yet'; + const promptParts = [ + csPromptBox('Voice Design Prompt', s.voice_design_prompt, 'voice_design_prompt'), + csPromptBox('Character Image Prompt', s.image_prompt, 'image_prompt'), + ].filter((_, i) => [s.voice_design_prompt, s.image_prompt][i]); + promptParts.push( + csPromptBox('SillyTavern Character Prompt', s.silly_tavern_prompt, 'silly_tavern_prompt', laterNote), + csPromptBox('Character Concept Art Prompt', s.concept_art_prompt, 'concept_art_prompt', laterNote, s.concept_art_image) + ); + const promptHtml = promptParts.length ? `
${promptParts.join('')}
` : ''; + const voiceId = meta.voice || s.voice || ''; return `
-
- ${csAvatarHtml(s)} -
-
- ${escHtml(s.name)} - ${s.archetype ? `${escHtml(s.archetype)}` : ''} - ${s.tier === 'main' ? 'Main' : 'Supporting'} -
- - - -
-
- ${metaTags.length ? `
${metaTags.map(t => `${escHtml(t.label)}${escHtml(t.text)}`).join('')}
` : ''} - ${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)} + ${csProfileHeaderHtml(s, meta, csDetailActionsHtml(s))} +
+
+ + ${voiceId ? escHtml(voiceId) : 'Noch keine Stimme'} + + + + +
+
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
+
+ ${(() => { + const sections = [ + csLcdSection('mdi-card-account-details-outline', 'Identität', [ + csLcdField('Voller Name', s.full_name, s, 'full_name'), + csLcdField('Vorname', s.first_name, s, 'first_name'), + csLcdField('Nachname', s.last_name, s, 'last_name'), + csLcdField('Alter', s.age_estimate, s, 'age_estimate'), + csLcdField('Geschlecht', s.gender, s, 'gender'), + csLcdField('Rasse / Spezies', s.race_species, s, 'race_species'), + csLcdField('Sprachen', s.languages, s, 'languages'), + csLcdField('Titel', s.title, s, 'title'), + csLcdField('Beruf / Rolle', s.profession, s, 'profession'), + csLcdField('Herkunft', s.nationality_background, s, 'nationality_background'), + csLcdField('Sozialer Stand', s.social_class, s, 'social_class'), + csLcdField('Religion', s.religious_beliefs, s, 'religious_beliefs'), + csLcdField('Ruf', s.reputation, s, 'reputation'), + ]), + csLcdSection('mdi-account-outline', 'Erscheinung', [ + csLcdField('Körperlich', s.physical, s, 'physical'), + csLcdField('Kleidung', s.clothing, s, 'clothing'), + ]), + csLcdSection('mdi-drama-masks', 'Persönlichkeit', [ + csLcdField('Eigenheiten', s.mannerisms, s, 'mannerisms'), + csLcdField('Kommunikationsstil', s.communication_style, s, 'communication_style'), + csLcdField('Stimme & Sprache', s.voice_pattern, s, 'voice_pattern'), + ]), + csLcdSection('mdi-book-open-outline', 'Geschichte', [ + csLcdField('Hintergrund', s.backstory, s, 'backstory'), + csLcdField('Motivation', s.motivation, s, 'motivation'), + csLcdField('Ängste', s.fears, s, 'fears'), + ]), + csLcdSection('mdi-sword', 'Fähigkeiten', [ + csLcdField('Fertigkeiten', s.skills, s, 'skills'), + csLcdField('Besondere Fähigkeiten', s.capabilities, s, 'capabilities'), + attrs, + invHtml, + ]), + csLcdSection('mdi-shield-sword-outline', 'Konflikt', [ + csLcdField('Geheimnis / Fataler Fehler', s.secret, s, 'secret'), + csLcdField('Konfliktstil', s.conflict_style, s, 'conflict_style'), + csLcdField('Siegbedingung', s.win_condition, s, 'win_condition'), + ]), + csLcdSection('mdi-account-group-outline', 'Beziehungen', [csLcdField('', s.relationships, s, 'relationships')], true), + csLcdSection('mdi-note-text-outline', 'Notizen', [csLcdField('', s.notes, s, 'notes')], true), + ].filter(Boolean); + // Nothing filled in yet (this character's turn in the passage + // sequence hasn't come up) — a bare empty gap here reads as broken + // ("did my data disappear?"), so say plainly that it's still coming. + return sections.length ? sections.join('') : `
This character's profile hasn't been generated yet — it fills in as the passage containing their scenes is processed.
`; + })()}
-
- ${identityHtml} - ${csField('Physical', s.physical, s, 'physical')} - ${csField('Clothing & Appearance', s.clothing, s, 'clothing')} - ${csField('Alignment & Ethos', s.alignment, s, 'alignment')} - ${attrs} - ${csField('Trained Skills', s.skills, s, 'skills')} - ${csField('Capabilities', s.capabilities, s, 'capabilities')} - ${invHtml} - ${csField('Backstory & Origin', s.backstory, s, 'backstory')} - ${csField('Relationships', s.relationships, s, 'relationships')} - ${csField('Motivation', s.motivation, s, 'motivation')} - ${csField('Fears', s.fears, s, 'fears')} - ${csField('Mannerisms & Habits', s.mannerisms, s, 'mannerisms')} - ${s.voice_pattern ? `
Voice & Speech
${escHtml(String(s.voice_pattern))}${csSourceBadgeHtml(s, 'voice_pattern') ? ` ${csSourceBadgeHtml(s, 'voice_pattern')}` : ''}
` : ''} - ${csField('Dark Secret / Fatal Flaw', s.secret, s, 'secret')} - ${csField('Conflict Style', s.conflict_style, s, 'conflict_style')} - ${csField('Win Condition', s.win_condition, s, 'win_condition')} -
${srcHtml} ${promptHtml} +
+
Click a i mark next to a field above to see the exact sentence it was drawn from.
+
`; } @@ -1205,7 +1694,6 @@ function csToMarkdown(sheets) { for (const s of list) { md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`; if (s.aliases) md += `*also known as ${s.aliases}*\n`; - if (s.full_name) md += `- **Full name:** ${s.full_name}\n`; if (s.gender) md += `- **Gender:** ${s.gender}\n`; if (s.title) md += `- **Title:** ${s.title}\n`; if (s.profession) md += `- **Occupation:** ${s.profession}\n`; @@ -1260,34 +1748,31 @@ function csWireResultInteractions(root, sheets, sourceText, book, inline = false }); const bkCtx = book || ''; + // Used to just pop a bare file picker — no way to paste an image URL or + // regenerate one from the character's own image_prompt without leaving + // this view entirely. Opens the same full lightbox (pick from disk / URL / + // AI-regenerate) already used everywhere else in the app instead. root.querySelectorAll('.cs-avatar').forEach(av => { - av.addEventListener('click', e => { + av.addEventListener('click', async e => { e.stopPropagation(); const name = av.dataset.name; - const inp = document.createElement('input'); - inp.type = 'file'; inp.accept = 'image/*'; - inp.onchange = async () => { - const file = inp.files[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = async ev => { - const dataUrl = ev.target.result; - const id = `${bkCtx}::${name}`.toLowerCase(); - if (typeof clSetImage === 'function') await clSetImage(id, dataUrl); - const s = sheets.find(x => x.name === name); - if (s) s._image = dataUrl; - const card = root.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`); - if (card) { - const oldAv = card.querySelector('.cs-avatar'); - if (oldAv) { - oldAv.outerHTML = csAvatarHtml(s); - card.querySelector('.cs-avatar')?.addEventListener('click', av.onclick); - } - } - toast('Profile picture saved', 'success'); - }; - reader.readAsDataURL(file); - }; - inp.click(); + const s = sheets.find(x => x.name === name); + if (!s) return; + const id = `${bkCtx}::${name}`.toLowerCase(); + let rec = (typeof clGet === 'function') ? await clGet(id).catch(() => null) : null; + if (!rec && typeof clUpsert === 'function') rec = await clUpsert(bkCtx, s).catch(() => null); + if (!rec || typeof _openAvatarLightbox !== 'function') { + toast('Avatar editor is unavailable right now', 'error'); + return; + } + _openAvatarLightbox(rec, async (updatedRec) => { + s._image = updatedRec?.image || rec.image || ''; + const card = root.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`); + if (card) { + const oldAv = card.querySelector('.cs-avatar'); + if (oldAv) oldAv.outerHTML = csAvatarHtml(s); + } + }); }); }); @@ -1299,6 +1784,40 @@ function csWireResultInteractions(root, sheets, sourceText, book, inline = false }); }); + root.querySelectorAll('.cs-auto-refine-btn').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const name = String(btn.dataset.name || '').trim(); + if (!name) return; + if (typeof window.csForReaderSelective === 'function') { + await window.csForReaderSelective([name]); + return; + } + toast('Character refinement is unavailable right now', 'error'); + }); + }); + + root.querySelectorAll('.cs-edit-btn').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const name = String(btn.dataset.name || '').trim(); + if (!name || typeof clEdit !== 'function') return; + try { + const all = typeof clGetAll === 'function' ? await clGetAll() : []; + const wantBook = String(book || '').trim().toLowerCase(); + const rec = all.find(r => { + const recName = String(r?.name || r?.sheet?.name || '').trim().toLowerCase(); + const recBook = String(r?.book || '').trim().toLowerCase(); + return recName === name.toLowerCase() && (!wantBook || recBook === wantBook); + }) || all.find(r => String(r?.name || r?.sheet?.name || '').trim().toLowerCase() === name.toLowerCase()); + if (!rec) { toast('Character record not found in library', 'error'); return; } + clEdit(rec.id); + } catch (err) { + toast('Could not open editor', 'error'); + } + }); + }); + root.querySelectorAll('.cs-img-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); @@ -1378,12 +1897,116 @@ function csWireResultInteractions(root, sheets, sourceText, book, inline = false }); }); - root.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => { + root.querySelectorAll('.cs-prompt-act').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const card = btn.closest('.cs-card'); + const name = card?.dataset.name; + const sheet = sheets.find(s => s.name === name); + if (!sheet) return; + const act = btn.dataset.act; + let rec = (typeof clUpsert === 'function') ? await clUpsert(book || '', sheet).catch(() => null) : null; + if (!rec) { toast('Could not save this character yet — try again', 'error'); return; } + if (act === 'voice') { + if (typeof _charDesignVoice === 'function') _charDesignVoice(rec); + return; + } + if (act === 'image') { + const orig = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Generating…'; + try { + await _charAutoGenerateImage(rec); + sheet._image = rec.image; + const av = card?.querySelector('.cs-avatar'); + if (av) av.outerHTML = csAvatarHtml(sheet); + toast('Profile image generated', 'success'); + } catch (err) { + toast('Image generation failed: ' + (err.message || err), 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = orig; + } + } + if (act === 'concept_art') { + const orig = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Generating…'; + try { + const img = await _charAutoGenerateConceptArt(rec); + sheet.concept_art_image = img; + const box = btn.closest('.cs-prompt-box'); + const body = box?.querySelector('.lcd-prompt-body'); + const existingPreview = body?.querySelector('.cs-concept-art-preview'); + if (existingPreview) existingPreview.querySelector('img').src = img; + else if (body) body.insertAdjacentHTML('afterbegin', `
Concept art
`); + btn.innerHTML = ' Regenerate Concept Art'; + toast('Concept art generated', 'success'); + } catch (err) { + toast('Concept art generation failed: ' + (err.message || err), 'error'); + btn.innerHTML = orig; + } finally { + btn.disabled = false; + } + } + }); + }); + + // Pencil toggle — makes this card's own field values editable in place, + // saving each field on its own debounce timer (one Map per card, so + // editing two fields within 900ms can't cancel each other's still-pending + // save the way a single shared timer did elsewhere in this app earlier + // this session). Fields that are currently empty aren't rendered at all + // (csField/csIdentityCell skip blank values), so adding a brand-new value + // to a field that has nothing yet still needs the full Library profile + // page — this only covers correcting a value that's already shown. + root.querySelectorAll('.cs-edit-toggle').forEach(btn => { + const card = btn.closest('.cs-card'); + if (!card) return; + const saveTimers = new Map(); btn.addEventListener('click', e => { + e.stopPropagation(); + const editing = card.classList.toggle('cs-editing'); + btn.innerHTML = editing ? '' : ''; + btn.title = editing ? 'Done editing' : 'Edit character sheet'; + card.querySelectorAll('.cs-field-editable[data-sheet-key]').forEach(el => { + el.contentEditable = editing ? 'true' : 'false'; + if (editing && !el.dataset.wired) { + el.dataset.wired = '1'; + el.addEventListener('input', () => { + const key = el.dataset.sheetKey; + const name = card.dataset.name; + const sheet = sheets.find(s => s.name === name); + if (!sheet || !key) return; + clearTimeout(saveTimers.get(key)); + saveTimers.set(key, setTimeout(async () => { + sheet[key] = el.textContent.trim(); + if (typeof csSaveToLibrary === 'function') await csSaveToLibrary(book, [sheet]); + }, 900)); + }); + } + }); + }); + }); + + root.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => { + btn.addEventListener('click', async e => { e.stopPropagation(); const pg = parseInt(btn.dataset.page, 10); - if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg); - else if (pg && typeof toast === 'function') toast(`Source: page ${pg}`, 'info'); + const quote = (btn.dataset.quote || '').trim(); + if (!pg) { toast('No page reference saved for this source', 'error'); return; } + if (typeof window.readerJumpToPage !== 'function') { toast(`Source: page ${pg}`, 'info'); return; } + await window.readerJumpToPage(pg); + // readerJumpToPage only scrolls to the TOP of the page — searching for + // the actual quoted sentence afterward highlights and scrolls to the + // precise line, so there's real surrounding context to read instead of + // "somewhere on this page, go find it yourself." + if (quote && typeof readerSearchApply === 'function') { + const term = quote.length > 60 ? quote.slice(0, 60) : quote; + readerSearchApply(term, { jump: true }); + const inp = document.getElementById('reader-search'); + if (inp) inp.value = term; + } }); }); } @@ -1405,10 +2028,29 @@ async function csShow(sheets, title, sourceText, book, hostEl = null) { } catch (_) {} } + // Browsing grid, not the profile itself — use the SAME compact card the + // Library's own character grid uses (click one to open the full profile) + // instead of a wall of full detail cards, which was unreadable and + // diverged from what every other cast grid in the app looks like. + // csSaveToLibrary (called by every caller before csShow) has already + // persisted these sheets, so the real records exist to look up. + const bkCtx = book || title || ''; + let allRecs = []; + try { allRecs = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(bkCtx) : []; } catch (_) { allRecs = []; } + const recByName = new Map(allRecs.map(r => [String(r.name || '').toLowerCase(), r])); + const recFor = (s) => recByName.get(String(s.name || '').toLowerCase()) + || { id: '', name: s.name, sheet: s, voice: s.voice, book: bkCtx, tags: '' }; + const recsById = new Map(); + const cardsFor = (list) => list.map(s => { + const rec = recFor(s); + if (rec.id) recsById.set(rec.id, rec); + return (typeof _charCardHtml === 'function') ? _charCardHtml(rec, allRecs) : csCardHtml(s); + }).join(''); + const main = sheets.filter(s => s.tier === 'main'); const supp = sheets.filter(s => s.tier !== 'main'); const group = (label, list) => list.length - ? `
${label}
` + list.map(csCardHtml).join('') + ? `
${label}
` + cardsFor(list) : ''; const ov = inline ? hostEl : document.createElement('div'); if (!inline) { @@ -1423,7 +2065,7 @@ async function csShow(sheets, title, sourceText, book, hostEl = null) {
-
${group('Main characters', main)}${group('Supporting characters', supp)}
+
${group('Main characters', main)}${group('Supporting characters', supp)}
`; document.body.appendChild(ov); ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); }); @@ -1440,10 +2082,23 @@ async function csShow(sheets, title, sourceText, book, hostEl = null) {
Generated sheets are saved into the cast library. Use the buttons above to jump back to the audiobook cast or continue to the roster stage.
-
${group('Main characters', main)}${group('Supporting characters', supp)}
+
${group('Main characters', main)}${group('Supporting characters', supp)}
`; } csWireResultInteractions(ov, sheets, sourceText, book, inline); + if (typeof _wireCharCards === 'function') { + // _charDetailPage defaults to the Library page's own #lib-chars-list + // container, which doesn't exist here (this grid lives on the Read + // Aloud "Character sheets" results screen, or in the standalone + // overlay) — without an explicit target, clicking a card silently did + // nothing. Point it at this grid's own list element instead, and wire + // "back" to redraw this same results grid rather than the Library. + const listEl = ov.querySelector('.cs-list') || ov; + _wireCharCards(ov, recsById, allRecs, null, { + container: listEl, + onBack: () => csShow(sheets, title, sourceText, book, hostEl), + }); + } } // ── Entry points (reader + rehearser) ──────────────────────────────────────── @@ -1457,6 +2112,77 @@ async function csSaveToLibrary(book, sheets) { } catch (e) { /* non-fatal — the overlay still works */ } } +// Wrap up a recast by filling the two "external tool" prompts (SillyTavern +// card + concept-art turnaround sheet) that aren't part of the passage-by- +// passage extraction schema — those need the character's COMPLETE profile in +// one shot, which only exists once the whole book has been scanned, so this +// runs as a background pass right after the main recast finishes rather than +// blocking it. Fire-and-forget from the caller's point of view: it shows its +// own progress/result toast and re-saves in place once done. +async function csAutoGenerateExternalPrompts(book, sheets) { + if (!Array.isArray(sheets) || !sheets.length) return; + const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' }; + let done = 0, failed = 0, repeatMsg = '', repeatCount = 0; + toast(`Generating SillyTavern + Concept Art prompts for ${sheets.length} character${sheets.length !== 1 ? 's' : ''}…`, 'info'); + for (const s of sheets) { + if (!s || !s.name) continue; + try { + const sample = [s.physical, s.backstory, s.motivation].filter(Boolean).join(' '); + const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : ''; + const r = await fetch('/api/character-generate-prompts', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: s.name, book, sheet: s, language, + llm_url: target.url, model: target.model, + fields: ['silly_tavern_prompt', 'concept_art_prompt'], + }), + }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); + const d = await r.json(); + if (d.silly_tavern_prompt) s.silly_tavern_prompt = d.silly_tavern_prompt; + if (d.concept_art_prompt) s.concept_art_prompt = d.concept_art_prompt; + done++; repeatCount = 0; + } catch (e) { + failed++; + const msg = (e && e.message) ? e.message : String(e); + console.error('[auto external prompts]', s.name, e); + // Same guard as the Library's bulk actions: 3 identical failures in a + // row means something systemic (LLM down, bad config), not a + // per-character fluke — stop instead of grinding through everyone. + if (msg === repeatMsg) { repeatCount++; } else { repeatMsg = msg; repeatCount = 1; } + if (repeatCount >= 3) break; + } + } + try { await clUpsertMany(book, sheets); } catch (_) {} + const suffix = failed ? ` (${failed} failed${repeatMsg ? ': ' + repeatMsg.slice(0, 160) : ''})` : ''; + toast(`External prompts generated for ${done} character${done !== 1 ? 's' : ''}${suffix}`, failed && !done ? 'error' : 'success'); + + // Concept art IMAGES, not just the prompt text — runs after clUpsertMany + // above so each character's record already exists to attach the image to + // (_charAutoGenerateConceptArt fetches-then-writes by id via clGet/clPut). + // Previously this prompt only ever produced text that a user had to notice + // and click "Generate Concept Art" on manually, per character. + if (typeof _charAutoGenerateConceptArt !== 'function') return; + const withPrompt = sheets.filter(s => s && s.name && _libStr(s.concept_art_prompt).trim()); + if (!withPrompt.length) return; + let imgDone = 0, imgFailed = 0, imgRepeatMsg = '', imgRepeatCount = 0; + toast(`Generating concept art for ${withPrompt.length} character${withPrompt.length !== 1 ? 's' : ''}…`, 'info'); + for (const s of withPrompt) { + try { + await _charAutoGenerateConceptArt({ id: clKey(book, s.name), sheet: s }); + imgDone++; imgRepeatCount = 0; + } catch (e) { + imgFailed++; + const msg = (e && e.message) ? e.message : String(e); + console.error('[auto concept art]', s.name, e); + if (msg === imgRepeatMsg) { imgRepeatCount++; } else { imgRepeatMsg = msg; imgRepeatCount = 1; } + if (imgRepeatCount >= 3) break; + } + } + const imgSuffix = imgFailed ? ` (${imgFailed} failed${imgRepeatMsg ? ': ' + imgRepeatMsg.slice(0, 160) : ''})` : ''; + toast(`Concept art generated for ${imgDone} character${imgDone !== 1 ? 's' : ''}${imgSuffix}`, imgFailed && !imgDone ? 'error' : 'success'); +} + function csGoToLibrary() { if (typeof navTo === 'function') navTo('s-library'); if (typeof navLibraryView === 'function') navLibraryView('characters'); @@ -1477,38 +2203,73 @@ function csAttachLineCounts(sheets, counts) { }); } -async function csForReader() { +async function csForReader(opts = {}) { + // fresh: "new recast" — discards every existing character SHEET (details, + // not the dialogue cast itself) and regenerates every profile from a blank + // slate, instead of the normal path which reuses/refreshes what's already + // saved. Used when the sheets have drifted enough that patching them isn't + // enough anymore. + const fresh = !!opts.fresh; const text = csReaderText(); const book = readerState.title || 'Untitled book'; const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0); if (typeof navTo === 'function') navTo('s-reader'); if (typeof showReaderView === 'function') showReaderView('chars'); + // navTo('s-reader') triggers the reader's own un-awaited async setup + // (window.readerOnShow — refreshes the backend/voice selects, re-renders + // the saved-books library, and can apply a pending window._readerStartView + // once that settles). If any of that resolves AFTER this view switch, it + // can silently flip the view back to the main reading screen a moment + // later — confirmed live: clicking "Cast from Reader" sometimes landed + // back on the plain Source view with a "resuming at sentence…" toast + // instead of starting the cast. Re-asserting the view shortly after wins + // that race regardless of which async step caused it. + if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120); const pageHost = csReaderPageHost(); - if (_cs.cache[key]) { + if (!fresh && _cs.cache[key]) { await csSaveToLibrary(book, _cs.cache[key]); if (pageHost) await csShow(_cs.cache[key], book, text, book, pageHost); return; } const knownRoster = csKnownReaderRoster(); let seedSheets = []; - try { + if (!fresh) try { if (typeof clGetAllByTagOrBook === 'function') { const existing = await clGetAllByTagOrBook(book); - seedSheets = (existing || []).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim()); + // The Library keeps every character record ever saved for this book, + // including ones from earlier, less accurate casting attempts (before a + // recast/merge/quality run cleaned things up) — without filtering, that + // stale history leaks back in here as "known" characters even though + // the CURRENT cast (knownRoster, from the live audiobook roster) no + // longer has them. Keep only saved records that still match a name in + // the current cast, so this list stays in sync with what step 2 shows. + const rosterSet = new Set(knownRoster.map(n => n.toLowerCase())); + const stillCast = rec => !rosterSet.size || rosterSet.has(String(rec?.name || '').trim().toLowerCase()); + seedSheets = (existing || []).filter(stillCast).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim()); } } catch (_) { seedSheets = []; } - const sheets = await csGenerate(text, key, knownRoster, { pageHost, seedSheets }); + // Computed up front (not just after the whole run finishes) so "Lines" + // shows immediately in the live preview instead of staying blank until + // generation completes. + const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook; + const lineCounts = new Map(); + if (ab?.segments?.length) { + for (const s of ab.segments) { + if (s?.type !== 'dialogue' || !s.speaker) continue; + const k = String(s.speaker).trim().toLowerCase(); + lineCounts.set(k, (lineCounts.get(k) || 0) + 1); + } + } + const sheets = await csGenerate(text, key, knownRoster, { pageHost, seedSheets, lineCounts }); if (!sheets) return; if (!sheets.length) { toast('No characters found', 'error'); return; } - const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook; - if (ab?.roster) { - const counts = new Map(); - ab.roster.forEach(function (info, name) { counts.set(String(name).trim().toLowerCase(), info.count || 0); }); - csAttachLineCounts(sheets, counts); + if (lineCounts.size) { + csAttachLineCounts(sheets, lineCounts); } await csSaveToLibrary(book, sheets); if (pageHost) await csShow(sheets, book, text, book, pageHost); toast(sheets.length + ' character sheets saved — Library → Cast', 'success'); + csAutoGenerateExternalPrompts(book, sheets); } // Refresh sheet data for a hand-picked subset of an already-cast book's @@ -1523,6 +2284,16 @@ async function csForReaderSelective(selectedNames) { const book = readerState.title || 'Untitled book'; if (typeof navTo === 'function') navTo('s-reader'); if (typeof showReaderView === 'function') showReaderView('chars'); + // navTo('s-reader') triggers the reader's own un-awaited async setup + // (window.readerOnShow — refreshes the backend/voice selects, re-renders + // the saved-books library, and can apply a pending window._readerStartView + // once that settles). If any of that resolves AFTER this view switch, it + // can silently flip the view back to the main reading screen a moment + // later — confirmed live: clicking "Cast from Reader" sometimes landed + // back on the plain Source view with a "resuming at sentence…" toast + // instead of starting the cast. Re-asserting the view shortly after wins + // that race regardless of which async step caused it. + if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120); const pageHost = csReaderPageHost(); let records = []; try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; } @@ -1594,6 +2365,16 @@ async function csForRehearser() { const key = 'reh:' + title + ':' + ((rehState.lines || []).length); if (typeof navTo === 'function') navTo('s-reader'); if (typeof showReaderView === 'function') showReaderView('chars'); + // navTo('s-reader') triggers the reader's own un-awaited async setup + // (window.readerOnShow — refreshes the backend/voice selects, re-renders + // the saved-books library, and can apply a pending window._readerStartView + // once that settles). If any of that resolves AFTER this view switch, it + // can silently flip the view back to the main reading screen a moment + // later — confirmed live: clicking "Cast from Reader" sometimes landed + // back on the plain Source view with a "resuming at sentence…" toast + // instead of starting the cast. Re-asserting the view shortly after wins + // that race regardless of which async step caused it. + if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120); const pageHost = csReaderPageHost(); if (_cs.cache[key]) { await csSaveToLibrary(book, _cs.cache[key]); diff --git a/static/js/characters-library.js b/static/js/characters-library.js index c00048e..42d04c8 100644 --- a/static/js/characters-library.js +++ b/static/js/characters-library.js @@ -6,13 +6,16 @@ // Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics. const CL_EDIT_FIELDS = [ - ['name', 'Name'], ['aliases', 'Aliases / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['full_name', 'Full name'], ['title', 'Title / role'], + ['name', 'Name'], ['aliases', 'Aliases / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['title', 'Title / role'], + ['age_estimate', 'Estimated age'], ['race_species', 'Race / species'], ['languages', 'Languages'], + ['nationality_background', 'Nationality / background'], ['social_class', 'Social class'], ['archetype', 'Archetype'], ['physical', 'Physical'], ['clothing', 'Clothing & Appearance'], ['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'], ['skills', 'Trained Skills'], ['capabilities', 'Capabilities'], ['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'], ['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'], + ['communication_style', 'Communication style'], ['reputation', 'Reputation'], ['religious_beliefs', 'Religious beliefs'], ['notes', 'Notes'], ['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'], ['silly_tavern_prompt', 'SillyTavern Character Prompt'], ['concept_art_prompt', 'Concept Art Prompt'], ['secret', 'Dark Secret / Fatal Flaw'], @@ -46,7 +49,12 @@ async function clPut(rec) { body: JSON.stringify(rec), }); if (!r.ok) throw new Error('clPut failed: ' + r.status); - return r.json(); + const saved = await r.json(); + // Lets an already-open Stage/rehearsal for this same book pick up a voice + // change immediately instead of silently keeping the old one — see + // _rehSyncCastVoiceFromLibrary's own comment for the full story. + if (typeof window._rehSyncCastVoiceFromLibrary === 'function') window._rehSyncCastVoiceFromLibrary(saved); + return saved; } async function clDelete(id) { @@ -77,7 +85,14 @@ function clNormalizeColor(color, name) { return clHslToHex(clNameHue(name), 58, 43); } -const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; +// Deliberately excludes 'title'/'profession' etc. — mirrors CS_IDENTITY_FIELDS +// in character-sheets.js (see the comment there): those are purely +// descriptive text that unrelated characters routinely share verbatim (job +// titles, epithets like "the Executioner" reused for a different character +// later in the book), so treating a shared title as proof of shared identity +// silently merged two different characters' persisted library records into +// one, corrupting both. Keep this list in sync with CS_IDENTITY_FIELDS. +const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name']; const CL_ALIAS_MAX_TOKENS = 12; const CL_ALIAS_MAX_CHARS = 500; // Bare articles/pronouns can end up as "aliases" when a descriptive alias like @@ -175,13 +190,27 @@ function clMergeTags(...parts) { } // Upsert one sheet into the library under a book. Returns the stored record. -async function clUpsert(book, sheet) { +// `knownId` bypasses the alias-based identity guess below entirely — pass it +// whenever the caller already holds a concrete, previously-loaded record +// (e.g. saving a voice/image pick from that record's own card/row) rather +// than a freshly-extracted sheet with no stable home yet. Without it, a +// character whose OWN alias list happens to also name a different character +// in the same book (confirmed live: an LLM-extracted "aliases" field for one +// character literally included another character's real name — an +// extraction slip, not a genuine same-person case) silently redirects the +// write to that OTHER character's record instead, since clSameIdentity only +// needs one alias token to overlap. Alias matching is still exactly right +// for its original purpose — deduping a freshly-extracted sheet against +// whatever's already stored — just not for updating a record the caller can +// already point at directly by id. +async function clUpsert(book, sheet, knownId) { const name = (sheet.name || '').trim(); if (!name) return null; const bk = (book || '').trim() || 'Unsorted'; - const all = await clGetAll().catch(() => []); - const aliasPrev = all.find(r => clSameIdentity(r, bk, sheet)); - const id = aliasPrev?.id || clKey(bk, name); + const aliasPrev = knownId + ? await clGet(knownId).catch(() => null) + : (await clGetAll().catch(() => [])).find(r => clSameIdentity(r, bk, sheet)); + const id = aliasPrev?.id || knownId || clKey(bk, name); const now = new Date(); const prev = aliasPrev || await clGet(id).catch(() => null); const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name }; @@ -195,8 +224,17 @@ async function clUpsert(book, sheet) { sheet: merged, color, analysis: prev?.analysis || null, - voice: prev?.voice || sheet.voice || null, - image: prev?.image || sheet.image || null, + // An explicit new value from the caller (e.g. picking a different voice + // in the picker) must win over whatever was already stored — this used + // to be `prev.voice || sheet.voice`, so once a character had ANY voice, + // every later reassignment silently no-op'd: the picker showed a + // "success" toast and updated the in-memory rec, but the persisted + // record kept the OLD voice forever, with the wrong voice then used for + // every audio-generation pass. Character-sheet regeneration passes never + // set voice/image at all, so falling back to prev here is still correct + // for that path. + voice: sheet.voice || prev?.voice || null, + image: sheet.image || prev?.image || null, created: prev?.created || now, updated: now, }; @@ -323,7 +361,6 @@ function clApplyFilter() { (r.sheet?.aliases || '').toLowerCase().includes(q) || (r.sheet?.first_name || '').toLowerCase().includes(q) || (r.sheet?.last_name || '').toLowerCase().includes(q) || - (r.sheet?.full_name || '').toLowerCase().includes(q) || (r.sheet?.title || '').toLowerCase().includes(q) || (r.sheet?.archetype || '').toLowerCase().includes(q) || (r.tags || '').toLowerCase().includes(q) || @@ -356,17 +393,12 @@ function clApplyFilter() { } function clCardHtml(rec) { - const tags = String(rec.tags || '').split(',').map(t => t.trim()).filter(Boolean); - const chips = tags.length - ? `
${tags.map(t => `${escHtml(t)}`).join('')}
` - : ''; return `
- ${csCardHtml(rec.sheet)} - ${chips} + ${typeof csOverviewCardHtml === 'function' ? csOverviewCardHtml(rec) : csCardHtml(rec.sheet)}
`; } diff --git a/static/js/fishaudio-browser.js b/static/js/fishaudio-browser.js index 8456ced..29235b6 100644 --- a/static/js/fishaudio-browser.js +++ b/static/js/fishaudio-browser.js @@ -145,7 +145,7 @@ const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; const lang = (v.language || 'EN').slice(0, 2).toUpperCase(); - const base = (v.title || 'fishaudio').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice'; + const base = (typeof _umlautSafe === 'function' ? _umlautSafe(v.title || 'fishaudio') : (v.title || 'fishaudio')).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice'; const voiceId = `${lang}_${base}`; try { const r = await fetch('/api/quick-import-voice', { diff --git a/static/js/i18n.js b/static/js/i18n.js index 8e3ad5c..4915ddd 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -8,8 +8,15 @@ // window.t('English') for JS-generated strings. Dynamic lists can be translated by // calling window.applyI18n(container) after rendering. -const I18N_LANGS = { en: 'English', de: 'Deutsch' }; +const I18N_LANGS = { + en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español', + it: 'Italiano', pt: 'Português', nl: 'Nederlands', pl: 'Polski', +}; +// Every language block below covers the exact same key set — the English UI +// chrome strings (nav, section titles/subtitles, common buttons/labels). +// Adding a new key? Add it to ALL blocks, or it silently falls back to +// English for languages that don't have it yet (see window.t / applyI18n). const I18N_DICT = { de: { // Brand / sidebar groups @@ -64,15 +71,351 @@ const I18N_DICT = { 'All genders': 'Alle Geschlechter', 'Microphone': 'Mikrofon', 'Upload file': 'Datei hochladen', 'Sort': 'Sortieren', 'Cards': 'Karten', 'List': 'Liste', 'Develop': 'Ausarbeiten', 'Match local': 'Lokal zuordnen', 'Match online': 'Online zuordnen', 'Design all': 'Alle entwerfen', - 'I play this': 'Ich spiele das', 'Save to library': 'In Bibliothek speichern', + 'I play this': 'Ich spiele das', 'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen', 'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript', 'Preview': 'Vorschau', 'Trim your sample': 'Probe zuschneiden', 'Language': 'Sprache', - 'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Tags': 'Tags', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt', + 'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt', // Common placeholders 'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...', 'Filter name or tag…': 'Name oder Tag filtern…', }, + + fr: { + 'Voice Creator': 'Voice Creator', + 'Clone · Design · Deploy': 'Cloner · Concevoir · Déployer', + 'Voices': 'Voix', 'Setup': 'Configuration', 'Tags': 'Étiquettes', + 'My Voices': 'Mes voix', 'All voices': 'Toutes les voix', 'Cloned': 'Clonées', + 'Designed': 'Conçues', 'Favorites': 'Favoris', 'Hidden': 'Masquées', + 'Library tools': 'Outils de bibliothèque', + 'Clone a Voice': 'Cloner une voix', 'Design a Voice': 'Concevoir une voix', + 'Get Voices Online': 'Obtenir des voix en ligne', 'Try It Out': 'Essayer', + 'Read Aloud': 'Lecture à voix haute', + 'Script Rehearser': 'Répétition de script', 'Library': 'Bibliothèque', 'Cast': 'Distribution', + 'Stage': 'Scène', 'Summary': 'Résumé', 'Import / Export': 'Import / Export', + 'Conversation': 'Conversation', 'Benchmark': 'Benchmark', 'Engines': 'Moteurs', + 'Language Models': 'Modèles de langage', 'Speech to Text': 'Voix vers texte', + 'Text to Speech': 'Texte vers voix', 'App Routing': "Routage de l'app", + 'Connect Apps': 'Connecter des apps', 'Settings': 'Paramètres', + 'Conversation Playground': 'Terrain de jeu conversation', + 'Pick a voice on the left, edit on the right.': 'Choisissez une voix à gauche, modifiez-la à droite.', + 'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.': + "Enregistrez 3 à 20 secondes de parole claire, découpez-la, nommez-la, puis enregistrez-la comme clone de voix réutilisable.", + 'Describe a voice in words and let the AI create it. No recording needed.': + "Décrivez une voix avec des mots et laissez l'IA la créer. Aucun enregistrement nécessaire.", + 'Browse public voice clip sources, preview direct audio files, and import voices from the web.': + "Parcourez des sources publiques d'extraits vocaux, prévisualisez des fichiers audio et importez des voix depuis le web.", + 'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.': + "Générez de la parole à partir de texte avec n'importe quel moteur et voix. Transcrivez aussi l'audio et refaites-le parler.", + 'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.': + 'Téléversez un script, attribuez des voix TTS ou votre micro aux personnages, puis répétez scène par scène.', + 'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.': + "Importez un PDF ou un document texte, choisissez une voix et une vitesse, puis faites-le lire à voix haute avec surlignage du mot prononcé.", + 'My books': 'Mes livres', 'Voice consistency': 'Cohérence de la voix', + 'Normalise loudness': 'Normaliser le volume', 'Export MP3': 'Exporter en MP3', + 'Select range': 'Sélectionner une plage', 'Synthesise': 'Synthétiser', + 'Tone / style': 'Ton / style', 'Temperature': 'Température', 'Speed': 'Vitesse', + 'Saved documents with their synthesised audio — reopen to continue.': + 'Documents enregistrés avec leur audio synthétisé — rouvrez pour continuer.', + 'Save changes': 'Enregistrer les modifications', 'Save to library': 'Enregistrer dans la bibliothèque', + 'Save to Voice Library': 'Enregistrer dans la bibliothèque de voix', 'Delete voice': 'Supprimer la voix', + 'Cancel': 'Annuler', 'Refresh': 'Actualiser', 'Delete': 'Supprimer', 'Save': 'Enregistrer', + 'Back': 'Retour', 'Apply →': 'Appliquer →', 'Browse': 'Parcourir', 'Download': 'Télécharger', + 'Record': 'Enregistrer', 'Stop': 'Arrêter', 'Play': 'Lire', 'Play selection': 'Lire la sélection', + 'Check level': 'Vérifier le niveau', 'Stop monitor': 'Arrêter le moniteur', 'Auto trim': 'Découpe auto', + 'Auto-transcribe': 'Transcription auto', 'Active': 'Actif', 'copy ID': "copier l'ID", + 'edit ID': "modifier l'ID", 'New voice': 'Nouvelle voix', 'All languages': 'Toutes les langues', + 'All genders': 'Tous les genres', 'Microphone': 'Microphone', 'Upload file': 'Téléverser un fichier', + 'Sort': 'Trier', 'Cards': 'Cartes', 'List': 'Liste', 'Develop': 'Développer', + 'Match local': 'Correspondance locale', 'Match online': 'Correspondance en ligne', 'Design all': 'Tout concevoir', + 'I play this': 'Je joue ce personnage', + 'Open rehearsal': 'Ouvrir la répétition', 'Fetch voices': 'Récupérer les voix', + 'Name your voice': 'Nommez votre voix', 'Reference transcript': 'Transcription de référence', + 'Preview': 'Aperçu', 'Trim your sample': 'Découpez votre échantillon', 'Language': 'Langue', + 'Gender': 'Genre', 'Voice': 'Voix', 'Speaking style · voice-design prompt': "Style d'élocution · invite de conception vocale", + 'Search voices…': 'Rechercher des voix…', 'Search voices...': 'Rechercher des voix...', + 'Filter name or tag…': 'Filtrer par nom ou étiquette…', + }, + + es: { + 'Voice Creator': 'Voice Creator', + 'Clone · Design · Deploy': 'Clonar · Diseñar · Implementar', + 'Voices': 'Voces', 'Setup': 'Configuración', 'Tags': 'Etiquetas', + 'My Voices': 'Mis voces', 'All voices': 'Todas las voces', 'Cloned': 'Clonadas', + 'Designed': 'Diseñadas', 'Favorites': 'Favoritos', 'Hidden': 'Ocultas', + 'Library tools': 'Herramientas de biblioteca', + 'Clone a Voice': 'Clonar una voz', 'Design a Voice': 'Diseñar una voz', + 'Get Voices Online': 'Obtener voces en línea', 'Try It Out': 'Probarlo', + 'Read Aloud': 'Leer en voz alta', + 'Script Rehearser': 'Ensayo de guion', 'Library': 'Biblioteca', 'Cast': 'Reparto', + 'Stage': 'Escenario', 'Summary': 'Resumen', 'Import / Export': 'Importar / Exportar', + 'Conversation': 'Conversación', 'Benchmark': 'Benchmark', 'Engines': 'Motores', + 'Language Models': 'Modelos de lenguaje', 'Speech to Text': 'Voz a texto', + 'Text to Speech': 'Texto a voz', 'App Routing': 'Enrutamiento de la app', + 'Connect Apps': 'Conectar apps', 'Settings': 'Ajustes', + 'Conversation Playground': 'Zona de pruebas de conversación', + 'Pick a voice on the left, edit on the right.': 'Elige una voz a la izquierda, edítala a la derecha.', + 'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.': + 'Graba de 3 a 20 segundos de habla limpia, recórtala, nómbrala y guárdala como un clon de voz reutilizable.', + 'Describe a voice in words and let the AI create it. No recording needed.': + 'Describe una voz con palabras y deja que la IA la cree. No se necesita grabación.', + 'Browse public voice clip sources, preview direct audio files, and import voices from the web.': + 'Explora fuentes públicas de clips de voz, previsualiza archivos de audio e importa voces desde la web.', + 'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.': + 'Genera voz a partir de texto con cualquier motor y voz. También transcribe audio y vuelve a reproducirlo hablado.', + 'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.': + 'Sube un guion, asigna voces TTS o tu propio micrófono a los personajes y ensaya escena por escena.', + 'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.': + 'Importa un PDF o documento de texto, elige una voz y velocidad, y escúchalo mientras se resalta la palabra que se está leyendo.', + 'My books': 'Mis libros', 'Voice consistency': 'Consistencia de voz', + 'Normalise loudness': 'Normalizar volumen', 'Export MP3': 'Exportar MP3', + 'Select range': 'Seleccionar rango', 'Synthesise': 'Sintetizar', + 'Tone / style': 'Tono / estilo', 'Temperature': 'Temperatura', 'Speed': 'Velocidad', + 'Saved documents with their synthesised audio — reopen to continue.': + 'Documentos guardados con su audio sintetizado — vuelve a abrirlos para continuar.', + 'Save changes': 'Guardar cambios', 'Save to library': 'Guardar en la biblioteca', + 'Save to Voice Library': 'Guardar en la biblioteca de voces', 'Delete voice': 'Eliminar voz', + 'Cancel': 'Cancelar', 'Refresh': 'Actualizar', 'Delete': 'Eliminar', 'Save': 'Guardar', + 'Back': 'Atrás', 'Apply →': 'Aplicar →', 'Browse': 'Explorar', 'Download': 'Descargar', + 'Record': 'Grabar', 'Stop': 'Detener', 'Play': 'Reproducir', 'Play selection': 'Reproducir selección', + 'Check level': 'Comprobar nivel', 'Stop monitor': 'Detener monitor', 'Auto trim': 'Recorte automático', + 'Auto-transcribe': 'Transcripción automática', 'Active': 'Activo', 'copy ID': 'copiar ID', + 'edit ID': 'editar ID', 'New voice': 'Nueva voz', 'All languages': 'Todos los idiomas', + 'All genders': 'Todos los géneros', 'Microphone': 'Micrófono', 'Upload file': 'Subir archivo', + 'Sort': 'Ordenar', 'Cards': 'Tarjetas', 'List': 'Lista', 'Develop': 'Desarrollar', + 'Match local': 'Coincidencia local', 'Match online': 'Coincidencia en línea', 'Design all': 'Diseñar todo', + 'I play this': 'Yo interpreto esto', + 'Open rehearsal': 'Abrir ensayo', 'Fetch voices': 'Obtener voces', + 'Name your voice': 'Nombra tu voz', 'Reference transcript': 'Transcripción de referencia', + 'Preview': 'Vista previa', 'Trim your sample': 'Recorta tu muestra', 'Language': 'Idioma', + 'Gender': 'Género', 'Voice': 'Voz', 'Speaking style · voice-design prompt': 'Estilo de habla · prompt de diseño de voz', + 'Search voices…': 'Buscar voces…', 'Search voices...': 'Buscar voces...', + 'Filter name or tag…': 'Filtrar por nombre o etiqueta…', + }, + + it: { + 'Voice Creator': 'Voice Creator', + 'Clone · Design · Deploy': 'Clona · Progetta · Distribuisci', + 'Voices': 'Voci', 'Setup': 'Configurazione', 'Tags': 'Tag', + 'My Voices': 'Le mie voci', 'All voices': 'Tutte le voci', 'Cloned': 'Clonate', + 'Designed': 'Progettate', 'Favorites': 'Preferiti', 'Hidden': 'Nascoste', + 'Library tools': 'Strumenti libreria', + 'Clone a Voice': 'Clona una voce', 'Design a Voice': 'Progetta una voce', + 'Get Voices Online': 'Ottieni voci online', 'Try It Out': 'Prova', + 'Read Aloud': 'Leggi ad alta voce', + 'Script Rehearser': 'Prova script', 'Library': 'Libreria', 'Cast': 'Cast', + 'Stage': 'Palco', 'Summary': 'Riepilogo', 'Import / Export': 'Importa / Esporta', + 'Conversation': 'Conversazione', 'Benchmark': 'Benchmark', 'Engines': 'Motori', + 'Language Models': 'Modelli linguistici', 'Speech to Text': 'Voce in testo', + 'Text to Speech': 'Testo in voce', 'App Routing': 'Instradamento app', + 'Connect Apps': 'Connetti app', 'Settings': 'Impostazioni', + 'Conversation Playground': 'Area di prova conversazione', + 'Pick a voice on the left, edit on the right.': 'Scegli una voce a sinistra, modificala a destra.', + 'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.': + 'Registra 3-20 secondi di voce pulita, taglia, assegna un nome e salva come clone vocale riutilizzabile.', + 'Describe a voice in words and let the AI create it. No recording needed.': + "Descrivi una voce a parole e lascia che l'IA la crei. Nessuna registrazione necessaria.", + 'Browse public voice clip sources, preview direct audio files, and import voices from the web.': + 'Sfoglia fonti pubbliche di clip vocali, anteprima file audio e importa voci dal web.', + 'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.': + "Genera voce dal testo con qualsiasi backend e voce. Trascrivi anche l'audio e falla riparlare.", + 'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.': + 'Carica uno script, assegna voci TTS o il tuo microfono ai personaggi, poi prova scena per scena.', + 'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.': + "Importa un PDF o un documento di testo, scegli voce e velocità, e fallo leggere ad alta voce con evidenziazione della parola pronunciata.", + 'My books': 'I miei libri', 'Voice consistency': 'Coerenza vocale', + 'Normalise loudness': 'Normalizza volume', 'Export MP3': 'Esporta MP3', + 'Select range': 'Seleziona intervallo', 'Synthesise': 'Sintetizza', + 'Tone / style': 'Tono / stile', 'Temperature': 'Temperatura', 'Speed': 'Velocità', + 'Saved documents with their synthesised audio — reopen to continue.': + 'Documenti salvati con il loro audio sintetizzato — riaprili per continuare.', + 'Save changes': 'Salva modifiche', 'Save to library': 'Salva nella libreria', + 'Save to Voice Library': 'Salva nella libreria vocale', 'Delete voice': 'Elimina voce', + 'Cancel': 'Annulla', 'Refresh': 'Aggiorna', 'Delete': 'Elimina', 'Save': 'Salva', + 'Back': 'Indietro', 'Apply →': 'Applica →', 'Browse': 'Sfoglia', 'Download': 'Scarica', + 'Record': 'Registra', 'Stop': 'Ferma', 'Play': 'Riproduci', 'Play selection': 'Riproduci selezione', + 'Check level': 'Controlla livello', 'Stop monitor': 'Ferma monitor', 'Auto trim': 'Taglio automatico', + 'Auto-transcribe': 'Trascrizione automatica', 'Active': 'Attivo', 'copy ID': 'copia ID', + 'edit ID': 'modifica ID', 'New voice': 'Nuova voce', 'All languages': 'Tutte le lingue', + 'All genders': 'Tutti i generi', 'Microphone': 'Microfono', 'Upload file': 'Carica file', + 'Sort': 'Ordina', 'Cards': 'Schede', 'List': 'Elenco', 'Develop': 'Sviluppa', + 'Match local': 'Abbinamento locale', 'Match online': 'Abbinamento online', 'Design all': 'Progetta tutto', + 'I play this': 'Interpreto io questo', + 'Open rehearsal': 'Apri prova', 'Fetch voices': 'Recupera voci', + 'Name your voice': 'Assegna un nome alla voce', 'Reference transcript': 'Trascrizione di riferimento', + 'Preview': 'Anteprima', 'Trim your sample': 'Taglia il tuo campione', 'Language': 'Lingua', + 'Gender': 'Genere', 'Voice': 'Voce', 'Speaking style · voice-design prompt': 'Stile di parlato · prompt di progettazione vocale', + 'Search voices…': 'Cerca voci…', 'Search voices...': 'Cerca voci...', + 'Filter name or tag…': 'Filtra per nome o tag…', + }, + + pt: { + 'Voice Creator': 'Voice Creator', + 'Clone · Design · Deploy': 'Clonar · Projetar · Implantar', + 'Voices': 'Vozes', 'Setup': 'Configuração', 'Tags': 'Etiquetas', + 'My Voices': 'Minhas vozes', 'All voices': 'Todas as vozes', 'Cloned': 'Clonadas', + 'Designed': 'Projetadas', 'Favorites': 'Favoritos', 'Hidden': 'Ocultas', + 'Library tools': 'Ferramentas da biblioteca', + 'Clone a Voice': 'Clonar uma voz', 'Design a Voice': 'Projetar uma voz', + 'Get Voices Online': 'Obter vozes online', 'Try It Out': 'Experimentar', + 'Read Aloud': 'Leitura em voz alta', + 'Script Rehearser': 'Ensaio de roteiro', 'Library': 'Biblioteca', 'Cast': 'Elenco', + 'Stage': 'Palco', 'Summary': 'Resumo', 'Import / Export': 'Importar / Exportar', + 'Conversation': 'Conversa', 'Benchmark': 'Benchmark', 'Engines': 'Motores', + 'Language Models': 'Modelos de linguagem', 'Speech to Text': 'Voz para texto', + 'Text to Speech': 'Texto para voz', 'App Routing': 'Roteamento do app', + 'Connect Apps': 'Conectar apps', 'Settings': 'Configurações', + 'Conversation Playground': 'Espaço de teste de conversa', + 'Pick a voice on the left, edit on the right.': 'Escolha uma voz à esquerda, edite à direita.', + 'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.': + 'Grave de 3 a 20 segundos de fala limpa, corte, nomeie e salve como um clone de voz reutilizável.', + 'Describe a voice in words and let the AI create it. No recording needed.': + 'Descreva uma voz com palavras e deixe a IA criá-la. Não é necessária gravação.', + 'Browse public voice clip sources, preview direct audio files, and import voices from the web.': + 'Navegue por fontes públicas de clipes de voz, pré-visualize arquivos de áudio e importe vozes da web.', + 'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.': + 'Gere fala a partir de texto com qualquer backend e voz. Também transcreva áudio e reproduza-o falado.', + 'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.': + 'Envie um roteiro, atribua vozes TTS ou seu próprio microfone aos personagens e ensaie cena por cena.', + 'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.': + 'Importe um PDF ou documento de texto, escolha uma voz e velocidade, e ouça-o enquanto a palavra falada é destacada.', + 'My books': 'Meus livros', 'Voice consistency': 'Consistência de voz', + 'Normalise loudness': 'Normalizar volume', 'Export MP3': 'Exportar MP3', + 'Select range': 'Selecionar intervalo', 'Synthesise': 'Sintetizar', + 'Tone / style': 'Tom / estilo', 'Temperature': 'Temperatura', 'Speed': 'Velocidade', + 'Saved documents with their synthesised audio — reopen to continue.': + 'Documentos salvos com seu áudio sintetizado — reabra para continuar.', + 'Save changes': 'Salvar alterações', 'Save to library': 'Salvar na biblioteca', + 'Save to Voice Library': 'Salvar na biblioteca de vozes', 'Delete voice': 'Excluir voz', + 'Cancel': 'Cancelar', 'Refresh': 'Atualizar', 'Delete': 'Excluir', 'Save': 'Salvar', + 'Back': 'Voltar', 'Apply →': 'Aplicar →', 'Browse': 'Procurar', 'Download': 'Baixar', + 'Record': 'Gravar', 'Stop': 'Parar', 'Play': 'Reproduzir', 'Play selection': 'Reproduzir seleção', + 'Check level': 'Verificar nível', 'Stop monitor': 'Parar monitor', 'Auto trim': 'Corte automático', + 'Auto-transcribe': 'Transcrição automática', 'Active': 'Ativo', 'copy ID': 'copiar ID', + 'edit ID': 'editar ID', 'New voice': 'Nova voz', 'All languages': 'Todos os idiomas', + 'All genders': 'Todos os gêneros', 'Microphone': 'Microfone', 'Upload file': 'Enviar arquivo', + 'Sort': 'Ordenar', 'Cards': 'Cartões', 'List': 'Lista', 'Develop': 'Desenvolver', + 'Match local': 'Correspondência local', 'Match online': 'Correspondência online', 'Design all': 'Projetar tudo', + 'I play this': 'Eu interpreto isto', + 'Open rehearsal': 'Abrir ensaio', 'Fetch voices': 'Buscar vozes', + 'Name your voice': 'Nomeie sua voz', 'Reference transcript': 'Transcrição de referência', + 'Preview': 'Pré-visualização', 'Trim your sample': 'Corte sua amostra', 'Language': 'Idioma', + 'Gender': 'Gênero', 'Voice': 'Voz', 'Speaking style · voice-design prompt': 'Estilo de fala · prompt de design de voz', + 'Search voices…': 'Buscar vozes…', 'Search voices...': 'Buscar vozes...', + 'Filter name or tag…': 'Filtrar por nome ou etiqueta…', + }, + + nl: { + 'Voice Creator': 'Voice Creator', + 'Clone · Design · Deploy': 'Klonen · Ontwerpen · Implementeren', + 'Voices': 'Stemmen', 'Setup': 'Instellingen', 'Tags': 'Tags', + 'My Voices': 'Mijn stemmen', 'All voices': 'Alle stemmen', 'Cloned': 'Gekloond', + 'Designed': 'Ontworpen', 'Favorites': 'Favorieten', 'Hidden': 'Verborgen', + 'Library tools': 'Bibliotheektools', + 'Clone a Voice': 'Een stem klonen', 'Design a Voice': 'Een stem ontwerpen', + 'Get Voices Online': 'Stemmen online ophalen', 'Try It Out': 'Uitproberen', + 'Read Aloud': 'Voorlezen', + 'Script Rehearser': 'Scriptrepetitie', 'Library': 'Bibliotheek', 'Cast': 'Cast', + 'Stage': 'Podium', 'Summary': 'Samenvatting', 'Import / Export': 'Importeren / Exporteren', + 'Conversation': 'Gesprek', 'Benchmark': 'Benchmark', 'Engines': 'Engines', + 'Language Models': 'Taalmodellen', 'Speech to Text': 'Spraak naar tekst', + 'Text to Speech': 'Tekst naar spraak', 'App Routing': 'App-routering', + 'Connect Apps': 'Apps verbinden', 'Settings': 'Instellingen', + 'Conversation Playground': 'Gesprek-speeltuin', + 'Pick a voice on the left, edit on the right.': 'Kies links een stem, bewerk rechts.', + 'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.': + 'Neem 3–20 seconden heldere spraak op, knip bij, geef een naam en sla op als herbruikbare stemkloon.', + 'Describe a voice in words and let the AI create it. No recording needed.': + 'Beschrijf een stem in woorden en laat de AI hem maken. Geen opname nodig.', + 'Browse public voice clip sources, preview direct audio files, and import voices from the web.': + 'Blader door openbare stemclipbronnen, bekijk audiobestanden vooraf en importeer stemmen van het web.', + 'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.': + 'Genereer spraak uit tekst met elke backend en stem. Transcribeer ook audio en spreek het opnieuw uit.', + 'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.': + 'Upload een script, wijs TTS-stemmen of je eigen microfoon toe aan personages en repeteer scène voor scène.', + 'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.': + 'Importeer een PDF of tekstdocument, kies een stem en snelheid, en laat het voorlezen terwijl het gesproken woord wordt gemarkeerd.', + 'My books': 'Mijn boeken', 'Voice consistency': 'Stemconsistentie', + 'Normalise loudness': 'Volume normaliseren', 'Export MP3': 'MP3 exporteren', + 'Select range': 'Bereik selecteren', 'Synthesise': 'Synthetiseren', + 'Tone / style': 'Toon / stijl', 'Temperature': 'Temperatuur', 'Speed': 'Snelheid', + 'Saved documents with their synthesised audio — reopen to continue.': + 'Opgeslagen documenten met hun gesynthetiseerde audio — heropen om verder te gaan.', + 'Save changes': 'Wijzigingen opslaan', 'Save to library': 'Opslaan in bibliotheek', + 'Save to Voice Library': 'Opslaan in stembibliotheek', 'Delete voice': 'Stem verwijderen', + 'Cancel': 'Annuleren', 'Refresh': 'Vernieuwen', 'Delete': 'Verwijderen', 'Save': 'Opslaan', + 'Back': 'Terug', 'Apply →': 'Toepassen →', 'Browse': 'Bladeren', 'Download': 'Downloaden', + 'Record': 'Opnemen', 'Stop': 'Stoppen', 'Play': 'Afspelen', 'Play selection': 'Selectie afspelen', + 'Check level': 'Niveau controleren', 'Stop monitor': 'Monitor stoppen', 'Auto trim': 'Automatisch bijsnijden', + 'Auto-transcribe': 'Automatisch transcriberen', 'Active': 'Actief', 'copy ID': 'ID kopiëren', + 'edit ID': 'ID bewerken', 'New voice': 'Nieuwe stem', 'All languages': 'Alle talen', + 'All genders': 'Alle geslachten', 'Microphone': 'Microfoon', 'Upload file': 'Bestand uploaden', + 'Sort': 'Sorteren', 'Cards': 'Kaarten', 'List': 'Lijst', 'Develop': 'Ontwikkelen', + 'Match local': 'Lokaal matchen', 'Match online': 'Online matchen', 'Design all': 'Alles ontwerpen', + 'I play this': 'Ik speel dit', + 'Open rehearsal': 'Repetitie openen', 'Fetch voices': 'Stemmen ophalen', + 'Name your voice': 'Geef je stem een naam', 'Reference transcript': 'Referentietranscript', + 'Preview': 'Voorbeeld', 'Trim your sample': 'Knip je sample bij', 'Language': 'Taal', + 'Gender': 'Geslacht', 'Voice': 'Stem', 'Speaking style · voice-design prompt': 'Spreekstijl · stemontwerp-prompt', + 'Search voices…': 'Stemmen zoeken…', 'Search voices...': 'Stemmen zoeken...', + 'Filter name or tag…': 'Filter op naam of tag…', + }, + + pl: { + 'Voice Creator': 'Voice Creator', + 'Clone · Design · Deploy': 'Klonuj · Projektuj · Wdrażaj', + 'Voices': 'Głosy', 'Setup': 'Konfiguracja', 'Tags': 'Tagi', + 'My Voices': 'Moje głosy', 'All voices': 'Wszystkie głosy', 'Cloned': 'Sklonowane', + 'Designed': 'Zaprojektowane', 'Favorites': 'Ulubione', 'Hidden': 'Ukryte', + 'Library tools': 'Narzędzia biblioteki', + 'Clone a Voice': 'Sklonuj głos', 'Design a Voice': 'Zaprojektuj głos', + 'Get Voices Online': 'Pobierz głosy online', 'Try It Out': 'Wypróbuj', + 'Read Aloud': 'Czytanie na głos', + 'Script Rehearser': 'Próba scenariusza', 'Library': 'Biblioteka', 'Cast': 'Obsada', + 'Stage': 'Scena', 'Summary': 'Podsumowanie', 'Import / Export': 'Import / Eksport', + 'Conversation': 'Rozmowa', 'Benchmark': 'Benchmark', 'Engines': 'Silniki', + 'Language Models': 'Modele językowe', 'Speech to Text': 'Mowa na tekst', + 'Text to Speech': 'Tekst na mowę', 'App Routing': 'Routing aplikacji', + 'Connect Apps': 'Połącz aplikacje', 'Settings': 'Ustawienia', + 'Conversation Playground': 'Plac zabaw rozmów', + 'Pick a voice on the left, edit on the right.': 'Wybierz głos po lewej, edytuj po prawej.', + 'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.': + 'Nagraj 3–20 sekund czystej mowy, przytnij, nazwij, a następnie zapisz jako wielokrotnego użytku klon głosu.', + 'Describe a voice in words and let the AI create it. No recording needed.': + 'Opisz głos słowami i pozwól AI go stworzyć. Nagrywanie nie jest potrzebne.', + 'Browse public voice clip sources, preview direct audio files, and import voices from the web.': + 'Przeglądaj publiczne źródła klipów głosowych, podglądaj pliki audio i importuj głosy z sieci.', + 'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.': + 'Generuj mowę z tekstu za pomocą dowolnego silnika i głosu. Transkrybuj też audio i wypowiedz je ponownie.', + 'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.': + 'Prześlij scenariusz, przypisz głosy TTS lub własny mikrofon do postaci, a następnie ćwicz scenę po scenie.', + 'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.': + 'Zaimportuj PDF lub dokument tekstowy, wybierz głos i prędkość, a następnie posłuchaj czytania z podświetlaniem wypowiadanego słowa.', + 'My books': 'Moje książki', 'Voice consistency': 'Spójność głosu', + 'Normalise loudness': 'Normalizuj głośność', 'Export MP3': 'Eksportuj MP3', + 'Select range': 'Wybierz zakres', 'Synthesise': 'Syntetyzuj', + 'Tone / style': 'Ton / styl', 'Temperature': 'Temperatura', 'Speed': 'Prędkość', + 'Saved documents with their synthesised audio — reopen to continue.': + 'Zapisane dokumenty z zsyntetyzowanym audio — otwórz ponownie, aby kontynuować.', + 'Save changes': 'Zapisz zmiany', 'Save to library': 'Zapisz w bibliotece', + 'Save to Voice Library': 'Zapisz w bibliotece głosów', 'Delete voice': 'Usuń głos', + 'Cancel': 'Anuluj', 'Refresh': 'Odśwież', 'Delete': 'Usuń', 'Save': 'Zapisz', + 'Back': 'Wstecz', 'Apply →': 'Zastosuj →', 'Browse': 'Przeglądaj', 'Download': 'Pobierz', + 'Record': 'Nagraj', 'Stop': 'Zatrzymaj', 'Play': 'Odtwórz', 'Play selection': 'Odtwórz zaznaczenie', + 'Check level': 'Sprawdź poziom', 'Stop monitor': 'Zatrzymaj monitor', 'Auto trim': 'Automatyczne przycinanie', + 'Auto-transcribe': 'Automatyczna transkrypcja', 'Active': 'Aktywny', 'copy ID': 'kopiuj ID', + 'edit ID': 'edytuj ID', 'New voice': 'Nowy głos', 'All languages': 'Wszystkie języki', + 'All genders': 'Wszystkie płcie', 'Microphone': 'Mikrofon', 'Upload file': 'Prześlij plik', + 'Sort': 'Sortuj', 'Cards': 'Karty', 'List': 'Lista', 'Develop': 'Rozwiń', + 'Match local': 'Dopasuj lokalnie', 'Match online': 'Dopasuj online', 'Design all': 'Zaprojektuj wszystko', + 'I play this': 'Ja to gram', + 'Open rehearsal': 'Otwórz próbę', 'Fetch voices': 'Pobierz głosy', + 'Name your voice': 'Nazwij swój głos', 'Reference transcript': 'Transkrypcja referencyjna', + 'Preview': 'Podgląd', 'Trim your sample': 'Przytnij próbkę', 'Language': 'Język', + 'Gender': 'Płeć', 'Voice': 'Głos', 'Speaking style · voice-design prompt': 'Styl mówienia · prompt projektowania głosu', + 'Search voices…': 'Szukaj głosów…', 'Search voices...': 'Szukaj głosów...', + 'Filter name or tag…': 'Filtruj po nazwie lub tagu…', + }, }; let _appLang = 'en'; diff --git a/static/js/integrations.js b/static/js/integrations.js index 4ace5b4..e185ecf 100644 --- a/static/js/integrations.js +++ b/static/js/integrations.js @@ -46,7 +46,7 @@ function integrationVoiceList() { function virtualDesignVoiceIds() { return Object.keys(loadDesignPresets ? loadDesignPresets() : {}) .sort((a,b)=>a.localeCompare(b)) - .map(name => 'vd_' + name.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, '')); + .map(name => 'vd_' + (typeof _umlautSafe === 'function' ? _umlautSafe(name) : name).replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, '')); } function renderIntegrationSnippets() { if (!$('snippet-sillytavern')) return; diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 17e680f..d63a516 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -7,10 +7,33 @@ async function libraryRenderCharacters() { const container = document.getElementById('lib-chars-list'); if (!container) return; + + // Fetch BEFORE touching the DOM: a transient failure here (e.g. the + // server briefly unresponsive while a TTS container restart is in + // flight) must never blank out an already-populated list — confirmed + // live as "No characters yet" flashing up over a real, populated cast + // while a bulk voice-design run elsewhere was mid-restart. Leave whatever + // is already on screen alone and let the next real refresh fix it. + let all = []; + try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } + catch (e) { + console.warn('[characters] load failed', e); + if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error'); + return; + } + + // Every action on this page (remove voice, auto-design, delete...) re-runs + // this full rebuild via `refresh()`. Collapsing the container down to a + // one-line loading placeholder mid-rebuild shrinks #main-content below the + // user's current scroll position, which the browser clamps back to fit — + // confirmed live as "the screen jumps to the top" after every single + // action. Restore it once the real content is back, unless a caller + // explicitly wants to jump to a specific book (see _libCharsScrollToBook + // below), which takes priority over just staying put. + const mainEl = document.getElementById('main-content'); + const savedScrollTop = (!window._libCharsScrollToBook && mainEl) ? mainEl.scrollTop : null; container.innerHTML = '
Loading characters…
'; - let all = []; - try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } catch (_) { all = []; } const byId = new Map(all.map(function (rec) { return [rec.id, rec]; })); if (!all.length) { @@ -25,6 +48,7 @@ async function libraryRenderCharacters() { container.querySelector('#lib-chars-import').addEventListener('click', function () { if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); }); }); + if (savedScrollTop != null) mainEl.scrollTop = savedScrollTop; return; } @@ -36,12 +60,37 @@ async function libraryRenderCharacters() { byBook[bk].push(rec); }); + // The Narrator reads every non-dialogue line but was never a real Library + // record (castWriteBack deliberately never creates one, to keep narrators + // out of the cast library) — so there was nowhere in Assign Voices to give + // it a voice at all; only the Script Rehearser's own Cast tab could set + // one, in a rehearsal-local field (rehState.narratorVoice) that isn't + // shared back here. Synthesize a placeholder per production so it shows up + // and can be picked/designed exactly like any other character; the first + // actual voice pick turns it into a real saved record via clUpsert same as + // any other card, and rehApplySharedCast now reads it back like any other + // shared cast entry (see rehearser.js). + Object.keys(byBook).forEach(function (bk) { + const hasNarrator = byBook[bk].some(function (r) { return String(r.name || '').trim().toLowerCase() === 'narrator'; }); + if (!hasNarrator) { + // Must also land in `byId` (built above from the real fetched records, + // before this synthesis) — _wireCharCards looks up every card's click + // target there and silently no-ops if it's missing, which is exactly + // why clicking this card did nothing at all. + const narrRec = { id: clKey(bk, 'Narrator'), book: bk, name: 'Narrator', tags: bk, voice: null, image: null, sheet: {} }; + byId.set(narrRec.id, narrRec); + byBook[bk].unshift(narrRec); + } + }); + container.innerHTML = ''; // Global toolbar — import a cast from SillyTavern into a new/unsorted production, // a Cards/Table view toggle, and a Sort dropdown (all persisted, like Script // Rehearser's cast-list controls). const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards'; + let returnToReader = false; + try { returnToReader = sessionStorage.getItem('ttsvc_cast_return') === 'reader'; } catch (_) {} const SORT_OPTIONS = [ ['tier', 'Rolle (Haupt zuerst)'], ['alpha', 'Alphabet'], @@ -53,7 +102,10 @@ async function libraryRenderCharacters() { ? localStorage.getItem('ttsvc_libchars_sort') : 'tier'; const bar = document.createElement('div'); bar.className = 'lib-chars-toolbar'; - bar.innerHTML = '' + bar.innerHTML = (returnToReader + ? '' + : '') + + '' + '