diff --git a/CHANGELOG.md b/CHANGELOG.md index f198131..83a097f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,119 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.20.5] — 2026-08-11 + +### Added +- **Emotion quick-pick added to Read Aloud, Try a Voice, and Conversation.** All three previously had either no style control at all (Conversation) or a free-text field that Fish-Speech silently ignores (Try a Voice, Read Aloud — it only reacts to an inline `[tag]` in the text). The new picker is backend-aware: on Fish-Speech it's applied as an inline tag on the text itself; on style-aware backends (VoiceDesign, CustomVoice) it fills the existing instruct field as before. +- **"Run until < N unknown…"** under Read Aloud's Identify Characters menu — repeats the recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a chosen target, or two passes in a row make no further progress (e.g. GPU/LLM contention). Previously only doable by calling `audiobookRecastUnknown()` directly from the browser console. + +### Fixed +- **Fish-Speech's per-line emotion tags were being read out loud as literal text instead of applied as silent tone control** (confirmed live: `(excited)` was spoken as "Hexited"). Fish-Speech's S2-Pro checkpoint requires **square brackets** (`[excited]`) for a tag to be treated as a control instruction — the app was sending round parentheses. Now that the user has Fish-Speech running (`fish-speech-api`, port 38080), the backend is confirmed working end-to-end: clones the reference WAV, keeps identity stable, and now actually applies the requested tone silently. Already auto-discovered under Engines → Text to Speech → Local, no configuration needed. +- **Character portrait prompts had no idea what kind of book they belonged to**, producing things like a WWI/WWII-era naval admiral, East-Asian-styled warriors, and an Asian-presenting priest in a Western medieval fantasy book (confirmed live with screenshots from a German DSA fantasy novel). The book-level context feature (`/api/book-profile` — genre/setting/era/language) already existed and was wired into voice-design prompts, but was never passed to `csBuildImagePrompt`. Also now includes the character's `race_species` field (human/elf/ork/etc., already collected by the casting LLM but previously unused downstream) in the prompt. Applies automatically to future portrait generations once a book profile is set on Read Aloud → Book Profile. +- **Fish-Speech emotion tags produced no audible effect on non-English books** (confirmed live on a German audiobook: audio quality was fine, but every line was flat/emotionless). Root cause was two-fold: (1) per-line auto emotions are LLM-generated in the book's own spoken language (e.g. German "bedrohlich"), but Fish-Speech's docs require **English** tags "regardless of the spoken language" — the untranslated German word was silently ignored; (2) a **double-tagging bug**: the client already embeds `[tag]` directly into the text sent to Fish-Speech, but the server was *also* independently deriving and prepending its own tag from the separately-sent Qwen3-TTS-style instruct sentence, producing things like `[Sprich in einem bedrohlich Tonfall.] [bedrohlich] …` — neither bracket was valid English, so both were ignored. Fixed by (a) adding a German→English emotion-word translation table used when building the client-side Fish tag, and (b) making the server skip its own tag derivation whenever the text already carries one, while still falling back to the original short-text-verbatim behavior for freely-typed style instructions (Read Aloud / Try a Voice) that don't match either template. Qwen3-TTS backends are unaffected — they still receive the native-language instruct sentence unchanged, which is what they're designed to understand. + +## [1.20.4] — 2026-08-10 + +### Fixed +- **"Casting audiobook" and "Casting unknown" could finish all their LLM work and then get permanently stuck showing "Stop Casting"**, with no error and no way to proceed, even though the underlying work had genuinely completed (confirmed live: GPU load back to idle, but the panel never left its "running" state). Root cause: in both `audiobookCast()` and `audiobookRecastUnknown()`, the post-processing that runs after the main loop finishes (deduping, rollback checks, saving the draft) was completely unguarded — any exception there meant execution never reached `view.complete()`, the only thing that actually resets the button and shows a result. Both now wrap that tail in their own try/catch, so a failure there still reaches a terminal state with a visible error instead of hanging forever. Note: this fix only applies to casting runs started after upgrading — a session already stuck in this state is running old code in memory and won't self-recover; reload and reopen the book, and the periodic autosave during casting means progress up to the point it finished should still be there to resume from. + +## [1.20.3] — 2026-08-10 + +### Fixed +- **Heading OCR now actually recognizes decorative chapter-heading images that combine an icon/border graphic with the text** (e.g. a bold octagonal badge around "3.Kapitel") — previously it failed silently on every single one of these in a real test book (0 of 16 chapters recovered), either finding no text at all or confidently misreading the border as a stray character, because Tesseract's default full-page layout analysis gets confused by the graphic surrounding the actual text. Root-caused by testing the real OCR engine directly against the actual failing page: cropping tightly to exclude the graphic (roughly the bottom half of the heading region, where centered chapter-title text typically sits below any icon) and telling Tesseract to expect a single line of text (page segmentation mode 7) fixed it completely — verified against all 16 chapter headings in the same book, all recovered correctly and cleanly (1.Kapitel through 16.Kapitel, no garbage, no duplicates). Falls back to the original untrimmed full-heading-region OCR for headings that are already plain text with no surrounding graphic, so this is additive, not a narrowing of what already worked. + +## [1.20.1] — 2026-08-10 + +### Fixed +- **A PDF page that produces zero extractable text (no real text layer AND heading OCR either found nothing or failed the confidence threshold — common for a page that's entirely a decorative divider graphic, e.g. one book had a page that was just a small icon with no chapter number at all) used to be completely invisible to page tracking**, silently shifting every subsequent page number out of sync with the actual PDF for the rest of the book. Confirmed live: investigated a real book's chapter-heading pages directly — one divider page rendered as literally just a small icon graphic (no OCR-recoverable text by design, not an OCR failure), and every such page was dropping out of `readerState.sentences` entirely, taking its page number with it. Every PDF page now keeps at least a placeholder marker (empty text — never spoken, never shown as a line) so page-break reconstruction after LLM speaker-attribution never skips a page number. + +## [1.20.0] — 2026-08-10 + +### Added +- **App Routing rows now have a playback-speed multiplier (0.5x-2x) and a preview button.** The speed is applied server-side to real routed requests via a pitch-preserving ffmpeg tempo change (not a naive frame-rate shift, which would make a sped-up voice sound like a chipmunk) — see `core/audio.py:_change_tempo`. The preview button synthesizes the row's exact output voice directly (no need to save the route first) and plays it back at the chosen speed for an instant "does this sound right" check, using a short phrase localized to the row's own language setting. + +## [1.19.4] — 2026-08-10 + +### Changed +- **Audiobook export now synthesizes lines one at a time instead of 2 concurrent workers.** Every TTS backend this app talks to (Voice Clone, Voice Design, Fish-Speech, and the other local engines) is a single self-hosted GPU model instance, not a horizontally-scaled service — confirmed live, twice now, with two different backends: 2 concurrent requests reliably push at least one past the reverse proxy's 60-second timeout under real load, causing seemingly-random per-line failures that can doom a whole multi-hour export. Serial is slower per line but doesn't waste time on doomed, retried requests — net faster in practice, and actually finishes. + +## [1.19.3] — 2026-08-10 + +### Fixed +- **The looser chapter-heading detection from 1.19.0 caused ordinary narration sentences to be misdetected as chapter breaks, replacing real paragraph text with a thin marker line — confirmed live as "lots of empty pages" in the A4 pagination view.** Two separate bugs: (1) requiring the keyword only at the start of the line, with nothing checked afterward, meant any short sentence starting with a common word like German "Teil" ("part") — e.g. "Teil des Grundes war unklar." ("Part of the reason was unclear.") — matched as a chapter; now whatever follows the keyword must actually look like part of a heading (empty, a bare number, or a colon/dash-separated subtitle), not a normal grammatical continuation. (2) The leading-numeral stripping regex treated a bare "C" as valid Roman numeral 100 with no requirement that anything sensible follow it — so it silently ate the "C" off the front of "Chapter", turning "Chapter 1: The Beginning" into "hapter 1: The Beginning" before the keyword check ever ran. Verified against the actual book that surfaced this (1998 lines): zero false positives, one correct real match ("14. Kapitel"). + +## [1.19.2] — 2026-08-10 + +### Fixed +- **The tone/identity comparison table always suggested Voice Design as the alternative to Voice Clone, never Fish-Speech, even when Fish-Speech was running** — the suggestion logic just took the first backend matching one criterion (`style_aware`, or `uses_wav`) rather than preferring one matching BOTH, so it never surfaced the strictly-better option (tone-aware AND keeps voice identity) over a partial fix. Now prefers a backend satisfying both properties before falling back to a partial match. + +## [1.19.1] — 2026-08-10 + +### Added +- **Chapter headings now render as a visible horizontal-rule marker with the chapter's own title/number in the Script Rehearser/Studio Stage view**, not just as an audible pause in the finished export. Reuses the exact same detection `audiobookExport()` uses for chapter/file boundaries, so the fix to that detection (numbered OCR headings like "1.Kapitel") shows up here too — scanning through a long script now makes chapter breaks visually obvious instead of looking like one continuous, undifferentiated wall of narration. + +## [1.19.0] — 2026-08-10 + +### Added +- **Audiobook exports now have real pauses between paragraphs and chapters, plus an optional custom chapter-transition sound** — `mergeWavBlobs()` used to concatenate every line's clip with literally zero gap, which read as characters teleporting mid-scene with no beat between paragraphs, let alone chapters. New "Pacing" section in Studio → Perform & Export lets you set the paragraph pause (default 2s) and chapter pause (default 4s) independently, and upload a short sound (chime, page-turn, etc., max 2 MB) to play before each chapter's pause — decoded and resampled client-side to exactly match the narration's own sample rate so it splices in cleanly rather than corrupting the merge. Settings persist per-browser via localStorage. + +### Fixed +- **Chapter headings recovered via OCR (from PDFs where the heading is baked into the page as an image) usually weren't recognised as chapters at all**, because `audiobookIsChapter()` required the keyword ("Kapitel"/"Chapter"/etc.) to be the very first word — but OCR'd numbered headings commonly read "1.Kapitel" or "I. Kapitel" with the number first. This silently defeated the documented "one file per chapter" audiobook export for any book using numbered image headings (confirmed live: a full novel exported as a single 7.5-hour file instead of per-chapter files). The chapter regex now tolerates an optional leading number or roman numeral before the keyword. + +## [1.18.23] — 2026-08-09 + +### Changed +- Moved the backend tone/identity comparison table into the "Generate full audiobook" toggle row's spare width, instead of rendering as its own full-width banner below the toolbar. + +## [1.18.22] — 2026-08-09 + +### Changed +- Redesigned the backend tone/identity warning as a proper comparison table (current backend vs. the suggested alternative, "Tone control" and "Voice stays identical" as columns with ✓/✗) instead of a run-on sentence with a "Switch" button awkwardly wedged into the middle of it. + +## [1.18.21] — 2026-08-09 + +### Changed +- "Skip narrator" now defaults to unchecked (narration reads by default) and moved to the front of the Stage toolbar. +- The backend tone-support warning ("Switch to X for reliable tone…") now has an actual "Switch to X" button instead of just naming the better backend in a sentence and leaving you to go find it yourself in a settings dropdown. + +## [1.18.20] — 2026-08-09 + +### Fixed +- **A single transient synthesis failure could silently doom an entire multi-hour audiobook export.** `fetchTtsPreviewBlob`'s own retry logic only covers connection-level failures — `fetch()` doesn't throw on a non-2xx HTTP response, so a backend hiccup (confirmed live: transient 500s clustered in the first ~50 lines, most likely GPU/engine warm-up contention from the two parallel export workers both starting cold) skipped that retry layer entirely and permanently failed the line. Different, completely ordinary lines failed across repeated attempts — never the same one twice — confirming this was never about any specific line's content. The export now retries a failed line up to 3 times with backoff before giving up on it for real. + +## [1.18.19] — 2026-08-09 + +### Fixed +- **Reverted the sticky "Generate full audiobook" toggle from 1.18.18 — it broke scrolling entirely.** Stacking two sticky headers (the toggle plus the already-sticky, now-wrapping multi-row transport bar below it) could exceed the viewport height on a real window, leaving nothing scrollable visible at all. The toggle now scrolls away normally again, same as before 1.18.18; only the transport bar stays sticky. Will revisit with the wrapped toolbar's actual height accounted for, tested in isolation before shipping again. + +## [1.18.18] — 2026-07-30 + +### Fixed +- **The font-size (-A/+A) buttons were ~18px shorter than every other toolbar button** (`.reh-fontsize-btn` overrode padding to 2px 7px against the base button's 9px 18px). Removing the override alone left a smaller residual gap (plain "A" text computes a shorter natural line-height than icon+text buttons) — matched the sibling buttons' actual rendered height directly instead of guessing at line-height multipliers. Verified live: exact match. +- **Studio's "Generate full audiobook" toggle wasn't sticky, so it scrolled out of reach while reading through a long script** — only the transport bar below it was. Both now stick together, stacked in the correct order, so the mode toggle and play controls stay visible throughout. + +## [1.18.15] — 2026-07-30 + +### Changed +- Renamed the Stage toolbar's "Skip desc." checkbox to "Skip narrator" — clearer about what it actually controls (whether the Narrator voice reads scene/action text during playback) now that the toggle genuinely works. + +## [1.18.14] — 2026-07-30 + +### Fixed +- **The Stage toolbar's font-size, page-mode, Train, and Exit buttons were unreachable on any normal (non-ultrawide) window.** `.reh-console-actions` had `flex-shrink: 0`, and flex items default to `min-width: auto` regardless of any width set — together these meant the block could never shrink below its full unwrapped width (~1560px), so the browser's wrapping algorithm never considered it "too wide to fit" and it just overflowed silently off the right edge with no scrollbar to reach it. Confirmed live at a realistic 800px window width. Now shrinks and wraps onto additional rows instead. + +## [1.18.13] — 2026-07-30 + +### Fixed +- **Narration playback ignored the "Skip descriptions" toggle (and Studio's "Rehearse ⇄ Audiobook" toggle, which just flips the same flag) once any narrator voice was assigned.** The batch pre-skip optimization correctly checked both "skip mode is on" and "no narrator voice set," but the actual per-line narration-speak branch below it never re-checked the skip flag at all — so as soon as a book had a narrator voice configured (the normal case for any book actually being produced), narration played regardless of the toggle's position. This is why the toggle looked like dead weight: it couldn't turn narration OFF, only ever left it stuck ON. Both branches now consistently respect the toggle. +- Studio's audiobook-mode toggle now warns clearly if you turn it on with no narrator voice assigned yet, instead of silently doing nothing. +- Root-caused "emotions still not recognisable" on Book 02's audiobook: its entire line-audio cache (1761 cached clips) was synthesized *before* the per-line emotion-instruct engine fix was actually deployed to the running container — confirmed by comparing file timestamps against the fix's deploy time. Since the cache key is a hash of (text + voice + instruct) and none of those changed, every future playback kept serving the identical pre-fix, flat-delivery audio forever, with the engine fix having no way to ever take effect. Cleared the stale cache; the next playback or export for Book 02 will synthesize fresh against the corrected engine. Book 01's cache was unaffected — it was built entirely after the fix. + + + +### Fixed +- **A voice that started as a Voice Design creation was routed to the Voice Design engine forever, even after it had a proper reference clip saved.** Voice Design has no seed parameter at all, so every playback of a designed voice was an unpinned, unreproducible roll regardless of any seed pinned for it. Routing now checks only whether a reference clip exists (`has_ref`) — the actual reason Voice Design is needed at all — not voice origin. A designed voice with a saved reference now clones like any other voice, which is what makes a pinned seed actually take effect for it. Applies to line playback throughout the app (Rehearsal, audiobook export, Try It Out) and to the Seed Finder's own backend default. + ## [1.18.11] — 2026-07-29 ### Fixed diff --git a/VERSION b/VERSION index 6961fed..7bf9455 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.18.11 +1.20.5 diff --git a/core/audio.py b/core/audio.py index d7ce619..786b8b7 100644 --- a/core/audio.py +++ b/core/audio.py @@ -30,6 +30,32 @@ def _to_wav_16k(src: Path) -> Path: return out +def _change_tempo(wav_bytes: bytes, speed: float) -> bytes: + """Time-stretch WAV audio without shifting pitch, via ffmpeg's atempo + filter — used by App Routing's per-route playback-speed setting. A naive + frame-rate change (or pydub's speedup(), which only accelerates and uses + a much cruder splice technique) shifts pitch along with speed, which + reads as a chipmunk/slow-motion effect rather than someone just talking + faster or slower. atempo only accepts 0.5-2.0 per instance; the caller + (core/routing.py's _normalize_route) already clamps to that range. + """ + speed = max(0.5, min(2.0, speed)) + in_path = TEMP_DIR / f"{uuid.uuid4().hex}_tempo_in.wav" + out_path = TEMP_DIR / f"{uuid.uuid4().hex}_tempo_out.wav" + in_path.write_bytes(wav_bytes) + try: + result = subprocess.run( + ["ffmpeg", "-y", "-i", str(in_path), "-filter:a", f"atempo={speed}", str(out_path)], + capture_output=True, timeout=30, + ) + if result.returncode != 0 or not out_path.exists(): + raise RuntimeError(result.stderr.decode(errors="replace").strip() or "ffmpeg atempo failed") + return out_path.read_bytes() + finally: + in_path.unlink(missing_ok=True) + out_path.unlink(missing_ok=True) + + def _trim(src: Path, start_s: float, end_s: float) -> Path: seg = AudioSegment.from_file(str(src)) trimmed = seg[int(start_s * 1000):int(end_s * 1000)] diff --git a/core/routing.py b/core/routing.py index d57749b..15f0af5 100644 --- a/core/routing.py +++ b/core/routing.py @@ -67,6 +67,13 @@ def _normalize_route(rule: dict, idx: int = 0) -> dict: if lang not in _ROUTE_LANGS: lang = "*" output_voice = _clean_route_token(rule.get("output_voice", ""), "") + try: + speed = float(rule.get("speed", 1.0) or 1.0) + except (TypeError, ValueError): + speed = 1.0 + # Clamped to ffmpeg's atempo range for a single filter pass (0.5-2.0) — + # see _apply_route_sounds, which is what actually applies this. + speed = round(max(0.5, min(2.0, speed)), 2) return { "id": _clean_route_token(rule.get("id", f"route_{idx+1}"), f"route_{idx+1}"), "enabled": bool(rule.get("enabled", True)), @@ -77,6 +84,7 @@ def _normalize_route(rule: dict, idx: int = 0) -> dict: "output_voice": output_voice, "before_sound": _clean_route_sound(rule.get("before_sound", "")), "after_sound": _clean_route_sound(rule.get("after_sound", "")), + "speed": speed, } diff --git a/core/tts_helpers.py b/core/tts_helpers.py index 8870a8b..44e619f 100644 --- a/core/tts_helpers.py +++ b/core/tts_helpers.py @@ -379,12 +379,40 @@ def _nvidia_clone_request_audio( # ── Fish-Speech request (clone from saved WAV + inline emotion markers) ────── +# Compact German→English fallback for the emotion word Qwen3-TTS's native-language +# instruct sentence carries (e.g. "Sprich in einem bedrohlich Tonfall.") — Fish-Speech's +# docs require English tags "regardless of the spoken language". This is only a +# server-side safety net for callers that never went through the client's own +# _rehInlineTone/_rehEmotionEnglishTag translation (static/js/rehearser.js) and only +# send `instruct`; keep it in sync with that JS table if it grows. +_FISHSPEECH_EMOTION_DE_EN = { + "wütend": "angry", "zornig": "angry", "traurig": "sad", "ängstlich": "scared", + "furchtsam": "fearful", "fröhlich": "happy", "glücklich": "happy", + "flüsternd": "whispering", "aufgeregt": "excited", "überrascht": "surprised", + "verzweifelt": "desperate", "resigniert": "resigned", "entschlossen": "determined", + "selbstbewusst": "confident", "schüchtern": "shy", "ironisch": "sarcastic", + "sarkastisch": "sarcastic", "verächtlich": "contemptuous", "ernst": "serious", + "streng": "stern", "befehlend": "commanding", "sanft": "gentle", "zärtlich": "tender", + "kalt": "cold", "gelangweilt": "bored", "geheimnisvoll": "mysterious", + "bedrohlich": "threatening", "dramatisch": "dramatic", "ruhig": "calm", + "schockiert": "shocked", "verwirrt": "confused", "weinend": "tearful", + "trauernd": "grieving", "schroff": "curt", "freundlich": "friendly", + "spielerisch": "playful", "romantisch": "romantic", "erleichtert": "relieved", + "neugierig": "curious", "müde": "weary", "bemerkend": "remarking", + "flehend": "pleading", "warnend": "warning", "trotzig": "defiant", + "erschrocken": "startled", +} + + def _fishspeech_emotion_prefix(instruct: str) -> str: """Turn the per-line style instruction into a Fish-Speech inline emotion marker. - The Rehearser sends ``"Speak in a {emotion} manner. {persona}"`` — the persona is - already carried by the cloned reference WAV, so we only forward the emotion as a - ``(emotion)`` tag, which Fish-Speech honours for per-line tone control. + Fish-Speech (S2-Pro) requires SQUARE brackets for a tag to be treated as a silent + control instruction — round parentheses get read aloud as literal text instead + (confirmed live: "(excited)" was spoken as "Hexited") — and the tag word itself + must be English regardless of the instruct sentence's own language (per Fish + Audio's docs). Matches both the EN template ("Speak in a X manner.") and the DE + template ("Sprich in einem X Tonfall.") from _BUILD_INSTRUCT_TEMPLATES. """ import re s = (instruct or "").strip() @@ -392,8 +420,24 @@ def _fishspeech_emotion_prefix(instruct: str) -> str: return "" m = re.search(r"speak(?:ing)?\s+in\s+(?:a|an)\s+([a-z\- ]+?)\s+manner", s, re.I) if m: - return f"({m.group(1).strip().lower()}) " - return f"({s}) " if len(s) <= 40 else "" + # Matched the EN template — the word is already English, use as-is. + return f"[{m.group(1).strip().lower()}] " + m = re.search(r"sprich\s+in\s+einem\s+([a-zäöüß\- ]+?)\s+tonfall", s, re.I) + if m: + # Matched the DE template — the word is German and MUST be translated; + # it will almost always be pure a-z letters too, so there is no reliable + # way to tell "already English" apart from "German" by charset alone here. + word = m.group(1).strip().lower() + tag = _FISHSPEECH_EMOTION_DE_EN.get(word, "") + return f"[{tag}] " if tag else "" + # Free-typed style instruction (Read Aloud / Try a Voice let you type anything, + # not just the two fixed templates above) — no reliable way to tell English apart + # from another language here, so fall back to the original behavior: use it + # verbatim if short enough to plausibly be a tag. Confirmed as the pre-existing, + # working behavior for manually-typed English instructions on those two pages; + # only the automated Rehearser/Studio pipeline's two known templates are handled + # more precisely above. + return f"[{s}] " if len(s) <= 40 else "" def _fishspeech_request_audio( @@ -419,8 +463,13 @@ def _fishspeech_request_audio( # Stable per-voice seed → reduces run-to-run drift on top of the reference clone. seed = int(hashlib.md5(voice.encode("utf-8")).hexdigest()[:8], 16) + # The Rehearser/Studio pipeline already embeds an English [tag] straight into + # `text` client-side (_rehInlineTone in rehearser.js) — only fall back to deriving + # one from `instruct` here for callers that don't (e.g. a direct API call that + # skips the client helper), to avoid double-tagging the same line. + already_tagged = text.lstrip().startswith("[") payload = { - "text": _fishspeech_emotion_prefix(instruct) + text, + "text": text if already_tagged else _fishspeech_emotion_prefix(instruct) + text, "format": "wav", "references": [{"audio": audio_b64, "text": ref_text or ""}], "seed": seed, @@ -587,14 +636,27 @@ def _apply_route_sounds(audio: bytes, media_type: str, route: dict | None, setti return audio, media_type, [] before = _route_sound_path(settings, str(route.get("before_sound", ""))) after = _route_sound_path(settings, str(route.get("after_sound", ""))) - if not before and not after: + try: + speed = float(route.get("speed", 1.0) or 1.0) + except (TypeError, ValueError): + speed = 1.0 + has_speed = abs(speed - 1.0) > 0.01 + if not before and not after and not has_speed: return audio, media_type, [] source_format = "wav" if audio[:4] == b"RIFF" or "wav" in media_type.lower() else None speech = AudioSegment.from_file(io.BytesIO(audio), format=source_format) speech = speech.set_channels(1).set_sample_width(2).set_frame_rate(24000) - combined = AudioSegment.empty() applied = [] + if has_speed: + # Only the spoken voice is stretched — a before/after chime or + # page-turn sound stays at its own natural speed, added after. + from core.audio import _change_tempo + buf = io.BytesIO() + speech.export(buf, format="wav") + speech = AudioSegment.from_file(io.BytesIO(_change_tempo(buf.getvalue(), speed)), format="wav") + applied.append(f"speed:{speed}x") + combined = AudioSegment.empty() if before: combined += _sound_segment(before) applied.append(f"before:{before.name}") diff --git a/routes/conversation.py b/routes/conversation.py index 0eec581..f0e3a19 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -2735,21 +2735,42 @@ async def conversation_llm_models(url: str = "", api_key: str = ""): return {"models": [], "url": base, "error": str(e)} +# Conversation had no style/emotion control at all before this — every reply +# synthesized flat regardless of backend. Mirrors the same backend-aware split +# used for the Rehearser/Studio pipeline: Fish-Speech only reacts to an inline +# [tag] in the text itself (the instruct field is ignored), other backends +# take the descriptive phrase directly as instruct. `emotion` here is always +# already-English (the REH_EMOTIONS quick-pick list), so no translation table +# is needed the way the German-templated audiobook instruct sentences needed one. +def _conv_tts_text_and_instruct(text: str, emotion: str, backend: str) -> tuple[str, str]: + emotion = (emotion or "").strip() + if not emotion: + return text, "" + if re.search(r"fish", backend or "", re.I): + tag = emotion.split(",")[0].strip().lower() + if re.fullmatch(r"[a-z\- ]+", tag): + return f"[{tag}] {text}", "" + return text, "" + return text, emotion + + def _make_tts_task( text: str, voice: str, settings: dict, backend: str, sem: "asyncio.Semaphore | None", + emotion: str = "", ) -> "asyncio.Task": + tts_text, instruct = _conv_tts_text_and_instruct(text, emotion, backend) if sem is None: return asyncio.create_task( - asyncio.to_thread(_preview_request_audio, text, voice, settings, "", backend) + asyncio.to_thread(_preview_request_audio, tts_text, voice, settings, instruct, backend) ) async def _guarded() -> tuple[bytes, str]: async with sem: - return await asyncio.to_thread(_preview_request_audio, text, voice, settings, "", backend) + return await asyncio.to_thread(_preview_request_audio, tts_text, voice, settings, instruct, backend) return asyncio.create_task(_guarded()) @@ -2763,6 +2784,7 @@ async def conversation_turn( llm_model: str = Form(""), tts_backend: str = Form("voice_clone"), tts_voice: str = Form(""), + tts_emotion: str = Form(""), system_prompt: str = Form("You are a helpful voice assistant. Keep replies short and conversational."), history: str = Form("[]"), ): @@ -3004,7 +3026,7 @@ async def conversation_turn( if tts_first_start is None: tts_first_start = time.monotonic() tts_texts.append(chunk_text) - tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem)) + tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem, tts_emotion)) except Exception as exc: yield sse({"type": "error", "stage": "llm", "message": str(exc)}) return @@ -3023,7 +3045,7 @@ async def conversation_turn( if tts_first_start is None: tts_first_start = time.monotonic() tts_texts.append(sent_buf.strip()) - tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem)) + tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem, tts_emotion)) llm_total_ms = int((time.monotonic() - t_llm) * 1000) yield sse({"type": "llm_done", "text": llm_text, diff --git a/routes/tts.py b/routes/tts.py index 699b26b..b1b0f38 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -892,9 +892,15 @@ async def openai_speech_proxy(request: Request): backend = _route_backend(route, voice) style_instruction = str(data.get("instruct") or data.get("style_instruction") or "") virtual = _resolve_virtual_voice(voice) + def _route_speed(r): + try: + return float((r or {}).get("speed", 1.0) or 1.0) + except (TypeError, ValueError): + return 1.0 route_has_sounds = bool((route or {}).get("before_sound") or (route or {}).get("after_sound")) + route_has_speed = abs(_route_speed(route) - 1.0) > 0.01 - if backend == "streaming" and not virtual and response_format == "wav" and not route_has_sounds: + if backend == "streaming" and not virtual and response_format == "wav" and not route_has_sounds and not route_has_speed: try: resp = await asyncio.to_thread(_open_tts_stream_response, text, voice, settings, style_instruction) except Exception as e: diff --git a/static/dist/main.min.js b/static/dist/main.min.js index d2405e2..7baecb5 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,_pc,_qc,_rc,_sc,_tc,_uc;(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,_uc,_vc;(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)} @@ -130,7 +130,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,_
- `,body.appendChild(panel);let _batchLoaded=!1;panel.querySelector(".opt-group-title").addEventListener("click",()=>{panel.classList.toggle("open"),panel.classList.contains("open")&&!_batchLoaded&&(_batchLoaded=!0,_loadBatchSamples())});const textEl=panel.querySelector(".seed-finder-text"),fromEl=panel.querySelector(".seed-finder-from"),toEl=panel.querySelector(".seed-finder-to"),backendEl=panel.querySelector(".seed-finder-backend"),runBtn=panel.querySelector(".seed-finder-run-btn"),cancelBtn=panel.querySelector(".seed-finder-cancel-btn"),clearBtn=panel.querySelector(".seed-finder-clear-btn"),statusEl=panel.querySelector(".seed-finder-status"),progressEl=panel.querySelector(".seed-finder-progress"),progLabel=panel.querySelector(".seed-finder-progress-label"),progCount=panel.querySelector(".seed-finder-progress-count"),progBar=panel.querySelector(".seed-finder-bar"),resultsEl=panel.querySelector(".seed-finder-results");textEl.value=_seedFinderDefaultText(voiceId);const pinInput=panel.querySelector(".seed-finder-pin-input"),pinBtn=panel.querySelector(".seed-finder-pin-btn"),pinClear=panel.querySelector(".seed-finder-pin-clear"),pinStatus=panel.querySelector(".seed-finder-pin-status"),voiceObj=(window._voices||[]).find(v=>v.id===voiceId)||{};voiceObj.seed!==void 0&&voiceObj.seed!==null&&(pinInput.value=voiceObj.seed,pinStatus.textContent=`\u2713 Pinned seed ${voiceObj.seed}`),(voiceObj.origin==="designed"||!voiceObj.has_ref)&&(backendEl.value="voice_design");let _cancelled=!1,_currentAudio=null;async function _loadBatchSamples(){try{const resp=await fetch(`/api/seed-samples/${encodeURIComponent(voiceId)}`);if(!resp.ok)return;const{seeds}=await resp.json();if(!seeds||seeds.length===0)return;resultsEl.innerHTML="";const header=document.createElement("div");header.className="sfr-batch-header",header.textContent=`${seeds.length} pre-generated sample${seeds.length!==1?"s":""} from batch run \u2014 click Play to listen`,resultsEl.appendChild(header);for(const seed of seeds){const row=document.createElement("div");row.className="seed-finder-result-row sfr-batch",row.dataset.seed=seed,row.innerHTML=` + `,body.appendChild(panel);let _batchLoaded=!1;panel.querySelector(".opt-group-title").addEventListener("click",()=>{panel.classList.toggle("open"),panel.classList.contains("open")&&!_batchLoaded&&(_batchLoaded=!0,_loadBatchSamples())});const textEl=panel.querySelector(".seed-finder-text"),fromEl=panel.querySelector(".seed-finder-from"),toEl=panel.querySelector(".seed-finder-to"),backendEl=panel.querySelector(".seed-finder-backend"),runBtn=panel.querySelector(".seed-finder-run-btn"),cancelBtn=panel.querySelector(".seed-finder-cancel-btn"),clearBtn=panel.querySelector(".seed-finder-clear-btn"),statusEl=panel.querySelector(".seed-finder-status"),progressEl=panel.querySelector(".seed-finder-progress"),progLabel=panel.querySelector(".seed-finder-progress-label"),progCount=panel.querySelector(".seed-finder-progress-count"),progBar=panel.querySelector(".seed-finder-bar"),resultsEl=panel.querySelector(".seed-finder-results");textEl.value=_seedFinderDefaultText(voiceId);const pinInput=panel.querySelector(".seed-finder-pin-input"),pinBtn=panel.querySelector(".seed-finder-pin-btn"),pinClear=panel.querySelector(".seed-finder-pin-clear"),pinStatus=panel.querySelector(".seed-finder-pin-status"),voiceObj=(window._voices||[]).find(v=>v.id===voiceId)||{};voiceObj.seed!==void 0&&voiceObj.seed!==null&&(pinInput.value=voiceObj.seed,pinStatus.textContent=`\u2713 Pinned seed ${voiceObj.seed}`),voiceObj.has_ref||(backendEl.value="voice_design");let _cancelled=!1,_currentAudio=null;async function _loadBatchSamples(){try{const resp=await fetch(`/api/seed-samples/${encodeURIComponent(voiceId)}`);if(!resp.ok)return;const{seeds}=await resp.json();if(!seeds||seeds.length===0)return;resultsEl.innerHTML="";const header=document.createElement("div");header.className="sfr-batch-header",header.textContent=`${seeds.length} pre-generated sample${seeds.length!==1?"s":""} from batch run \u2014 click Play to listen`,resultsEl.appendChild(header);for(const seed of seeds){const row=document.createElement("div");row.className="seed-finder-result-row sfr-batch",row.dataset.seed=seed,row.innerHTML=` Seed ${seed} pre-generated @@ -308,7 +308,7 @@ curl -X POST ${proxyBase}/speak \\
${escHtml(path)}
${escHtml(duration||"-")} ${type?"\xB7 "+escHtml(type):""}
-
`}).join("")}async function openRouteSoundBrowser(row,target){_routeSoundPickerTarget={row,target},await loadRouteSounds(),renderRouteSoundBrowser();const panel=$("routing-sound-browser");panel&&(panel.hidden=!1,panel.scrollIntoView({block:"nearest",behavior:"smooth"}));const label=target==="before"?"before sound":"after sound",note=$("routing-sound-browser-note");note&&(note.textContent=`Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`)}function closeRouteSoundBrowser(){const panel=$("routing-sound-browser");panel&&(panel.hidden=!0);const audio=$("routing-sound-preview");audio&&(audio.pause(),audio.hidden=!0,audio.removeAttribute("src")),_routeSoundPlayingButton&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=null,_routeSoundPickerTarget=null}function playRouteSound(path,btn){const audio=$("routing-sound-preview");!audio||!path||(_routeSoundPlayingButton&&_routeSoundPlayingButton!==btn&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=btn,btn.textContent="\u275A\u275A",audio.hidden=!1,audio.src=routeSoundUrl(path),audio.onended=()=>{btn.textContent="\u25B6"},audio.onpause=()=>{_routeSoundPlayingButton===btn&&(btn.textContent="\u25B6")},audio.onplay=()=>{btn.textContent="\u275A\u275A"},audio.play().catch(e=>{btn.textContent="\u25B6",toast("Sound preview failed: "+e.message,"error")}))}function useRouteSound(path,target){const selected=_routeSoundPickerTarget||{},row=selected.row||document.querySelector(".routing-row"),useTarget=target||selected.target||"before";setRouteSoundField(row,useTarget,path),toast(`${useTarget==="before"?"Before":"After"} sound selected`,"success")}function routeBackendOptions(value){const current=value||"voice_clone";return ROUTE_BACKENDS.map(([code,label])=>``).join("")}function refreshRoutingVoiceOptions(){const dl=$("routing-voice-options");if(dl){const ids=[...activeVoiceIds(),...virtualDesignVoiceIds()];dl.innerHTML=[...new Set(ids)].map(id=>``).join("")}const soundsDl=$("routing-sound-options");soundsDl&&(soundsDl.innerHTML=_routeSounds.map(sound=>``).join(""))}function newRoute(app="Open WebUI",inputVoice="default",language="*",outputVoice=""){return{id:"route_"+Date.now().toString(36)+"_"+Math.random().toString(36).slice(2,6),enabled:!0,app,input_voice:inputVoice,language,backend:"voice_clone",output_voice:outputVoice,before_sound:"",after_sound:""}}function renderRoutingList(){if($("routing-list")){if(refreshRoutingVoiceOptions(),$("routing-proxy-url").textContent=getCreatorV1Url(),updateCreatorUrlHints(),$("routing-status").textContent=_ttsRoutes.length?`${_ttsRoutes.length} route${_ttsRoutes.length===1?"":"s"}`:"No routes yet.",!_ttsRoutes.length){$("routing-list").innerHTML='
No routing rules yet. Add a route or add the Open WebUI default examples.
';return}$("routing-list").innerHTML=_ttsRoutes.map((r,i)=>` + `}).join("")}async function openRouteSoundBrowser(row,target){_routeSoundPickerTarget={row,target},await loadRouteSounds(),renderRouteSoundBrowser();const panel=$("routing-sound-browser");panel&&(panel.hidden=!1,panel.scrollIntoView({block:"nearest",behavior:"smooth"}));const label=target==="before"?"before sound":"after sound",note=$("routing-sound-browser-note");note&&(note.textContent=`Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`)}function closeRouteSoundBrowser(){const panel=$("routing-sound-browser");panel&&(panel.hidden=!0);const audio=$("routing-sound-preview");audio&&(audio.pause(),audio.hidden=!0,audio.removeAttribute("src")),_routeSoundPlayingButton&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=null,_routeSoundPickerTarget=null}function playRouteSound(path,btn){const audio=$("routing-sound-preview");!audio||!path||(_routeSoundPlayingButton&&_routeSoundPlayingButton!==btn&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=btn,btn.textContent="\u275A\u275A",audio.hidden=!1,audio.playbackRate=1,audio.src=routeSoundUrl(path),audio.onended=()=>{btn.textContent="\u25B6"},audio.onpause=()=>{_routeSoundPlayingButton===btn&&(btn.textContent="\u25B6")},audio.onplay=()=>{btn.textContent="\u275A\u275A"},audio.play().catch(e=>{btn.textContent="\u25B6",toast("Sound preview failed: "+e.message,"error")}))}const ROUTE_SPEED_PREVIEW_TEXT={EN:"This is a quick playback speed test.",DE:"Das ist ein kurzer Test der Wiedergabegeschwindigkeit.",FR:"Ceci est un test rapide de la vitesse de lecture.",ES:"Esta es una prueba r\xE1pida de la velocidad de reproducci\xF3n.",IT:"Questo \xE8 un rapido test della velocit\xE0 di riproduzione.",PT:"Este \xE9 um teste r\xE1pido da velocidade de reprodu\xE7\xE3o.",NL:"Dit is een snelle test van de afspeelsnelheid.",PL:"To jest kr\xF3tki test szybko\u015Bci odtwarzania."};async function previewRouteSpeed(row,btn){var _a2,_b2,_c2,_d2,_e2;const outputVoice=(_a2=row.querySelector(".route-output"))==null?void 0:_a2.value.trim();if(!outputVoice){toast("Set an output voice first","error");return}const backend=((_b2=row.querySelector(".route-backend"))==null?void 0:_b2.value)||"voice_clone",speed=Math.max(.5,Math.min(2,parseFloat((_c2=row.querySelector(".route-speed"))==null?void 0:_c2.value)||1)),lang=(((_d2=row.querySelector(".route-lang"))==null?void 0:_d2.value)||"EN").toUpperCase(),text=ROUTE_SPEED_PREVIEW_TEXT[lang]||ROUTE_SPEED_PREVIEW_TEXT.EN,audio=$("routing-sound-preview");if(!audio)return;_routeSoundPlayingButton&&_routeSoundPlayingButton!==btn&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSpeedPreviewButton&&_routeSpeedPreviewButton!==btn&&((_e2=_routeSpeedPreviewButton.querySelector(".mdi"))==null||_e2.classList.replace("mdi-pause","mdi-play")),_routeSpeedPreviewButton=btn;const icon=btn.querySelector(".mdi");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(outputVoice,text,"wav","",backend);audio.hidden=!1,audio.src=URL.createObjectURL(blob),audio.playbackRate=speed,icon==null||icon.classList.replace("mdi-play","mdi-pause"),audio.onended=()=>icon==null?void 0:icon.classList.replace("mdi-pause","mdi-play"),audio.onpause=()=>{_routeSpeedPreviewButton===btn&&(icon==null||icon.classList.replace("mdi-pause","mdi-play"))},await audio.play()}catch(e){icon==null||icon.classList.replace("mdi-pause","mdi-play"),toast("Preview failed: "+e.message,"error")}finally{btn.disabled=!1}}let _routeSpeedPreviewButton=null;function useRouteSound(path,target){const selected=_routeSoundPickerTarget||{},row=selected.row||document.querySelector(".routing-row"),useTarget=target||selected.target||"before";setRouteSoundField(row,useTarget,path),toast(`${useTarget==="before"?"Before":"After"} sound selected`,"success")}function routeBackendOptions(value){const current=value||"voice_clone";return ROUTE_BACKENDS.map(([code,label])=>``).join("")}function refreshRoutingVoiceOptions(){const dl=$("routing-voice-options");if(dl){const ids=[...activeVoiceIds(),...virtualDesignVoiceIds()];dl.innerHTML=[...new Set(ids)].map(id=>``).join("")}const soundsDl=$("routing-sound-options");soundsDl&&(soundsDl.innerHTML=_routeSounds.map(sound=>``).join(""))}function newRoute(app="Open WebUI",inputVoice="default",language="*",outputVoice=""){return{id:"route_"+Date.now().toString(36)+"_"+Math.random().toString(36).slice(2,6),enabled:!0,app,input_voice:inputVoice,language,backend:"voice_clone",output_voice:outputVoice,before_sound:"",after_sound:""}}function renderRoutingList(){if($("routing-list")){if(refreshRoutingVoiceOptions(),$("routing-proxy-url").textContent=getCreatorV1Url(),updateCreatorUrlHints(),$("routing-status").textContent=_ttsRoutes.length?`${_ttsRoutes.length} route${_ttsRoutes.length===1?"":"s"}`:"No routes yet.",!_ttsRoutes.length){$("routing-list").innerHTML='
No routing rules yet. Add a route or add the Open WebUI default examples.
';return}$("routing-list").innerHTML=_ttsRoutes.map((r,i)=>`
- `).join(""),typeof VoicePicker!="undefined"&&document.querySelectorAll(".route-output").forEach(inp=>VoicePicker.attachTextPicker(inp))}}function readRoutingForm(){_ttsRoutes=[...document.querySelectorAll(".routing-row")].map((row,i)=>({id:(_ttsRoutes[Number(row.dataset.index)]||{}).id||`route_${i+1}`,enabled:row.querySelector(".route-enabled").checked,app:row.querySelector(".route-app").value.trim()||"*",input_voice:row.querySelector(".route-input").value.trim()||"default",language:row.querySelector(".route-lang").value||"*",backend:row.querySelector(".route-backend").value||"voice_clone",output_voice:row.querySelector(".route-output").value.trim(),before_sound:row.querySelector(".route-before-sound").value.trim(),after_sound:row.querySelector(".route-after-sound").value.trim()}))}async function loadRoutingTab(){if($("routing-list")){$("routing-proxy-url").textContent=getCreatorV1Url(),updateCreatorUrlHints(),$("routing-status").textContent="Loading routing\u2026",$("routing-list").innerHTML=loadingMarkup("Loading routing","Loading active voices and routing rules for the proxy.",5),setBusyButton("routing-refresh-btn",!0),await Promise.allSettled([_voices.length?Promise.resolve():loadVoiceLibrary().catch(()=>{}),loadRouteSounds()]);try{const r=await fetch("/api/tts-routes");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_ttsRoutes=Array.isArray(d.routes)?d.routes:[],renderRoutingList(),status("Routing loaded"),loadRoutingLog()}catch(e){$("routing-status").textContent="Load failed",$("routing-list").innerHTML=`
Failed to load routes: ${escHtml(e.message)}
`,status("Routing load failed")}finally{setBusyButton("routing-refresh-btn",!1)}}}async function saveRoutingTab(){readRoutingForm(),$("routing-save-btn").disabled=!0;try{const r=await fetch("/api/tts-routes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({routes:_ttsRoutes})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_ttsRoutes=(await r.json()).routes||_ttsRoutes,renderRoutingList(),toast("Routing saved","success"),status("TTS routing saved")}catch(e){toast("Save routes failed: "+e.message,"error")}finally{$("routing-save-btn").disabled=!1}}function renderRouteTestResult(d){const el=$("routing-test-result");if(!el)return;const health=d.voice_health||{},warnings=Array.isArray(health.warnings)?health.warnings:[];el.className="routing-test-result "+(warnings.length?"warn":"ok");const parts=[`${escHtml(d.requested_voice||"")} \u2192 ${escHtml(d.routed_voice||"")}`,`app ${escHtml(d.app||"-")}`,`backend ${escHtml(d.backend||"voice_clone")}`,`language ${escHtml(d.detected_language||"-")}`,d.matched?"matched route":"no route matched"];health.duration&&parts.push(`reference ${health.duration}s`),health.word_count!=null&&parts.push(`${health.word_count} words`),health.words_per_sec&&parts.push(`${health.words_per_sec} words/s`),warnings.length&&parts.push("Warning: "+warnings.map(escHtml).join("; "));const sounds=d.sounds||{};for(const[key,sound]of Object.entries(sounds)){const label=key==="before_sound"?"before sound":"after sound";parts.push(sound.ok?`${label} OK`:`${label}: ${escHtml(sound.error||"not found")}`),sound.ok||(el.className="routing-test-result warn")}el.innerHTML=parts.join(" \xB7 ")}function routingLogTime(ts){if(!ts)return"--:--:--";const d=new Date(ts);return Number.isNaN(d.getTime())?String(ts).slice(11,19)||"--:--:--":d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function routingLogBadge(item){const status2=String(item.status||item.kind||"log");return item.kind==="test"&&status2==="matched"?"test ok":item.kind==="test"&&status2==="no_match"?"test miss":status2.replace(/_/g," ")}function routingLogMeta(item){const parts=[];return item.backend&&parts.push(item.backend),item.language&&parts.push("lang "+item.language),item.response_format&&parts.push(item.response_format),item.duration!=null&&parts.push(Number(item.duration).toFixed(2)+"s"),item.bytes!=null&&parts.push(Math.round(Number(item.bytes)/1024)+" KB"),item.sounds&&Array.isArray(item.sounds)&&item.sounds.length&&parts.push("sounds "+item.sounds.join(",")),item.route_id&&parts.push("route "+item.route_id),item.client&&parts.push(item.client),parts.join(" \xB7 ")}let _routingLogItems=[],_routingLogFilter="all";function routingLogPassesFilter(item){const status2=String((item==null?void 0:item.status)||"").toLowerCase(),isError=status2==="error"||!!(item!=null&&item.error),isNoMatch=status2==="no_match"||(item==null?void 0:item.matched)===!1;return _routingLogFilter==="error"?isError:_routingLogFilter==="no_match"?isNoMatch:_routingLogFilter==="attention"?isError||isNoMatch:!0}function renderCurrentRoutingLog(){renderRoutingLog(_routingLogItems.filter(routingLogPassesFilter))}function renderRoutingLog(items=[]){const el=$("routing-log-list");if(el){if(!items.length){const filtered=_routingLogItems.length&&_routingLogFilter!=="all";el.innerHTML=`
${filtered?"No routing log entries match this filter.":"No routing log entries yet. Test a route or send a TTS request through the Creator proxy."}
`;return}el.innerHTML=items.map(item=>{const status2=String(item.status||"log").replace(/[^a-z0-9_-]/gi,"_"),requested=item.requested_voice||"-",routed=item.routed_voice||"-",voice=requested===routed?requested:`${requested} \u2192 ${routed}`,text=item.error?`Error: ${item.error}`:item.text_preview||"";return` + `).join(""),typeof VoicePicker!="undefined"&&document.querySelectorAll(".route-output").forEach(inp=>VoicePicker.attachTextPicker(inp))}}function readRoutingForm(){_ttsRoutes=[...document.querySelectorAll(".routing-row")].map((row,i)=>({id:(_ttsRoutes[Number(row.dataset.index)]||{}).id||`route_${i+1}`,enabled:row.querySelector(".route-enabled").checked,app:row.querySelector(".route-app").value.trim()||"*",input_voice:row.querySelector(".route-input").value.trim()||"default",language:row.querySelector(".route-lang").value||"*",backend:row.querySelector(".route-backend").value||"voice_clone",output_voice:row.querySelector(".route-output").value.trim(),speed:Math.max(.5,Math.min(2,parseFloat(row.querySelector(".route-speed").value)||1)),before_sound:row.querySelector(".route-before-sound").value.trim(),after_sound:row.querySelector(".route-after-sound").value.trim()}))}async function loadRoutingTab(){if($("routing-list")){$("routing-proxy-url").textContent=getCreatorV1Url(),updateCreatorUrlHints(),$("routing-status").textContent="Loading routing\u2026",$("routing-list").innerHTML=loadingMarkup("Loading routing","Loading active voices and routing rules for the proxy.",5),setBusyButton("routing-refresh-btn",!0),await Promise.allSettled([_voices.length?Promise.resolve():loadVoiceLibrary().catch(()=>{}),loadRouteSounds()]);try{const r=await fetch("/api/tts-routes");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_ttsRoutes=Array.isArray(d.routes)?d.routes:[],renderRoutingList(),status("Routing loaded"),loadRoutingLog()}catch(e){$("routing-status").textContent="Load failed",$("routing-list").innerHTML=`
Failed to load routes: ${escHtml(e.message)}
`,status("Routing load failed")}finally{setBusyButton("routing-refresh-btn",!1)}}}async function saveRoutingTab(){readRoutingForm(),$("routing-save-btn").disabled=!0;try{const r=await fetch("/api/tts-routes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({routes:_ttsRoutes})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_ttsRoutes=(await r.json()).routes||_ttsRoutes,renderRoutingList(),toast("Routing saved","success"),status("TTS routing saved")}catch(e){toast("Save routes failed: "+e.message,"error")}finally{$("routing-save-btn").disabled=!1}}function renderRouteTestResult(d){const el=$("routing-test-result");if(!el)return;const health=d.voice_health||{},warnings=Array.isArray(health.warnings)?health.warnings:[];el.className="routing-test-result "+(warnings.length?"warn":"ok");const parts=[`${escHtml(d.requested_voice||"")} \u2192 ${escHtml(d.routed_voice||"")}`,`app ${escHtml(d.app||"-")}`,`backend ${escHtml(d.backend||"voice_clone")}`,`language ${escHtml(d.detected_language||"-")}`,d.matched?"matched route":"no route matched"];health.duration&&parts.push(`reference ${health.duration}s`),health.word_count!=null&&parts.push(`${health.word_count} words`),health.words_per_sec&&parts.push(`${health.words_per_sec} words/s`),warnings.length&&parts.push("Warning: "+warnings.map(escHtml).join("; "));const sounds=d.sounds||{};for(const[key,sound]of Object.entries(sounds)){const label=key==="before_sound"?"before sound":"after sound";parts.push(sound.ok?`${label} OK`:`${label}: ${escHtml(sound.error||"not found")}`),sound.ok||(el.className="routing-test-result warn")}el.innerHTML=parts.join(" \xB7 ")}function routingLogTime(ts){if(!ts)return"--:--:--";const d=new Date(ts);return Number.isNaN(d.getTime())?String(ts).slice(11,19)||"--:--:--":d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function routingLogBadge(item){const status2=String(item.status||item.kind||"log");return item.kind==="test"&&status2==="matched"?"test ok":item.kind==="test"&&status2==="no_match"?"test miss":status2.replace(/_/g," ")}function routingLogMeta(item){const parts=[];return item.backend&&parts.push(item.backend),item.language&&parts.push("lang "+item.language),item.response_format&&parts.push(item.response_format),item.duration!=null&&parts.push(Number(item.duration).toFixed(2)+"s"),item.bytes!=null&&parts.push(Math.round(Number(item.bytes)/1024)+" KB"),item.sounds&&Array.isArray(item.sounds)&&item.sounds.length&&parts.push("sounds "+item.sounds.join(",")),item.route_id&&parts.push("route "+item.route_id),item.client&&parts.push(item.client),parts.join(" \xB7 ")}let _routingLogItems=[],_routingLogFilter="all";function routingLogPassesFilter(item){const status2=String((item==null?void 0:item.status)||"").toLowerCase(),isError=status2==="error"||!!(item!=null&&item.error),isNoMatch=status2==="no_match"||(item==null?void 0:item.matched)===!1;return _routingLogFilter==="error"?isError:_routingLogFilter==="no_match"?isNoMatch:_routingLogFilter==="attention"?isError||isNoMatch:!0}function renderCurrentRoutingLog(){renderRoutingLog(_routingLogItems.filter(routingLogPassesFilter))}function renderRoutingLog(items=[]){const el=$("routing-log-list");if(el){if(!items.length){const filtered=_routingLogItems.length&&_routingLogFilter!=="all";el.innerHTML=`
${filtered?"No routing log entries match this filter.":"No routing log entries yet. Test a route or send a TTS request through the Creator proxy."}
`;return}el.innerHTML=items.map(item=>{const status2=String(item.status||"log").replace(/[^a-z0-9_-]/gi,"_"),requested=item.requested_voice||"-",routed=item.routed_voice||"-",voice=requested===routed?requested:`${requested} \u2192 ${routed}`,text=item.error?`Error: ${item.error}`:item.text_preview||"";return`
${escHtml(routingLogTime(item.ts))}
${escHtml(routingLogBadge(item))}
@@ -339,7 +343,7 @@ curl -X POST ${proxyBase}/speak \\
${escHtml(voice)}
${escHtml(routingLogMeta(item)||"-")}
${escHtml(text||"-")}
-
`}).join("")}}async function loadRoutingLog(){const el=$("routing-log-list");if(el)try{const r=await fetch("/api/tts-routing-log?limit=80");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_routingLogItems=Array.isArray(d.items)?d.items:[],renderCurrentRoutingLog()}catch(e){el.innerHTML=`
Routing log unavailable: ${escHtml(e.message)}
`}}async function clearRoutingLog(){const btn=$("routing-log-clear-btn");btn&&(btn.disabled=!0);try{const r=await fetch("/api/tts-routing-log",{method:"DELETE"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_routingLogItems=[],renderRoutingLog([]),toast("Routing log cleared","success")}catch(e){toast("Clear log failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function testRouting(){readRoutingForm();const btn=$("routing-test-btn"),el=$("routing-test-result");btn.disabled=!0,el.className="routing-test-result",el.textContent="Testing route...";try{const r=await fetch("/api/tts-route-test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({app:$("routing-test-app").value.trim()||"Open WebUI",voice:$("routing-test-voice").value.trim()||"default",input:$("routing-test-text").value.trim()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}renderRouteTestResult(await r.json()),loadRoutingLog()}catch(e){el.className="routing-test-result warn",el.textContent="Route test failed: "+e.message}finally{btn.disabled=!1}}async function uploadRouteSoundForRow(row,target){const input=document.createElement("input");input.type="file",input.accept="audio/*",input.multiple=!1,input.onchange=async()=>{if(!input.files||!input.files.length)return;const btn=row.querySelector(`.route-sound-upload[data-target="${target}"]`),field=row.querySelector(target==="before"?".route-before-sound":".route-after-sound");btn&&(btn.disabled=!0);try{const fd=new FormData;fd.append("file",input.files[0]);const r=await fetch("/api/route-sounds/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();field.value=d.path||"",await loadRouteSounds(),readRoutingForm(),renderRoutingList(),toast(`${target==="before"?"Before":"After"} sound uploaded`,"success"),status(`Uploaded route sound: ${d.path}`)}catch(e){toast("Sound upload failed: "+e.message,"error"),status("Sound upload failed")}finally{btn&&(btn.disabled=!1)}},input.click()}(_h=$("routing-refresh-btn"))==null||_h.addEventListener("click",loadRoutingTab),(_i=$("routing-add-btn"))==null||_i.addEventListener("click",()=>{readRoutingForm(),_ttsRoutes.push(newRoute()),renderRoutingList()}),(_j=$("routing-add-openwebui-btn"))==null||_j.addEventListener("click",()=>{readRoutingForm();const voices=activeVoiceIds(),firstByLang=lang=>voices.find(v=>v.toUpperCase().startsWith(lang+"_"))||"";_ttsRoutes.push(newRoute("Open WebUI","default","EN",firstByLang("EN"))),_ttsRoutes.push(newRoute("Open WebUI","default","DE",firstByLang("DE"))),renderRoutingList()}),(_k=$("routing-save-btn"))==null||_k.addEventListener("click",saveRoutingTab),(_l=$("routing-test-btn"))==null||_l.addEventListener("click",testRouting),(_m=$("routing-log-refresh-btn"))==null||_m.addEventListener("click",loadRoutingLog),(_n=$("routing-log-clear-btn"))==null||_n.addEventListener("click",clearRoutingLog),(_o=$("routing-log-filter"))==null||_o.addEventListener("change",e=>{_routingLogFilter=e.target.value||"all",renderCurrentRoutingLog()}),(_p=$("routing-list"))==null||_p.addEventListener("change",e=>{const picker=e.target.closest(".route-sound-picker");if(!picker)return;const field=picker.closest(".routing-row").querySelector(picker.dataset.target==="before"?".route-before-sound":".route-after-sound");field&&(field.value=picker.value||""),readRoutingForm()}),(_q=$("routing-list"))==null||_q.addEventListener("click",e=>{const pickBtn=e.target.closest(".route-sound-pick");if(pickBtn){const row2=pickBtn.closest(".routing-row");openRouteSoundBrowser(row2,pickBtn.dataset.target);return}const uploadBtn=e.target.closest(".route-sound-upload");if(uploadBtn){const row2=uploadBtn.closest(".routing-row");uploadRouteSoundForRow(row2,uploadBtn.dataset.target);return}const btn=e.target.closest(".routing-delete");if(!btn)return;readRoutingForm();const row=btn.closest(".routing-row");_ttsRoutes.splice(Number(row.dataset.index),1),renderRoutingList()}),(_r=$("routing-sound-search"))==null||_r.addEventListener("input",debounce(renderRouteSoundBrowser,120)),(_s=$("routing-sound-refresh-btn"))==null||_s.addEventListener("click",async()=>{await loadRouteSounds(),renderRouteSoundBrowser()}),(_t=$("routing-sound-close-btn"))==null||_t.addEventListener("click",closeRouteSoundBrowser),(_u=$("routing-sound-list"))==null||_u.addEventListener("click",e=>{const item=e.target.closest(".routing-sound-item");if(!item)return;const path=item.dataset.path||"",playBtn=e.target.closest(".sound-play");if(playBtn){playRouteSound(path,playBtn);return}e.target.closest(".sound-use-current")&&useRouteSound(path,(_routeSoundPickerTarget==null?void 0:_routeSoundPickerTarget.target)||"before")});const CLONE_SAMPLE_TEXTS={EN:"Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain \u2014 these are the things I love. Can you hear how clearly I speak?",DE:"Hallo! Ich hei\xDFe Alex und das ist meine Stimme. Ich kann leise fl\xFCstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster \u2014 das liebe ich. H\xF6rst du, wie deutlich ich spreche?",IT:"Ciao! Mi chiamo Marco e questa \xE8 la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia \u2014 queste sono le cose che amo. Senti come parlo chiaramente?",ES:"\xA1Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire fr\xEDo del invierno, la c\xE1lida luz del fuego y el suave sonido de la lluvia \u2014 estas son las cosas que amo. \xBFPuedes o\xEDr lo claramente que hablo?",FR:"Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumi\xE8re du feu et le son de la pluie \u2014 voil\xE0 ce que j'aime. Entends-tu comme je parle clairement ?",PT:"Ol\xE1! Meu nome \xE9 Ana e esta \xE9 a minha voz. Posso falar suavemente ou com grande for\xE7a. O ar fresco do inverno, a luz quente do fogo e o som da chuva \u2014 estas s\xE3o as coisas que amo. Consegues ouvir como falo claramente?",NL:"Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen \u2014 dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?",PL:"Cze\u015B\u0107! Mam na imi\u0119 Anna i to jest m\xF3j g\u0142os. Mog\u0119 m\xF3wi\u0107 cicho lub z ca\u0142\u0105 moc\u0105. Mro\u017Ane zimowe powietrze, ciep\u0142e \u015Bwiat\u0142o ognia i d\u017Awi\u0119k deszczu za oknem \u2014 to s\u0105 rzeczy, kt\xF3re kocham. Czy s\u0142yszysz, jak wyra\u017Anie m\xF3wi\u0119?"},CLONE_SAMPLE_NAMES={EN:"Sam",DE:"Alex",IT:"Marco",ES:"Carlos",FR:"Sophie",PT:"Ana",NL:"Laura",PL:"Anna"};function cloneSampleForLang(lang){var _a2;const txt=$("clone-sample-text"),def=CLONE_SAMPLE_NAMES[lang]||"";let t=CLONE_SAMPLE_TEXTS[lang]||CLONE_SAMPLE_TEXTS.EN;const name=(((_a2=$("clone-your-name"))==null?void 0:_a2.value)||"").trim();name&&def&&(t=t.replace(new RegExp("\\b"+def.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b"),name)),txt&&(txt.value=t,txt.dataset.sampleName=name||def)}window.initCloneSampleText=function(){const sel=$("clone-sample-lang"),txt=$("clone-sample-text");!sel||!txt||(cloneSampleForLang(sel.value),sel._cloneSampleBound||(sel.addEventListener("change",()=>cloneSampleForLang(sel.value)),sel._cloneSampleBound=!0))},initCloneSampleText();let ws=null,wsRegions=null,currentFileId=null,trimmedFileId=null,designedFileId=null,editingVoiceId=null,editingVoicePath=null;function initWaveSurfer(){ws&&(ws.destroy(),ws=null,wsRegions=null),wsRegions=WaveSurfer.Regions.create(),ws=WaveSurfer.create({container:"#waveform",waveColor:"#45475a",progressColor:"#89b4fa",cursorColor:"#cba6f7",height:90,normalize:!0,plugins:[wsRegions]}),ws.on("ready",()=>{const dur=ws.getDuration();$("trim-end").value=dur.toFixed(2),$("trim-end").max=dur.toFixed(2),$("trim-start").max=dur.toFixed(2),updateRegion()}),wsRegions.on("region-updated",r=>{$("trim-start").value=r.start.toFixed(2),$("trim-end").value=r.end.toFixed(2),updateDurationLabel()})}function updateRegion(){wsRegions.clearRegions();const s=parseFloat($("trim-start").value)||0,e=parseFloat($("trim-end").value)||(ws?ws.getDuration():0);wsRegions.addRegion({start:s,end:e,color:"rgba(137,180,250,0.25)",drag:!0,resize:!0}),updateDurationLabel()}function updateDurationLabel(){const d=Math.max(0,(parseFloat($("trim-end").value)||0)-(parseFloat($("trim-start").value)||0)),el=$("trim-duration");el.textContent=d.toFixed(1)+" s",el.className=d>=5&&d<=20?"dur-ok":d>20?"dur-warn":"dur-bad"}["trim-start","trim-end"].forEach(id=>$(id).addEventListener("input",()=>{ws&&updateRegion()})),$("play-btn").addEventListener("click",()=>{ws&&ws.playPause()}),$("play-selection-btn").addEventListener("click",()=>{ws&&ws.play(parseFloat($("trim-start").value)||0,parseFloat($("trim-end").value)||ws.getDuration())}),$("auto-trim-btn").addEventListener("click",async()=>{if(!currentFileId){toast("No audio loaded","error");return}$("auto-trim-btn").disabled=!0,status("Finding best TTS reference segment\u2026");try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)status("Backend auto trim unavailable; analysing audio in browser\u2026"),d=await clientAutoTrimBounds(currentFileId);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText||"Auto trim failed")}$("trim-start").value=Number(d.start).toFixed(2),$("trim-end").value=Number(d.end).toFixed(2),ws&&updateRegion(),toast("Auto trim set: "+Number(d.duration).toFixed(1)+" s","success"),status(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),status("Auto trim failed")}finally{$("auto-trim-btn").disabled=!1}});function loadAudioId(id,dur,opts={}){currentFileId=id,trimmedFileId=null,designedFileId=null,editingVoiceId=opts.editingVoiceId||null,editingVoicePath=opts.editingVoicePath||null,$("trim-start").value="0",$("trim-end").value=dur.toFixed(2),$("waveform-card").style.display="",initWaveSurfer(),ws.load("/api/audio/"+id),$("save-result").style.display="none",$("trim-audio").style.display="none",$("no-audio-hint").style.display="",editingVoiceId&&($("voice-id-input").value=editingVoiceId,$("voice-id-input").dispatchEvent(new Event("input")),$("transcript-area").value=opts.transcript||"",status("Editing existing voice: "+editingVoiceId))}const dropZone=$("drop-zone"),fileInput=$("file-input");dropZone.addEventListener("click",()=>fileInput.click()),dropZone.addEventListener("dragover",e=>{e.preventDefault(),dropZone.classList.add("drag-over")}),dropZone.addEventListener("dragleave",()=>dropZone.classList.remove("drag-over")),dropZone.addEventListener("drop",e=>{e.preventDefault(),dropZone.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadFile(e.dataTransfer.files[0])}),fileInput.addEventListener("change",()=>{fileInput.files.length&&uploadFile(fileInput.files[0])});async function uploadFile(file){status("Uploading "+file.name+"\u2026");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();throw new Error(e.detail||r.statusText)}const d=await r.json();loadAudioId(d.id,d.duration),status("Loaded: "+file.name+" ("+d.duration.toFixed(1)+" s)"),toast("File loaded","success")}catch(e){toast("Upload failed: "+e.message,"error"),status("Upload failed")}}async function loadLibraryVoiceAudio(v){const audioResp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!audioResp.ok){const e=await audioResp.json().catch(()=>({}));throw new Error(e.detail||audioResp.statusText)}const blob=await audioResp.blob(),ext=(v.file_type||"wav").toLowerCase(),fd=new FormData;fd.append("file",new File([blob],`${v.id}.${ext}`,{type:blob.type||"audio/wav"}));const upload=await fetch("/api/upload",{method:"POST",body:fd});if(!upload.ok){const e=await upload.json().catch(()=>({}));throw new Error(e.detail||upload.statusText)}const d=await upload.json();return{id:d.id,voice_id:v.id,duration:d.duration,transcript:v.transcript||"",file_type:ext,path:v.path}}$("yt-btn").addEventListener("click",()=>{const url=$("yt-url").value.trim();if(!url)return;$("yt-btn").disabled=!0,$("yt-progress").textContent="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"),$("yt-progress").textContent=d.error,$("yt-btn").disabled=!1,es.close()):d.done?(es.close(),$("yt-btn").disabled=!1,$("yt-progress").textContent="Done!",loadAudioId(d.id,d.duration),toast("YouTube audio loaded","success")):($("yt-progress").textContent=d.msg||"",d.pct&&status("Downloading\u2026 "+d.pct+"%"))},es.onerror=()=>{es.close(),$("yt-btn").disabled=!1}});const RAW_MIC_CONSTRAINTS={echoCancellation:!1,noiseSuppression:!1,autoGainControl:!1};async function visibleMicrophoneCount(){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.enumerateDevices))return null;try{return(await navigator.mediaDevices.enumerateDevices()).filter(device=>device.kind==="audioinput").length}catch{return null}}async function microphoneErrorMessage(error){const name=(error==null?void 0:error.name)||"",message=(error==null?void 0:error.message)||"",lowerMessage=message.toLowerCase(),micCount=await visibleMicrophoneCount();return name==="NotFoundError"||lowerMessage.includes("requested device not found")?micCount===0?"No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.":"The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.":name==="NotAllowedError"||name==="PermissionDeniedError"?"Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.":name==="NotReadableError"?"The microphone is busy or unavailable. Close other apps using it, then try again.":name==="SecurityError"?"Microphone access requires localhost or HTTPS.":message||"Microphone failed."}async function requestMicrophoneStream(options={}){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.getUserMedia))throw new Error("Microphone requires HTTPS. Open the app via https://... or access it on localhost.");if(!options.raw)return navigator.mediaDevices.getUserMedia({audio:!0});try{return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS})}catch(e){if((e==null?void 0:e.name)==="OverconstrainedError"||(e==null?void 0:e.name)==="NotFoundError")return navigator.mediaDevices.getUserMedia({audio:!0});throw e}}let _cloneMonState={stream:null,recordStream:null,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,waveRing:null,monitoring:!1};function _cloneRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("clone-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=$("clone-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function _cloneStartMeter(){if(!_cloneMonState.analyser)return;_cloneMonState.meterRaf&&cancelAnimationFrame(_cloneMonState.meterRaf);const data=new Float32Array(_cloneMonState.analyser.fftSize),canvas=$("clone-live-wave"),RING=300,ADD=10;_cloneMonState.waveRing=new Float32Array(RING);const tick=()=>{_cloneMonState.analyser.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,level=Number.isFinite(db)?(db+60)/60:0;if(_cloneRenderMeter(level,db,peak>.98),canvas&&_cloneMonState.waveRing){const ring=_cloneMonState.waveRing;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{}}),_cloneMonState.stream&&_cloneMonState.stream.getTracks().forEach(t=>t.stop()),_cloneMonState.recordStream&&_cloneMonState.recordStream.getTracks().forEach(t=>t.stop()),_cloneMonState.audioCtx&&_cloneMonState.audioCtx.close().catch(()=>{}),Object.assign(_cloneMonState,{stream:null,recordStream:null,sourceNode:null,gainNode:null,analyser:null,audioCtx:null,monitoring:!1,waveRing:null}),_cloneRenderMeter(0,-1/0,!1);const wc=$("clone-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height),$("clone-monitor-btn")&&($("clone-monitor-btn").disabled=!1),$("clone-monitor-stop")&&($("clone-monitor-stop").disabled=!0)}(_v=$("clone-monitor-btn"))==null||_v.addEventListener("click",async()=>{try{await _cloneStartMonitor(),status("Mic level monitor active")}catch(e){const m=await microphoneErrorMessage(e);toast(m,"error")}}),(_w=$("clone-monitor-stop"))==null||_w.addEventListener("click",()=>{_cloneStopMonitor(),status("Mic level monitor stopped")}),(_x=$("clone-mic-gain"))==null||_x.addEventListener("input",()=>{const v=parseFloat($("clone-mic-gain").value)||0;$("clone-mic-gain-value")&&($("clone-mic-gain-value").textContent=v.toFixed(2)+"x"),_cloneMonState.gainNode&&(_cloneMonState.gainNode.gain.value=v)}),_cloneRenderMeter();let mediaRec=null,recChunks=[],recTimer=null,recSecs=0;$("rec-start-btn").addEventListener("click",async()=>{try{await _cloneStartMonitor(),recChunks=[],recSecs=0,$("rec-time").textContent="0:00",$("rec-indicator").classList.add("active"),$("rec-start-btn").disabled=!0,$("rec-stop-btn").disabled=!1,recTimer=setInterval(()=>{recSecs++,$("rec-time").textContent=Math.floor(recSecs/60)+":"+String(recSecs%60).padStart(2,"0")},1e3),mediaRec=new MediaRecorder(_cloneMonState.recordStream||_cloneMonState.stream,{audioBitsPerSecond:256e3}),mediaRec.ondataavailable=e=>{e.data.size&&recChunks.push(e.data)},mediaRec.onstop=async()=>{clearInterval(recTimer),$("rec-indicator").classList.remove("active");const blob=new Blob(recChunks,{type:mediaRec.mimeType||"audio/webm"}),ext=(mediaRec.mimeType||"").includes("ogg")?".ogg":".webm";_cloneStopMonitor(),await uploadFile(new File([blob],"recording"+ext,{type:blob.type}))},mediaRec.start(100),status("Recording\u2026")}catch(e){_cloneStopMonitor(),toast(await microphoneErrorMessage(e),"error")}}),$("rec-stop-btn").addEventListener("click",()=>{mediaRec&&mediaRec.state!=="inactive"&&mediaRec.stop(),$("rec-start-btn").disabled=!1,$("rec-stop-btn").disabled=!0}),$("trim-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;if(!currentFileId){toast("No audio loaded","error");return}try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId,start:parseFloat($("trim-start").value)||0,end:parseFloat($("trim-end").value)||0})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();trimmedFileId=d.id,designedFileId=null,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",switchTab("save"),toast("Trim done","success"),(_b2=(_a2=$("transcript-area"))==null?void 0:_a2.closest(".card"))==null||_b2.scrollIntoView({behavior:"smooth",block:"center"}),(_c2=window._cloneAutoTranscribe)==null||_c2.call(window)}catch(e){toast("Trim failed: "+e.message,"error")}});const DESIGN_LANG_CODE={Auto:"EN",English:"EN",Chinese:"ZH",Japanese:"JA",Korean:"KO",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Portuguese:"PT",Russian:"RU"},DESIGN_GENDER_WORD={F:"female",M:"male",N:"neutral"},DESIGN_PRESET_KEY="vcf-design-presets",DESIGN_PRESET_SEEDED_KEY="vcf-design-presets-seeded-v2",DEFAULT_DESIGN_PRESETS={EN_M_Young_Energetic:{description:"Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.",sample_text:"Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.",language:"English",gender:"M"},EN_F_Warm_Narrator:{description:"Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.",sample_text:"The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.",language:"English",gender:"F"},DE_M_Elderly_Documentary:{description:"Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.",sample_text:"Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.",language:"German",gender:"M"},DE_F_Young_Friendly:{description:"Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.",sample_text:"Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.",language:"German",gender:"F"},EN_N_Old_Wise_Assistant:{description:"Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.",sample_text:"Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.",language:"English",gender:"N"}},QWEN_DESIGN_SAMPLES={"qwen-timbre-reuse":{title:"Qwen Timbre Reuse",summary:"Reference clip for designing a reusable teen character timbre.",description:"Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous",text:"H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?",language:"English",gender:"M"},"acoustic-sausage-announcer":{title:"Acoustic Attribute Control - British announcer",summary:"Fast, loud, articulate British male delivery with excitement and performative authority.",description:`gender: Male. + `}).join("")}}async function loadRoutingLog(){const el=$("routing-log-list");if(el)try{const r=await fetch("/api/tts-routing-log?limit=80");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_routingLogItems=Array.isArray(d.items)?d.items:[],renderCurrentRoutingLog()}catch(e){el.innerHTML=`
Routing log unavailable: ${escHtml(e.message)}
`}}async function clearRoutingLog(){const btn=$("routing-log-clear-btn");btn&&(btn.disabled=!0);try{const r=await fetch("/api/tts-routing-log",{method:"DELETE"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_routingLogItems=[],renderRoutingLog([]),toast("Routing log cleared","success")}catch(e){toast("Clear log failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function testRouting(){readRoutingForm();const btn=$("routing-test-btn"),el=$("routing-test-result");btn.disabled=!0,el.className="routing-test-result",el.textContent="Testing route...";try{const r=await fetch("/api/tts-route-test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({app:$("routing-test-app").value.trim()||"Open WebUI",voice:$("routing-test-voice").value.trim()||"default",input:$("routing-test-text").value.trim()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}renderRouteTestResult(await r.json()),loadRoutingLog()}catch(e){el.className="routing-test-result warn",el.textContent="Route test failed: "+e.message}finally{btn.disabled=!1}}async function uploadRouteSoundForRow(row,target){const input=document.createElement("input");input.type="file",input.accept="audio/*",input.multiple=!1,input.onchange=async()=>{if(!input.files||!input.files.length)return;const btn=row.querySelector(`.route-sound-upload[data-target="${target}"]`),field=row.querySelector(target==="before"?".route-before-sound":".route-after-sound");btn&&(btn.disabled=!0);try{const fd=new FormData;fd.append("file",input.files[0]);const r=await fetch("/api/route-sounds/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();field.value=d.path||"",await loadRouteSounds(),readRoutingForm(),renderRoutingList(),toast(`${target==="before"?"Before":"After"} sound uploaded`,"success"),status(`Uploaded route sound: ${d.path}`)}catch(e){toast("Sound upload failed: "+e.message,"error"),status("Sound upload failed")}finally{btn&&(btn.disabled=!1)}},input.click()}(_h=$("routing-refresh-btn"))==null||_h.addEventListener("click",loadRoutingTab),(_i=$("routing-add-btn"))==null||_i.addEventListener("click",()=>{readRoutingForm(),_ttsRoutes.push(newRoute()),renderRoutingList()}),(_j=$("routing-add-openwebui-btn"))==null||_j.addEventListener("click",()=>{readRoutingForm();const voices=activeVoiceIds(),firstByLang=lang=>voices.find(v=>v.toUpperCase().startsWith(lang+"_"))||"";_ttsRoutes.push(newRoute("Open WebUI","default","EN",firstByLang("EN"))),_ttsRoutes.push(newRoute("Open WebUI","default","DE",firstByLang("DE"))),renderRoutingList()}),(_k=$("routing-save-btn"))==null||_k.addEventListener("click",saveRoutingTab),(_l=$("routing-test-btn"))==null||_l.addEventListener("click",testRouting),(_m=$("routing-log-refresh-btn"))==null||_m.addEventListener("click",loadRoutingLog),(_n=$("routing-log-clear-btn"))==null||_n.addEventListener("click",clearRoutingLog),(_o=$("routing-log-filter"))==null||_o.addEventListener("change",e=>{_routingLogFilter=e.target.value||"all",renderCurrentRoutingLog()}),(_p=$("routing-list"))==null||_p.addEventListener("change",e=>{const picker=e.target.closest(".route-sound-picker");if(!picker)return;const field=picker.closest(".routing-row").querySelector(picker.dataset.target==="before"?".route-before-sound":".route-after-sound");field&&(field.value=picker.value||""),readRoutingForm()}),(_q=$("routing-list"))==null||_q.addEventListener("click",e=>{const speedPreviewBtn=e.target.closest(".route-speed-preview");if(speedPreviewBtn){previewRouteSpeed(speedPreviewBtn.closest(".routing-row"),speedPreviewBtn);return}const pickBtn=e.target.closest(".route-sound-pick");if(pickBtn){const row2=pickBtn.closest(".routing-row");openRouteSoundBrowser(row2,pickBtn.dataset.target);return}const uploadBtn=e.target.closest(".route-sound-upload");if(uploadBtn){const row2=uploadBtn.closest(".routing-row");uploadRouteSoundForRow(row2,uploadBtn.dataset.target);return}const btn=e.target.closest(".routing-delete");if(!btn)return;readRoutingForm();const row=btn.closest(".routing-row");_ttsRoutes.splice(Number(row.dataset.index),1),renderRoutingList()}),(_r=$("routing-sound-search"))==null||_r.addEventListener("input",debounce(renderRouteSoundBrowser,120)),(_s=$("routing-sound-refresh-btn"))==null||_s.addEventListener("click",async()=>{await loadRouteSounds(),renderRouteSoundBrowser()}),(_t=$("routing-sound-close-btn"))==null||_t.addEventListener("click",closeRouteSoundBrowser),(_u=$("routing-sound-list"))==null||_u.addEventListener("click",e=>{const item=e.target.closest(".routing-sound-item");if(!item)return;const path=item.dataset.path||"",playBtn=e.target.closest(".sound-play");if(playBtn){playRouteSound(path,playBtn);return}e.target.closest(".sound-use-current")&&useRouteSound(path,(_routeSoundPickerTarget==null?void 0:_routeSoundPickerTarget.target)||"before")});const CLONE_SAMPLE_TEXTS={EN:"Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain \u2014 these are the things I love. Can you hear how clearly I speak?",DE:"Hallo! Ich hei\xDFe Alex und das ist meine Stimme. Ich kann leise fl\xFCstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster \u2014 das liebe ich. H\xF6rst du, wie deutlich ich spreche?",IT:"Ciao! Mi chiamo Marco e questa \xE8 la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia \u2014 queste sono le cose che amo. Senti come parlo chiaramente?",ES:"\xA1Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire fr\xEDo del invierno, la c\xE1lida luz del fuego y el suave sonido de la lluvia \u2014 estas son las cosas que amo. \xBFPuedes o\xEDr lo claramente que hablo?",FR:"Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumi\xE8re du feu et le son de la pluie \u2014 voil\xE0 ce que j'aime. Entends-tu comme je parle clairement ?",PT:"Ol\xE1! Meu nome \xE9 Ana e esta \xE9 a minha voz. Posso falar suavemente ou com grande for\xE7a. O ar fresco do inverno, a luz quente do fogo e o som da chuva \u2014 estas s\xE3o as coisas que amo. Consegues ouvir como falo claramente?",NL:"Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen \u2014 dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?",PL:"Cze\u015B\u0107! Mam na imi\u0119 Anna i to jest m\xF3j g\u0142os. Mog\u0119 m\xF3wi\u0107 cicho lub z ca\u0142\u0105 moc\u0105. Mro\u017Ane zimowe powietrze, ciep\u0142e \u015Bwiat\u0142o ognia i d\u017Awi\u0119k deszczu za oknem \u2014 to s\u0105 rzeczy, kt\xF3re kocham. Czy s\u0142yszysz, jak wyra\u017Anie m\xF3wi\u0119?"},CLONE_SAMPLE_NAMES={EN:"Sam",DE:"Alex",IT:"Marco",ES:"Carlos",FR:"Sophie",PT:"Ana",NL:"Laura",PL:"Anna"};function cloneSampleForLang(lang){var _a2;const txt=$("clone-sample-text"),def=CLONE_SAMPLE_NAMES[lang]||"";let t=CLONE_SAMPLE_TEXTS[lang]||CLONE_SAMPLE_TEXTS.EN;const name=(((_a2=$("clone-your-name"))==null?void 0:_a2.value)||"").trim();name&&def&&(t=t.replace(new RegExp("\\b"+def.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b"),name)),txt&&(txt.value=t,txt.dataset.sampleName=name||def)}window.initCloneSampleText=function(){const sel=$("clone-sample-lang"),txt=$("clone-sample-text");!sel||!txt||(cloneSampleForLang(sel.value),sel._cloneSampleBound||(sel.addEventListener("change",()=>cloneSampleForLang(sel.value)),sel._cloneSampleBound=!0))},initCloneSampleText();let ws=null,wsRegions=null,currentFileId=null,trimmedFileId=null,designedFileId=null,editingVoiceId=null,editingVoicePath=null;function initWaveSurfer(){ws&&(ws.destroy(),ws=null,wsRegions=null),wsRegions=WaveSurfer.Regions.create(),ws=WaveSurfer.create({container:"#waveform",waveColor:"#45475a",progressColor:"#89b4fa",cursorColor:"#cba6f7",height:90,normalize:!0,plugins:[wsRegions]}),ws.on("ready",()=>{const dur=ws.getDuration();$("trim-end").value=dur.toFixed(2),$("trim-end").max=dur.toFixed(2),$("trim-start").max=dur.toFixed(2),updateRegion()}),wsRegions.on("region-updated",r=>{$("trim-start").value=r.start.toFixed(2),$("trim-end").value=r.end.toFixed(2),updateDurationLabel()})}function updateRegion(){wsRegions.clearRegions();const s=parseFloat($("trim-start").value)||0,e=parseFloat($("trim-end").value)||(ws?ws.getDuration():0);wsRegions.addRegion({start:s,end:e,color:"rgba(137,180,250,0.25)",drag:!0,resize:!0}),updateDurationLabel()}function updateDurationLabel(){const d=Math.max(0,(parseFloat($("trim-end").value)||0)-(parseFloat($("trim-start").value)||0)),el=$("trim-duration");el.textContent=d.toFixed(1)+" s",el.className=d>=5&&d<=20?"dur-ok":d>20?"dur-warn":"dur-bad"}["trim-start","trim-end"].forEach(id=>$(id).addEventListener("input",()=>{ws&&updateRegion()})),$("play-btn").addEventListener("click",()=>{ws&&ws.playPause()}),$("play-selection-btn").addEventListener("click",()=>{ws&&ws.play(parseFloat($("trim-start").value)||0,parseFloat($("trim-end").value)||ws.getDuration())}),$("auto-trim-btn").addEventListener("click",async()=>{if(!currentFileId){toast("No audio loaded","error");return}$("auto-trim-btn").disabled=!0,status("Finding best TTS reference segment\u2026");try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)status("Backend auto trim unavailable; analysing audio in browser\u2026"),d=await clientAutoTrimBounds(currentFileId);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText||"Auto trim failed")}$("trim-start").value=Number(d.start).toFixed(2),$("trim-end").value=Number(d.end).toFixed(2),ws&&updateRegion(),toast("Auto trim set: "+Number(d.duration).toFixed(1)+" s","success"),status(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),status("Auto trim failed")}finally{$("auto-trim-btn").disabled=!1}});function loadAudioId(id,dur,opts={}){currentFileId=id,trimmedFileId=null,designedFileId=null,editingVoiceId=opts.editingVoiceId||null,editingVoicePath=opts.editingVoicePath||null,$("trim-start").value="0",$("trim-end").value=dur.toFixed(2),$("waveform-card").style.display="",initWaveSurfer(),ws.load("/api/audio/"+id),$("save-result").style.display="none",$("trim-audio").style.display="none",$("no-audio-hint").style.display="",editingVoiceId&&($("voice-id-input").value=editingVoiceId,$("voice-id-input").dispatchEvent(new Event("input")),$("transcript-area").value=opts.transcript||"",status("Editing existing voice: "+editingVoiceId))}const dropZone=$("drop-zone"),fileInput=$("file-input");dropZone.addEventListener("click",()=>fileInput.click()),dropZone.addEventListener("dragover",e=>{e.preventDefault(),dropZone.classList.add("drag-over")}),dropZone.addEventListener("dragleave",()=>dropZone.classList.remove("drag-over")),dropZone.addEventListener("drop",e=>{e.preventDefault(),dropZone.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadFile(e.dataTransfer.files[0])}),fileInput.addEventListener("change",()=>{fileInput.files.length&&uploadFile(fileInput.files[0])});async function uploadFile(file){status("Uploading "+file.name+"\u2026");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();throw new Error(e.detail||r.statusText)}const d=await r.json();loadAudioId(d.id,d.duration),status("Loaded: "+file.name+" ("+d.duration.toFixed(1)+" s)"),toast("File loaded","success")}catch(e){toast("Upload failed: "+e.message,"error"),status("Upload failed")}}async function loadLibraryVoiceAudio(v){const audioResp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!audioResp.ok){const e=await audioResp.json().catch(()=>({}));throw new Error(e.detail||audioResp.statusText)}const blob=await audioResp.blob(),ext=(v.file_type||"wav").toLowerCase(),fd=new FormData;fd.append("file",new File([blob],`${v.id}.${ext}`,{type:blob.type||"audio/wav"}));const upload=await fetch("/api/upload",{method:"POST",body:fd});if(!upload.ok){const e=await upload.json().catch(()=>({}));throw new Error(e.detail||upload.statusText)}const d=await upload.json();return{id:d.id,voice_id:v.id,duration:d.duration,transcript:v.transcript||"",file_type:ext,path:v.path}}$("yt-btn").addEventListener("click",()=>{const url=$("yt-url").value.trim();if(!url)return;$("yt-btn").disabled=!0,$("yt-progress").textContent="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"),$("yt-progress").textContent=d.error,$("yt-btn").disabled=!1,es.close()):d.done?(es.close(),$("yt-btn").disabled=!1,$("yt-progress").textContent="Done!",loadAudioId(d.id,d.duration),toast("YouTube audio loaded","success")):($("yt-progress").textContent=d.msg||"",d.pct&&status("Downloading\u2026 "+d.pct+"%"))},es.onerror=()=>{es.close(),$("yt-btn").disabled=!1}});const RAW_MIC_CONSTRAINTS={echoCancellation:!1,noiseSuppression:!1,autoGainControl:!1};async function visibleMicrophoneCount(){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.enumerateDevices))return null;try{return(await navigator.mediaDevices.enumerateDevices()).filter(device=>device.kind==="audioinput").length}catch{return null}}async function microphoneErrorMessage(error){const name=(error==null?void 0:error.name)||"",message=(error==null?void 0:error.message)||"",lowerMessage=message.toLowerCase(),micCount=await visibleMicrophoneCount();return name==="NotFoundError"||lowerMessage.includes("requested device not found")?micCount===0?"No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.":"The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.":name==="NotAllowedError"||name==="PermissionDeniedError"?"Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.":name==="NotReadableError"?"The microphone is busy or unavailable. Close other apps using it, then try again.":name==="SecurityError"?"Microphone access requires localhost or HTTPS.":message||"Microphone failed."}async function requestMicrophoneStream(options={}){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.getUserMedia))throw new Error("Microphone requires HTTPS. Open the app via https://... or access it on localhost.");if(!options.raw)return navigator.mediaDevices.getUserMedia({audio:!0});try{return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS})}catch(e){if((e==null?void 0:e.name)==="OverconstrainedError"||(e==null?void 0:e.name)==="NotFoundError")return navigator.mediaDevices.getUserMedia({audio:!0});throw e}}let _cloneMonState={stream:null,recordStream:null,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,waveRing:null,monitoring:!1};function _cloneRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("clone-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=$("clone-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function _cloneStartMeter(){if(!_cloneMonState.analyser)return;_cloneMonState.meterRaf&&cancelAnimationFrame(_cloneMonState.meterRaf);const data=new Float32Array(_cloneMonState.analyser.fftSize),canvas=$("clone-live-wave"),RING=300,ADD=10;_cloneMonState.waveRing=new Float32Array(RING);const tick=()=>{_cloneMonState.analyser.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,level=Number.isFinite(db)?(db+60)/60:0;if(_cloneRenderMeter(level,db,peak>.98),canvas&&_cloneMonState.waveRing){const ring=_cloneMonState.waveRing;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{}}),_cloneMonState.stream&&_cloneMonState.stream.getTracks().forEach(t=>t.stop()),_cloneMonState.recordStream&&_cloneMonState.recordStream.getTracks().forEach(t=>t.stop()),_cloneMonState.audioCtx&&_cloneMonState.audioCtx.close().catch(()=>{}),Object.assign(_cloneMonState,{stream:null,recordStream:null,sourceNode:null,gainNode:null,analyser:null,audioCtx:null,monitoring:!1,waveRing:null}),_cloneRenderMeter(0,-1/0,!1);const wc=$("clone-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height),$("clone-monitor-btn")&&($("clone-monitor-btn").disabled=!1),$("clone-monitor-stop")&&($("clone-monitor-stop").disabled=!0)}(_v=$("clone-monitor-btn"))==null||_v.addEventListener("click",async()=>{try{await _cloneStartMonitor(),status("Mic level monitor active")}catch(e){const m=await microphoneErrorMessage(e);toast(m,"error")}}),(_w=$("clone-monitor-stop"))==null||_w.addEventListener("click",()=>{_cloneStopMonitor(),status("Mic level monitor stopped")}),(_x=$("clone-mic-gain"))==null||_x.addEventListener("input",()=>{const v=parseFloat($("clone-mic-gain").value)||0;$("clone-mic-gain-value")&&($("clone-mic-gain-value").textContent=v.toFixed(2)+"x"),_cloneMonState.gainNode&&(_cloneMonState.gainNode.gain.value=v)}),_cloneRenderMeter();let mediaRec=null,recChunks=[],recTimer=null,recSecs=0;$("rec-start-btn").addEventListener("click",async()=>{try{await _cloneStartMonitor(),recChunks=[],recSecs=0,$("rec-time").textContent="0:00",$("rec-indicator").classList.add("active"),$("rec-start-btn").disabled=!0,$("rec-stop-btn").disabled=!1,recTimer=setInterval(()=>{recSecs++,$("rec-time").textContent=Math.floor(recSecs/60)+":"+String(recSecs%60).padStart(2,"0")},1e3),mediaRec=new MediaRecorder(_cloneMonState.recordStream||_cloneMonState.stream,{audioBitsPerSecond:256e3}),mediaRec.ondataavailable=e=>{e.data.size&&recChunks.push(e.data)},mediaRec.onstop=async()=>{clearInterval(recTimer),$("rec-indicator").classList.remove("active");const blob=new Blob(recChunks,{type:mediaRec.mimeType||"audio/webm"}),ext=(mediaRec.mimeType||"").includes("ogg")?".ogg":".webm";_cloneStopMonitor(),await uploadFile(new File([blob],"recording"+ext,{type:blob.type}))},mediaRec.start(100),status("Recording\u2026")}catch(e){_cloneStopMonitor(),toast(await microphoneErrorMessage(e),"error")}}),$("rec-stop-btn").addEventListener("click",()=>{mediaRec&&mediaRec.state!=="inactive"&&mediaRec.stop(),$("rec-start-btn").disabled=!1,$("rec-stop-btn").disabled=!0}),$("trim-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;if(!currentFileId){toast("No audio loaded","error");return}try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId,start:parseFloat($("trim-start").value)||0,end:parseFloat($("trim-end").value)||0})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();trimmedFileId=d.id,designedFileId=null,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",switchTab("save"),toast("Trim done","success"),(_b2=(_a2=$("transcript-area"))==null?void 0:_a2.closest(".card"))==null||_b2.scrollIntoView({behavior:"smooth",block:"center"}),(_c2=window._cloneAutoTranscribe)==null||_c2.call(window)}catch(e){toast("Trim failed: "+e.message,"error")}});const DESIGN_LANG_CODE={Auto:"EN",English:"EN",Chinese:"ZH",Japanese:"JA",Korean:"KO",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Portuguese:"PT",Russian:"RU"},DESIGN_GENDER_WORD={F:"female",M:"male",N:"neutral"},DESIGN_PRESET_KEY="vcf-design-presets",DESIGN_PRESET_SEEDED_KEY="vcf-design-presets-seeded-v2",DEFAULT_DESIGN_PRESETS={EN_M_Young_Energetic:{description:"Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.",sample_text:"Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.",language:"English",gender:"M"},EN_F_Warm_Narrator:{description:"Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.",sample_text:"The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.",language:"English",gender:"F"},DE_M_Elderly_Documentary:{description:"Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.",sample_text:"Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.",language:"German",gender:"M"},DE_F_Young_Friendly:{description:"Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.",sample_text:"Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.",language:"German",gender:"F"},EN_N_Old_Wise_Assistant:{description:"Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.",sample_text:"Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.",language:"English",gender:"N"}},QWEN_DESIGN_SAMPLES={"qwen-timbre-reuse":{title:"Qwen Timbre Reuse",summary:"Reference clip for designing a reusable teen character timbre.",description:"Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous",text:"H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?",language:"English",gender:"M"},"acoustic-sausage-announcer":{title:"Acoustic Attribute Control - British announcer",summary:"Fast, loud, articulate British male delivery with excitement and performative authority.",description:`gender: Male. pitch: Low male pitch with significant upward inflections for emphasis and excitement. speed: Fast-paced delivery with deliberate pauses for dramatic effect. volume: Loud and projecting, increasing notably during moments of praise and announcements. @@ -734,7 +738,7 @@ This warms each voice so the engine caches its .pt and first playback is instant - `;const close=()=>{ov.remove(),document.removeEventListener("keydown",onKey)};function onKey(e){e.key==="Escape"&&close()}ov.addEventListener("click",e=>{e.target===ov&&close()}),ov.querySelector(".vl-bdc-cancel").addEventListener("click",close),document.addEventListener("keydown",onKey),ov.querySelector(".vl-bdc-go").addEventListener("click",async()=>{const goBtn=ov.querySelector(".vl-bdc-go"),cancelBtn=ov.querySelector(".vl-bdc-cancel");goBtn.disabled=cancelBtn.disabled=!0;let done=0,errors=0;await runPool(ids,async id=>{try{(await fetch(`/api/voice/${encodeURIComponent(id)}`,{method:"DELETE"})).ok?done++:errors++}catch{errors++}},5,n=>{goBtn.innerHTML=` Deleting ${n}/${ids.length}\u2026`}),close(),toast(`Deleted ${done} voice${done!==1?"s":""}${errors?` (${errors} errors)`:""}`,errors?"error":"success"),_bulkSelected.clear(),await loadVoiceLibrary()}),document.body.appendChild(ov),ov.querySelector(".vl-bdc-cancel").focus()}async function _bulkSetEnabled(ids,enabled){let done=0;for(const id of ids)await saveMeta(id,{enabled}).catch(()=>{}),done++;return done}function backendVoiceId(value){return typeof value=="string"?value:(value==null?void 0:value.id)||(value==null?void 0:value.voice)||(value==null?void 0:value.name)||JSON.stringify(value)}function shouldFilterBackendVoices(backend){return["voice_clone","streaming","nvidia_zeroshot","nvidia_flow"].includes(backend||"")}function shouldReplaceWithLibraryVoices(backend){return backend==="voice_design"}async function activeLibraryVoiceIds(){return _voices.length||await loadVoiceLibrary(),new Set((_voices||[]).filter(v=>v.enabled!==!1).map(v=>v.id))}function cleanReferenceText(text){return String(text||"").trim()}function selectedPreviewLibraryVoice(){var _a2;const id=((_a2=$("tts-voice-select"))==null?void 0:_a2.value)||"";return id?(_voices||[]).find(v=>v.id===id):null}function previewVoiceWarnings(v){var _a2;const warnings=[],backend=backendById(((_a2=$("tts-backend-select"))==null?void 0:_a2.value)||"");backend&&backend.id&&!["voice_clone","streaming","nvidia_zeroshot","nvidia_flow"].includes(backend.id)&&warnings.push(backend.id==="nvidia_magpie"?"NVIDIA Magpie uses fixed speaker voices, not saved WAV clone identity.":"This backend may follow style/model voice more than the saved WAV identity."),backend&&backend.id==="nvidia_zeroshot"&&v.duration&&(Number(v.duration)<3||Number(v.duration)>10)&&warnings.push("NVIDIA Zeroshot works best with a clear 3-10 second prompt."),backend&&backend.id==="nvidia_flow"&&!v.transcript&&warnings.push("NVIDIA Flow requires the exact saved reference transcript for this voice."),v.transcript||warnings.push("No reference transcript is saved; cloned identity is harder to judge."),v.duration&&(Number(v.duration)<3||Number(v.duration)>20)&&warnings.push("Reference clip length is outside the 3-20 second sweet spot."),v.needs_tts_restart&&warnings.push("This voice changed since the last backend refresh; restart or clear restart flags before judging it.");const healthWarnings=v.health&&Array.isArray(v.health.warnings)?v.health.warnings:[];return warnings.push(...healthWarnings.slice(0,3)),warnings}function updatePreviewVoiceMatchPanel(){const panel=$("preview-match-panel");if(!panel)return;const v=selectedPreviewLibraryVoice();if(!v){panel.hidden=!0;return}panel.hidden=!1;const lang=v.language||v.lang||(v.id||"").split("_")[0]||"-",gender=v.gender||(v.id||"").split("_")[1]||"-",db=fmtDbfs(v),dur=v.duration?fmtDuration(v.duration):"-";$("preview-match-title").textContent=v.id,$("preview-match-detail").textContent=`${lang} \xB7 ${gender} \xB7 ${dur} \xB7 ${db} dBFS`;const warnings=previewVoiceWarnings(v);$("preview-match-warning").textContent=warnings.length?warnings.join(" "):"For a fair voice match check, play the WAV and synthesize the exact saved reference text.";const transcript=cleanReferenceText(v.transcript||"");$("preview-match-transcript").textContent=transcript||"No reference text saved for this voice.",$("preview-ref-use-text").disabled=!transcript,$("preview-ref-synth").disabled=!transcript;const audio=$("preview-ref-audio"),expected=voiceFileUrl(v);audio.dataset.src!==expected&&(audio.pause(),audio.src=expected,audio.dataset.src=expected);const actionsEl=panel.querySelector(".preview-match-actions");let personaBtn=panel.querySelector(".preview-persona-btn");v.persona?personaBtn||(personaBtn=document.createElement("button"),personaBtn.className="btn-secondary preview-persona-btn",personaBtn.type="button",personaBtn.textContent="Rewrite with persona",actionsEl==null||actionsEl.appendChild(personaBtn),personaBtn.addEventListener("click",async()=>{const text=$("preview-text-area").value.trim();if(!text){toast("Enter text to rewrite","error");return}const lv=selectedPreviewLibraryVoice();if(!(lv!=null&&lv.persona)){toast("This voice has no persona","error");return}personaBtn.disabled=!0,personaBtn.textContent="Rewriting\u2026";try{const llmUrl=localStorage.getItem("refine-llm-url")||(_appSettings==null?void 0:_appSettings.llm_url)||"http://localhost:11434/v1",r=await fetch("/api/rewrite-with-persona",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text,persona:lv.persona,llm_url:llmUrl,model:(_appSettings==null?void 0:_appSettings.llm_model)||"",mode:"rewrite"})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();$("preview-text-area").value=d.text,toast("Text rewritten in persona style","success")}catch(e){toast("Persona rewrite failed: "+e.message,"error")}finally{personaBtn.disabled=!1,personaBtn.textContent="Rewrite with persona"}})):personaBtn==null||personaBtn.remove();const personaToggle=$("preview-persona-toggle");if(personaToggle){const label=personaToggle.closest("label");v.persona?(personaToggle.disabled=!1,label&&(label.title="Rewrite text through this voice's character persona before generating")):(personaToggle.checked=!1,personaToggle.disabled=!0,label&&(label.title="This voice has no character persona saved \u2014 set one on the Voice Inspector page first."))}}async function synthesizeSelectedReferenceText(){const v=selectedPreviewLibraryVoice();if(!v){toast("Select a library voice first","error");return}let text=cleanReferenceText(v.transcript||"");if(!text){toast("This voice has no reference text","error");return}if(v.needs_tts_restart){if(!confirm("This voice is marked as needing a TTS restart. If you already restarted the backend, clear the flag and synthesize anyway?"))return;await clearTtsRestartFlags(),v.needs_tts_restart=!1,updatePreviewVoiceMatchPanel()}const backend=$("tts-backend-select").value;if(!backend){toast("No available TTS backend","error");return}const btn=$("preview-ref-synth");btn.disabled=!0;try{$("preview-text-area").value=text;const source=await createTtsAudioSource(v.id,text,backend,$("preview-playback-mode").value,$("preview-style-instruction").value.trim());previewBlob=source.blob;const audio=$("preview-audio");audio.src=source.url,audio.style.display="",await audio.play(),$("save-preview-mp3-btn").disabled=!1,$("save-preview-btn").disabled=source.streaming,toast(source.streaming?"Reference text streaming":"Reference text synthesized","success")}catch(e){toast("Reference synthesis failed: "+e.message,"error")}finally{btn.disabled=!1}}$("fetch-tts-voices-btn").addEventListener("click",async()=>{var _a2;$("fetch-tts-voices-btn").disabled=!0;try{const backend=(_a2=$("tts-backend-select"))==null?void 0:_a2.value;if(!backend)throw new Error("No available TTS backend");let ids;if(shouldReplaceWithLibraryVoices(backend))_voices.length||await loadVoiceLibrary(),ids=(_voices||[]).filter(v=>v.enabled!==!1).map(v=>v.id);else{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)))}ids=voices.map(backendVoiceId)}const sel=$("tts-voice-select"),prev=sel.value;window.VoicePicker?(VoicePicker.upgrade("tts-voice-select"),VoicePicker.populate("tts-voice-select",ids),prev&&ids.includes(prev)&&VoicePicker.setValue("tts-voice-select",prev)):(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)),updatePreviewVoiceMatchPanel();const suffix=shouldFilterBackendVoices(backend)||shouldReplaceWithLibraryVoices(backend)?" active voices":" voices";toast("Fetched "+ids.length+suffix,"success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{$("fetch-tts-voices-btn").disabled=!1}}),$("tts-backend-select").addEventListener("change",()=>{const sel=$("tts-voice-select");sel.innerHTML='',updateBackendHelp(),updatePreviewVoiceMatchPanel(),previewBlob=null,$("save-preview-mp3-btn").disabled=!0,$("save-preview-btn").disabled=!0}),$("tts-voice-select").addEventListener("change",updatePreviewVoiceMatchPanel),$("preview-ref-play").addEventListener("click",async()=>{updatePreviewVoiceMatchPanel();const audio=$("preview-ref-audio");try{await audio.play()}catch(e){toast("Reference playback failed: "+e.message,"error")}}),$("preview-ref-use-text").addEventListener("click",()=>{const v=selectedPreviewLibraryVoice(),text=cleanReferenceText((v==null?void 0:v.transcript)||"");if(!text){toast("This voice has no reference text","error");return}$("preview-text-area").value=text,toast("Reference text copied to target text","success")}),$("preview-ref-synth").addEventListener("click",synthesizeSelectedReferenceText);let _ttsStreamHealth=null;function effectiveTtsPlaybackMode(override="settings"){return override&&override!=="settings"?override:_appSettings.tts_stream_mode||"auto"}async function isTtsStreamAvailable(force=!1){if(_ttsStreamHealth&&!force)return _ttsStreamHealth.ok;try{return _ttsStreamHealth=await fetch("/api/tts-stream-health").then(r=>r.json()),!!_ttsStreamHealth.ok}catch{return _ttsStreamHealth={ok:!1},!1}}async function createTtsStreamUrl(voice,text,instruct=""){if(!await isTtsStreamAvailable())throw new Error("streaming backend unavailable");const r=await fetch("/api/tts-stream-session",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text,voice,instruct})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return(await r.json()).url}async function _ttsPreviewFetchWithRetry(body,tries){tries=tries||3;for(let i=1;i<=tries;i++)try{return await fetch("/api/tts-preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})}catch(e){if(i===tries)throw e;await new Promise(r=>setTimeout(r,2500*i))}}function _ttsBackendForVoice(voiceId,fallbackBackend){if(!voiceId||voiceId==="me")return fallbackBackend;const v=(window._voices||[]).find(x=>x.id===voiceId);return v&&(v.origin==="designed"||!v.has_ref)?"voice_design":fallbackBackend}async function fetchTtsPreviewBlob(voice,text,responseFormat="wav",instruct="",backend="voice_clone",applyPersona=!1,extra=null){const body={text,voice,response_format:responseFormat,instruct,backend};applyPersona&&(body.apply_persona=!0),extra&&typeof extra=="object"&&Object.assign(body,extra);const r=await _ttsPreviewFetchWithRetry(body);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const blob=await r.blob();if(responseFormat==="wav"){const v=(window._voices||[]).find(x=>x.id===voice),gainDb=v&&v.loudness&&typeof v.loudness.gain_db=="number"?v.loudness.gain_db:0;if(gainDb)try{const nr=await fetch("/api/audio/apply-gain?gain_db="+encodeURIComponent(gainDb),{method:"POST",body:blob});if(nr.ok)return await nr.blob()}catch{}}return blob}function _textWords(s){return String(s||"").toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g,"").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean)}function _textSimilarity(a,b){const wa=_textWords(a),wb=_textWords(b);if(!wa.length&&!wb.length)return 1;if(!wa.length||!wb.length)return 0;const counts=new Map;wa.forEach(w=>counts.set(w,(counts.get(w)||0)+1));let overlap=0;return wb.forEach(w=>{const c=counts.get(w);c&&(overlap++,counts.set(w,c-1))}),2*overlap/(wa.length+wb.length)}async function _voiceRoundtripCheck(voiceId,text,backend,instruct=""){const blob=await fetchTtsPreviewBlob(voiceId,text,"wav",instruct,backend),fd=new FormData;fd.append("file",blob,"roundtrip.wav"),fd.append("backend","configured");const r=await fetch("/api/transcribe-bytes",{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();return{transcript:d.text||"",score:_textSimilarity(text,d.text||""),blob}}async function createTtsAudioSource(voice,text,backend="voice_clone",modeOverride="settings",instruct="",applyPersona=!1,extra=null){const mode=effectiveTtsPlaybackMode(modeOverride);if(backend!=="streaming"||mode==="buffered"){const blob=await fetchTtsPreviewBlob(voice,text,"wav",instruct,backend,applyPersona,extra);return{url:URL.createObjectURL(blob),blob,streaming:!1,label:"buffered"}}try{return{url:await createTtsStreamUrl(voice,text,instruct),blob:null,streaming:!0,label:"streaming"}}catch(e){if(mode==="streaming")throw e;const blob=await fetchTtsPreviewBlob(voice,text,"wav",instruct,backend,applyPersona,extra);return{url:URL.createObjectURL(blob),blob,streaming:!1,label:"buffered"}}}let previewBlob=null;const PREVIEW_SAMPLE_TEXT="Hello! This is a voice preview from TTS Voice Creator - Clone and Design.";$("preview-text-area").addEventListener("focus",()=>{$("preview-text-area").value===PREVIEW_SAMPLE_TEXT&&($("preview-text-area").value="")},{once:!0});function _onPreviewGenerated(source,voice,text,backend,instruct){typeof effectsSourceBlob!="undefined"&&(window._effectsSourceBlob=null),window._effectsSynthArgs={voice,text,instruct:instruct||"",backend};const ea=$("effects-apply-btn");ea&&(ea.disabled=!1);const ap=$("add-to-playlist-btn");ap&&source.blob&&(ap.disabled=!1),typeof historyPush=="function"&&source.blob&&historyPush(voice,text,backend,source.blob,source.url)}const _TRYOUT_SPEED_KEY="ttsvc_tryout_native_speed";(function(){const saved=localStorage.getItem(_TRYOUT_SPEED_KEY);if(saved){const el=$("preview-native-speed");el&&(el.value=saved)}})(),(_R=$("preview-native-speed"))==null||_R.addEventListener("change",function(){localStorage.setItem(_TRYOUT_SPEED_KEY,this.value)}),$("preview-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;const voice=$("tts-voice-select").value,backend=$("tts-backend-select").value,text=$("preview-text-area").value.trim(),instruct=$("preview-style-instruction").value.trim(),applyPersona=((_a2=$("preview-persona-toggle"))==null?void 0:_a2.checked)||!1;if(!backend){toast("No available TTS backend","error");return}if(!voice){toast("Select a TTS voice","error");return}if(!text){toast("Enter preview text","error");return}$("preview-btn").disabled=!0,$("save-preview-mp3-btn").disabled=!0,$("save-preview-btn").disabled=!0,$("add-to-playlist-btn")&&($("add-to-playlist-btn").disabled=!0),$("effects-apply-btn")&&($("effects-apply-btn").disabled=!0);const _nspd=parseFloat((_b2=$("preview-native-speed"))==null?void 0:_b2.value),_extra=!isNaN(_nspd)&&_nspd!==1?{speed:_nspd}:null;try{const audio=$("preview-audio"),source=((_c2=$("preview-chunked-toggle"))==null?void 0:_c2.checked)&&text.length>200&&typeof generateChunkedTts=="function"?await generateChunkedTts(voice,text,backend,instruct,_extra,applyPersona):await createTtsAudioSource(voice,text,backend,$("preview-playback-mode").value,instruct,applyPersona,_extra);previewBlob=source.blob,window._previewVoice=voice,window._previewBackend=backend,window._previewText=text,audio.src=source.url,audio.style.display="",await audio.play(),$("save-preview-mp3-btn").disabled=!1,$("save-preview-btn").disabled=source.streaming,_onPreviewGenerated(source,voice,text,backend,instruct),toast(source.streaming?"Streaming preview playing":source.label==="chunked"?`Chunked (${text.length} chars) playing`:"Preview playing","success")}catch(e){toast("TTS failed: "+e.message,"error")}finally{$("preview-btn").disabled=!1}}),$("save-preview-mp3-btn").addEventListener("click",async()=>{const voice=$("tts-voice-select").value,backend=$("tts-backend-select").value,text=$("preview-text-area").value.trim(),instruct=$("preview-style-instruction").value.trim();if(!backend){toast("No available TTS backend","error");return}if(!voice||!text)return;const btn=$("save-preview-mp3-btn");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(voice,text,"mp3",instruct,backend),a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=(voice||"preview")+"_preview.mp3",a.click(),toast("MP3 saved","success")}catch(e){toast("MP3 save failed: "+e.message,"error")}finally{btn.disabled=!1}}),$("save-preview-btn").addEventListener("click",()=>{if(!previewBlob)return;const a=document.createElement("a");a.href=URL.createObjectURL(previewBlob),a.download=($("tts-voice-select").value||"preview")+"_preview.wav",a.click()});const PERF_HISTORY_KEY="vcf-perf-history",PERF_HISTORY_MAX=50,PERF_HISTORY_SORT={key:"ts",dir:"desc"};function perfHistoryLoad(){try{return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY)||"[]")}catch{return[]}}function perfHistorySave(entries){try{localStorage.setItem(PERF_HISTORY_KEY,JSON.stringify(entries.slice(-PERF_HISTORY_MAX)))}catch{}}function perfHistoryAdd(entry){const h=perfHistoryLoad();h.push(entry),perfHistorySave(h)}function perfSparklineSvg(rtfValues){if(!rtfValues.length)return"";const W=120,H=32,PAD=2,barW=Math.max(4,Math.floor((W-PAD*2)/rtfValues.length)-1),maxV=Math.max(...rtfValues,1),bars=rtfValues.map((v,i)=>{const bh=Math.max(3,Math.round(v/maxV*(H-PAD*2))),x=PAD+i*(barW+1),y=H-PAD-bh,col=v<1?"var(--green)":"var(--yellow)";return``}).join("");return``}function perfHistoryVoiceLookup(voiceId){return(Array.isArray(window._voices)&&window._voices.length?window._voices:typeof _voices!="undefined"&&Array.isArray(_voices)?_voices:[]).find(v=>v&&(v.id===voiceId||v.name===voiceId||v.voice_id===voiceId))||null}function perfHistoryVoiceMeta(entry){var _a2,_b2,_c2;const voice=(entry==null?void 0:entry.voice)||"",saved=(entry==null?void 0:entry.voiceMeta)||{},lib=perfHistoryVoiceLookup(voice)||{},language=saved.lang||saved.language||(entry==null?void 0:entry.lang)||(entry==null?void 0:entry.language)||lib.lang||lib.language||"",gender=String(saved.gender||(entry==null?void 0:entry.gender)||lib.gender||"").trim().toUpperCase().charAt(0);return{id:voice,label:saved.label||saved.display_name||(entry==null?void 0:entry.voiceLabel)||lib.display_name||lib.name||voice,lang:language,gender:["F","M","N"].includes(gender)?gender:"",flag:saved.flag||(entry==null?void 0:entry.flag)||lib.flag||"",avatar:saved.avatar||(entry==null?void 0:entry.avatar)||lib.avatar||"",hasPicture:!!((_c2=(_b2=(_a2=saved.has_picture)!=null?_a2:saved.hasPicture)!=null?_b2:entry==null?void 0:entry.hasPicture)!=null?_c2:lib.has_picture)}}function perfHistoryExtraFields(voice,backend){const lib=perfHistoryVoiceLookup(voice)||{};return{voiceMeta:{label:lib.display_name||lib.name||voice,lang:lib.lang||lib.language||"",gender:lib.gender||"",flag:lib.flag||"",avatar:lib.avatar||"",has_picture:!!lib.has_picture},device:typeof backendComputeDevice=="function"?backendComputeDevice(backend):""}}function perfHistoryGenderLabel(gender){return{F:"Female",M:"Male",N:"Diverse"}[gender]||""}function perfHistoryDeviceLabel(entry){return(entry==null?void 0:entry.device)||(typeof backendComputeDevice=="function"?backendComputeDevice((entry==null?void 0:entry.backend)||""):"")||"Unknown"}function perfHistoryDeviceHtml(entry){const label=perfHistoryDeviceLabel(entry),cls=typeof backendComputeDeviceClass=="function"?backendComputeDeviceClass((entry==null?void 0:entry.backend)||""):label.toLowerCase().includes("gpu")?"gpu":label.toLowerCase().includes("cpu")?"cpu":"";return`${escHtml(label)}`}function perfHistoryAvatarHtml(entry){var _a2;const meta=perfHistoryVoiceMeta(entry),title=meta.label||meta.id||"Voice";if(meta.hasPicture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(meta.avatar,24):null;if(icon)return`${icon.replace(/vp-avatar/g,"perf-history-avatar-icon")}`;const color=typeof avatarColor=="function"?avatarColor(meta.lang||meta.id||title):"#6b7280",init=((_a2=(title||"?").trim()[0])==null?void 0:_a2.toUpperCase())||"?";return`${escHtml(init)}`}function perfHistorySortValue(entry,key){switch(key){case"backend":return String(entry.backend||"").toLowerCase();case"language":return String(perfHistoryVoiceMeta(entry).lang||"").toLowerCase();case"gender":return String(perfHistoryGenderLabel(perfHistoryVoiceMeta(entry).gender)||"").toLowerCase();case"voice":return String(entry.voice||"").toLowerCase();case"device":return String(perfHistoryDeviceLabel(entry)||"").toLowerCase();case"avgLatencyMs":return Number(entry.avgLatencyMs);case"minLatencyMs":return Number(entry.minLatencyMs);case"avgRtf":return Number(entry.avgRtf);case"ts":default:return Number(entry.ts)}}function perfHistoryCompare(a,b){const av=perfHistorySortValue(a,PERF_HISTORY_SORT.key),bv=perfHistorySortValue(b,PERF_HISTORY_SORT.key);let result=0;if(typeof av=="string"||typeof bv=="string")result=String(av).localeCompare(String(bv),void 0,{numeric:!0,sensitivity:"base"});else{const an=Number.isFinite(av)?av:-1/0,bn=Number.isFinite(bv)?bv:-1/0;result=an===bn?0:an-bn}return PERF_HISTORY_SORT.dir==="asc"?result:-result}function perfHistoryHeadButton(key,label){const active=PERF_HISTORY_SORT.key===key,icon=active?PERF_HISTORY_SORT.dir==="asc"?"mdi-arrow-up":"mdi-arrow-down":"mdi-swap-vertical";return``}function renderPerfHistory(){var _a2,_b2;const histList=$("perf-history-list");if(!histList)return;const filterEl=$("perf-history-filter-current"),filterOn=filterEl==null?void 0:filterEl.checked,curBack=(_a2=$("perf-backend-select"))==null?void 0:_a2.value,curVoice=(_b2=$("perf-voice-select"))==null?void 0:_b2.value;let entries=perfHistoryLoad().slice();if(filterOn&&curBack&&(entries=entries.filter(e=>e.backend===curBack&&e.voice===curVoice)),!entries.length){histList.innerHTML='
'+(filterOn?"No history for this backend/voice yet.":"No benchmark history yet. Run a benchmark above to start tracking.")+"
";return}entries.sort((a,b)=>perfHistoryCompare(a,b)||Number(b.ts)-Number(a.ts));const head=`
${perfHistoryHeadButton("ts","Date / Time")} @@ -775,7 +779,7 @@ This warms each voice so the engine caches its .pt and first playback is instant ${maxL} ms worst ${avgRtf.toFixed(2)} avg RTF ${avgRtf<1?' Real-time capable':' Slower than real-time'} - `,sessionDone&&rtfArr.length&&(updateTrendDisplay(perfBackendSel.value,perfVoiceSel.value,avgRtf),perfHistoryAdd({ts:Date.now(),backend:perfBackendSel.value,voice:perfVoiceSel.value,...perfHistoryExtraFields(perfVoiceSel.value,perfBackendSel.value),textLen:perfText.value.trim().length,avgLatencyMs:avg,minLatencyMs:minL,maxLatencyMs:maxL,avgRtf,runCount:ok.length,allOk:ok.length===perfRows.length}),renderPerfHistory())}else perfSummary.innerHTML='All runs failed'}perfClearBtn.addEventListener("click",()=>{perfRows=[],renderPerfTable(),$("perf-trend-row")&&($("perf-trend-row").style.display="none"),perfProgress.style.display="none"}),perfRunBtn.addEventListener("click",async()=>{const backend=perfBackendSel.value,voice=perfVoiceSel.value,text=perfText.value.trim(),runs=parseInt(perfRunsSel.value)||3;if(!backend){toast("Select a backend first","error");return}if(!voice){toast("Fetch and select a voice first","error");return}if(!text){toast("Enter sample text","error");return}perfRows=[],perfRunBtn.disabled=!0,perfProgress.style.display="";for(let i=0;i1?"s":""} completed.`,renderPerfTable(!0),perfRunBtn.disabled=!1}),(_a2=$("perf-history-filter-current"))==null||_a2.addEventListener("change",renderPerfHistory),(_b2=$("perf-history-clear-btn"))==null||_b2.addEventListener("click",()=>{perfHistorySave([]),renderPerfHistory(),toast("Benchmark history cleared","success")}),renderPerfHistory()})();async function mergeWavBlobs(blobs){if(!blobs||blobs.length===0)return null;if(blobs.length===1)return blobs[0];function parseWav(bytes){const v=new DataView(bytes.buffer);let off=12,fmt=null,dataOff=0,dataSize=0;for(;off+8<=bytes.length;){const id=v.getUint32(off,!1),sz=v.getUint32(off+4,!0);id===1718449184?fmt={channels:v.getUint16(off+10,!0),sampleRate:v.getUint32(off+12,!0),bitDepth:v.getUint16(off+22,!0)}:id===1684108385&&(dataOff=off+8,dataSize=sz),off+=8+sz}return{fmt,dataOff,dataSize}}const parsed=[];for(const b of blobs){const bytes=new Uint8Array(await b.arrayBuffer()),p=parseWav(bytes);if(!p.fmt)throw new Error("Invalid WAV in chunk");parsed.push({bytes,...p})}const ref=parsed[0].fmt,totalPcm=parsed.reduce((s,p)=>s+p.dataSize,0),out=new Uint8Array(44+totalPcm),dv=new DataView(out.buffer);dv.setUint32(0,1380533830,!1),dv.setUint32(4,36+totalPcm,!0),dv.setUint32(8,1463899717,!1),dv.setUint32(12,1718449184,!1),dv.setUint32(16,16,!0),dv.setUint16(20,1,!0),dv.setUint16(22,ref.channels,!0),dv.setUint32(24,ref.sampleRate,!0),dv.setUint32(28,ref.sampleRate*ref.channels*(ref.bitDepth>>3),!0),dv.setUint16(32,ref.channels*(ref.bitDepth>>3),!0),dv.setUint16(34,ref.bitDepth,!0),dv.setUint32(36,1684108385,!1),dv.setUint32(40,totalPcm,!0);let pos=44;for(const p of parsed)out.set(p.bytes.slice(p.dataOff,p.dataOff+p.dataSize),pos),pos+=p.dataSize;return new Blob([out],{type:"audio/wav"})}function splitTextIntoChunks(text,maxLen=800){const abbrev=/\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|e\.g|i\.e)\.\s/g,safe=text.replace(abbrev,m=>m.replace(".","\0")),restore=s=>s.replace(/\x00/g,"."),segments=[];for(const line of safe.split(` + `,sessionDone&&rtfArr.length&&(updateTrendDisplay(perfBackendSel.value,perfVoiceSel.value,avgRtf),perfHistoryAdd({ts:Date.now(),backend:perfBackendSel.value,voice:perfVoiceSel.value,...perfHistoryExtraFields(perfVoiceSel.value,perfBackendSel.value),textLen:perfText.value.trim().length,avgLatencyMs:avg,minLatencyMs:minL,maxLatencyMs:maxL,avgRtf,runCount:ok.length,allOk:ok.length===perfRows.length}),renderPerfHistory())}else perfSummary.innerHTML='All runs failed'}perfClearBtn.addEventListener("click",()=>{perfRows=[],renderPerfTable(),$("perf-trend-row")&&($("perf-trend-row").style.display="none"),perfProgress.style.display="none"}),perfRunBtn.addEventListener("click",async()=>{const backend=perfBackendSel.value,voice=perfVoiceSel.value,text=perfText.value.trim(),runs=parseInt(perfRunsSel.value)||3;if(!backend){toast("Select a backend first","error");return}if(!voice){toast("Fetch and select a voice first","error");return}if(!text){toast("Enter sample text","error");return}perfRows=[],perfRunBtn.disabled=!0,perfProgress.style.display="";for(let i=0;i1?"s":""} completed.`,renderPerfTable(!0),perfRunBtn.disabled=!1}),(_a2=$("perf-history-filter-current"))==null||_a2.addEventListener("change",renderPerfHistory),(_b2=$("perf-history-clear-btn"))==null||_b2.addEventListener("click",()=>{perfHistorySave([]),renderPerfHistory(),toast("Benchmark history cleared","success")}),renderPerfHistory()})();function _parseWavBytes(bytes){const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);let off=12,fmt=null,dataOff=0,dataSize=0;for(;off+8<=bytes.length;){const id=v.getUint32(off,!1),sz=v.getUint32(off+4,!0);id===1718449184?fmt={channels:v.getUint16(off+10,!0),sampleRate:v.getUint32(off+12,!0),bitDepth:v.getUint16(off+22,!0)}:id===1684108385&&(dataOff=off+8,dataSize=sz),off+=8+sz}return{fmt,dataOff,dataSize}}function _buildWavBlob(pcmBytes,fmt){const out=new Uint8Array(44+pcmBytes.length),dv=new DataView(out.buffer);return dv.setUint32(0,1380533830,!1),dv.setUint32(4,36+pcmBytes.length,!0),dv.setUint32(8,1463899717,!1),dv.setUint32(12,1718449184,!1),dv.setUint32(16,16,!0),dv.setUint16(20,1,!0),dv.setUint16(22,fmt.channels,!0),dv.setUint32(24,fmt.sampleRate,!0),dv.setUint32(28,fmt.sampleRate*fmt.channels*(fmt.bitDepth>>3),!0),dv.setUint16(32,fmt.channels*(fmt.bitDepth>>3),!0),dv.setUint16(34,fmt.bitDepth,!0),dv.setUint32(36,1684108385,!1),dv.setUint32(40,pcmBytes.length,!0),out.set(pcmBytes,44),new Blob([out],{type:"audio/wav"})}function _silencePcmBytes(ms,fmt){const bytesPerSample=fmt.bitDepth>>3,frames=Math.round(ms/1e3*fmt.sampleRate);return new Uint8Array(frames*fmt.channels*bytesPerSample)}async function mergeWavBlobs(blobs,gapMs=0){if(!blobs||blobs.length===0)return null;if(blobs.length===1&&!gapMs)return blobs[0];const parsed=[];for(const b of blobs){const bytes=new Uint8Array(await b.arrayBuffer()),p=_parseWavBytes(bytes);if(!p.fmt)throw new Error("Invalid WAV in chunk");parsed.push({bytes,...p})}const ref=parsed[0].fmt,silence=gapMs>0?_silencePcmBytes(gapMs,ref):null,totalPcm=parsed.reduce((s,p)=>s+p.dataSize,0)+(silence?silence.length*(parsed.length-1):0),out=new Uint8Array(totalPcm);let pos=0;return parsed.forEach((p,i)=>{out.set(p.bytes.slice(p.dataOff,p.dataOff+p.dataSize),pos),pos+=p.dataSize,silence&&im.replace(".","\0")),restore=s=>s.replace(/\x00/g,"."),segments=[];for(const line of safe.split(` `)){if(!line.trim()){segments.length&&segments.push("");continue}const sentences=line.match(/[^.!?]+[.!?]+\s*/g)||[],rest=line.replace(/[^.!?]+[.!?]+\s*/g,"").trim();segments.push(...sentences),rest&&segments.push(rest)}if(!segments.length)return[text];const chunks=[];let cur="",paraPending=!1;for(const seg of segments){if(seg===""){paraPending=!0;continue}const joined=cur?cur+(paraPending?` `:" ")+seg.trim():seg.trim();joined.length>maxLen&&cur?(chunks.push(restore(cur.trim())),cur=seg.trim()):cur=joined,paraPending=!1}return cur.trim()&&chunks.push(restore(cur.trim())),chunks.length?chunks:[text]}async function generateChunkedTts(voice,text,backend,instruct,extra=null,applyPersona=!1){const chunks=splitTextIntoChunks(text),prog=$("preview-chunk-progress");prog&&(prog.hidden=!1,prog.textContent=`Chunk 1 / ${chunks.length}\u2026`);const blobs=[];for(let i=0;i20&&_genHistory.pop(),renderHistory()}function _histAvatarColor(id){const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i<(id||"").length;i++)h=h*31+id.charCodeAt(i)>>>0;return palette[h%palette.length]}function renderHistory(){const list=$("history-list");if(list){if(!_genHistory.length){list.innerHTML='
No generations yet.
';return}list.innerHTML=_genHistory.map(item=>{const t=new Date(item.ts),ts=String(t.getHours()).padStart(2,"0")+":"+String(t.getMinutes()).padStart(2,"0"),preview=escHtml(item.text.length>90?item.text.slice(0,90)+"\u2026":item.text),v=(window._voices||[]).find(vx=>vx.id===item.voice),avatar=v!=null&&v.has_picture?``:`${(item.voice||"?")[0].toUpperCase()}`;return`
@@ -862,7 +866,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}(_ba=$("stt-tts-file"))==null||_ba.addEventListener("change",async()=>{const input=$("stt-tts-file");input.files&&input.files.length&&await sttTtsUploadFile(input.files[0]),input.value=""}),(_ca=$("stt-tts-refresh-stt-btn"))==null||_ca.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}}),(_da=$("stt-tts-stt-backend"))==null||_da.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}(_ea=$("stt-tts-rec-start"))==null||_ea.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")}}),(_fa=$("stt-tts-rec-stop"))==null||_fa.addEventListener("click",()=>{sttTtsRecorder&&sttTtsRecorder.state!=="inactive"&&sttTtsRecorder.stop()}),(_ga=$("stt-tts-transcribe-btn"))==null||_ga.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}}),(_ha=$("stt-tts-copy-preview-btn"))==null||_ha.addEventListener("click",()=>{const text=$("stt-tts-text").value.trim();text&&($("preview-text-area").value=text,switchTab("generation"),toast("Copied transcription to TTS Generation","success"))}),(_ia=$("stt-tts-backend-select"))==null||_ia.addEventListener("change",()=>{$("stt-tts-voice-select").innerHTML='',sttTtsOutputBlob=null,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0,updateBackendHelp()}),(_ja=$("stt-tts-fetch-voices-btn"))==null||_ja.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}}),(_ka=$("stt-tts-generate-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){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}}),(_la=$("stt-tts-save-mp3-btn"))==null||_la.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}}),(_ma=$("stt-tts-save-wav-btn"))==null||_ma.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 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` +`),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:!1,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+` @@ -1014,7 +1018,7 @@ This warms each voice so the engine caches its .pt and first playback is instant
- `}).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("")}(_sa=$("reh-fetch-voices-btn"))==null||_sa.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}}),(_ta=$("reh-narrator-voice"))==null||_ta.addEventListener("change",function(){rehState.narratorVoice=this.value}),(_ua=$("reh-back-1-btn"))==null||_ua.addEventListener("click",()=>showPhase(1));const _BUILD_INSTRUCT_TEMPLATES={DE:e=>`Sprich in einem ${e} Tonfall.`,EN:e=>`Speak in a ${e} manner.`},_BUILD_INSTRUCT_LANG_NAMES={DE:"German",FR:"French",ES:"Spanish",IT:"Italian",PT:"Portuguese",NL:"Dutch",PL:"Polish"};function _buildAccentClause(langCode){const langName=_BUILD_INSTRUCT_LANG_NAMES[langCode];return langName?`Speak with an authentic native ${langName} accent \u2014 not American-accented, not an English speaker doing ${langName}.`:langCode==="EN"?"English with a neutral British or international accent, explicitly not American/US-accented.":""}function _buildInstruct(voiceProfile,emotion,voiceId){const p=(voiceProfile||"").trim(),e=(emotion||"").trim(),langCode=String(voiceId||"").split("_")[0].toUpperCase(),accent=_buildAccentClause(langCode);if(!e&&!p&&!accent)return"";const tmpl=_BUILD_INSTRUCT_TEMPLATES[langCode]||_BUILD_INSTRUCT_TEMPLATES.EN;return[e?tmpl(e):"",p,accent].filter(Boolean).join(" ")}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(""))}(_va=$("reh-llm-refresh"))==null||_va.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;(_wa=$("reh-autodesign-cancel"))==null||_wa.addEventListener("click",()=>{rehDesignCancelled=!0}),(_xa=$("reh-autodesign-btn"))==null||_xa.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}(_ya=$("reh-matchlib-btn"))==null||_ya.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:""};(_za=$("reh-matchonline-btn"))==null||_za.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("")}(_sa=$("reh-fetch-voices-btn"))==null||_sa.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}}),(_ta=$("reh-narrator-voice"))==null||_ta.addEventListener("change",function(){rehState.narratorVoice=this.value}),(_ua=$("reh-back-1-btn"))==null||_ua.addEventListener("click",()=>showPhase(1));const _BUILD_INSTRUCT_TEMPLATES={DE:e=>`Sprich in einem ${e} Tonfall.`,EN:e=>`Speak in a ${e} manner.`},_BUILD_INSTRUCT_LANG_NAMES={DE:"German",FR:"French",ES:"Spanish",IT:"Italian",PT:"Portuguese",NL:"Dutch",PL:"Polish"};function _buildAccentClause(langCode){const langName=_BUILD_INSTRUCT_LANG_NAMES[langCode];return langName?`Speak with an authentic native ${langName} accent \u2014 not American-accented, not an English speaker doing ${langName}.`:langCode==="EN"?"English with a neutral British or international accent, explicitly not American/US-accented.":""}function _buildInstruct(voiceProfile,emotion,voiceId){const p=(voiceProfile||"").trim(),e=(emotion||"").trim(),langCode=String(voiceId||"").split("_")[0].toUpperCase(),accent=_buildAccentClause(langCode);if(!e&&!p&&!accent)return"";const tmpl=_BUILD_INSTRUCT_TEMPLATES[langCode]||_BUILD_INSTRUCT_TEMPLATES.EN;return[e?tmpl(e):"",p,accent].filter(Boolean).join(" ")}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)}const _REH_EMOTION_DE_EN={w\u00FCtend:"angry",zornig:"angry",erz\u00FCrnt:"angry",ver\u00E4rgert:"annoyed",gereizt:"irritated",traurig:"sad",melancholisch:"melancholic",betr\u00FCbt:"sad",niedergeschlagen:"dejected",\u00E4ngstlich:"scared",furchtsam:"fearful",ver\u00E4ngstigt:"frightened",panisch:"panicked",nerv\u00F6s:"nervous",fr\u00F6hlich:"happy",gl\u00FCcklich:"happy",freudig:"joyful",heiter:"cheerful",vergn\u00FCgt:"delighted",fl\u00FCsternd:"whispering",leise:"quiet",ged\u00E4mpft:"hushed",aufgeregt:"excited",begeistert:"enthusiastic",euphorisch:"euphoric",\u00FCberrascht:"surprised",erstaunt:"astonished",verbl\u00FCfft:"amazed",genervt:"annoyed",frustriert:"frustrated",verzweifelt:"desperate",hoffnungslos:"hopeless",resigniert:"resigned",entschlossen:"determined",entschieden:"decisive",selbstbewusst:"confident",stolz:"proud",arrogant:"arrogant",sch\u00FCchtern:"shy",verlegen:"embarrassed",unsicher:"uncertain",ironisch:"sarcastic",sarkastisch:"sarcastic",sp\u00F6ttisch:"mocking",h\u00F6hnisch:"scornful",ver\u00E4chtlich:"contemptuous",ernst:"serious",streng:"stern",autorit\u00E4r:"authoritative",befehlend:"commanding",sanft:"gentle",z\u00E4rtlich:"tender",liebevoll:"loving",warm:"warm",kalt:"cold",distanziert:"distant",gleichg\u00FCltig:"indifferent",gelangweilt:"bored",geheimnisvoll:"mysterious",unheimlich:"eerie",d\u00FCster:"ominous",bedrohlich:"threatening",dramatisch:"dramatic",theatralisch:"theatrical",pathetisch:"melodramatic",ruhig:"calm",gelassen:"composed",besonnen:"measured",schockiert:"shocked",entsetzt:"horrified",fassungslos:"stunned",z\u00F6gernd:"hesitant",verwirrt:"confused",ratlos:"bewildered",unschl\u00FCssig:"undecided",weinend:"tearful",schluchzend:"sobbing",trauernd:"grieving",gebrochen:"broken",schroff:"curt",barsch:"gruff",grob:"rough",abweisend:"dismissive",freundlich:"friendly",herzlich:"warm",einladend:"welcoming",spielerisch:"playful",neckend:"teasing",frech:"cheeky",schelmisch:"mischievous",romantisch:"romantic",sehns\u00FCchtig:"longing",verliebt:"infatuated",triumphierend:"triumphant",siegessicher:"victorious",erleichtert:"relieved",beruhigt:"reassured",schuldbewusst:"guilty",reum\u00FCtig:"remorseful",neugierig:"curious",interessiert:"interested",m\u00FCde:"weary",ersch\u00F6pft:"exhausted",wehm\u00FCtig:"wistful",nostalgisch:"nostalgic",bemerkend:"remarking",feststellend:"noting",sachlich:"matter-of-fact",n\u00FCchtern:"plain",flehend:"pleading",bittend:"imploring",warnend:"warning",mahnend:"admonishing",trotzig:"defiant",rebellisch:"rebellious",erschrocken:"startled",verst\u00F6rt:"disturbed"};function _rehEmotionEnglishTag(emotion){const raw=(emotion||"").trim();if(!raw)return"";const lower=raw.split(/[,;]\s*/)[0].trim().toLowerCase();if(_REH_EMOTION_DE_EN[lower])return _REH_EMOTION_DE_EN[lower];const stem=lower.replace(/(e|er|es|en|em)$/,"");if(stem.length>=4){for(const key in _REH_EMOTION_DE_EN)if(key.startsWith(stem))return _REH_EMOTION_DE_EN[key]}return/^[a-z\- ]+$/.test(lower)?lower:""}function _rehInlineTone(text,emotion){if(!_rehBackendIsFish()||/^\s*\[/.test(text))return text;const tag=_rehEmotionEnglishTag(emotion);return tag?`[${tag}] ${text}`: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(""))}(_va=$("reh-llm-refresh"))==null||_va.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;(_wa=$("reh-autodesign-cancel"))==null||_wa.addEventListener("click",()=>{rehDesignCancelled=!0}),(_xa=$("reh-autodesign-btn"))==null||_xa.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}(_ya=$("reh-matchlib-btn"))==null||_ya.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:""};(_za=$("reh-matchonline-btn"))==null||_za.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")} @@ -1031,7 +1035,13 @@ This warms each voice so the engine caches its .pt and first playback is instant ${editBtn} -
`;if(line.hidden&&!(rehState.bulkMode&&rehState.showHidden))return"";const _bSel=rehState.bulkSel.has(i),bulkCheck=rehState.bulkMode?``:"",lineFlags=(line.ignored?" reh-line-ignored":"")+(line.hidden?" reh-line-hidden":"")+(_bSel?" reh-selected":"");switch(line.type){case"act":return`
${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}
`;case"scene":return`
+
`;if(line.hidden&&!(rehState.bulkMode&&rehState.showHidden))return"";const _bSel=rehState.bulkSel.has(i),bulkCheck=rehState.bulkMode?``:"",lineFlags=(line.ignored?" reh-line-ignored":"")+(line.hidden?" reh-line-hidden":"")+(_bSel?" reh-selected":"");if(typeof audiobookIsChapter=="function"&&audiobookIsChapter(line)){const label=(typeof stripMarkdown=="function"?stripMarkdown(line.text||""):line.text||"").trim();return`
+ ${bulkCheck} + + ${label?escHtml(label):"Chapter"} + + ${editBtn} ${synthDot} +
`}switch(line.type){case"act":return`
${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}
`;case"scene":return`
${bulkCheck} ${escHtml(line.text)} ${editBtn} ${synthDot} @@ -1091,7 +1101,12 @@ 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)}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,c.voice);_showReSynthBtn(idx,!1),_markSynthDot(idx,"synthesizing");try{const blob=await fetchTtsPreviewBlob(c.voice,_rehInlineTone(stripMarkdown(line.text),line.emotion),"wav",instruct,_ttsBackendForVoice(c.voice,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}(_Ga=$("reh-tone-warn-close"))==null||_Ga.addEventListener("click",()=>{const w=$("reh-tone-warn");w&&(w.hidden=!0)}),(_Ha=$("reh-tb-play"))==null||_Ha.addEventListener("click",()=>{rehState.playing?pausePlay():startPlay()}),(_Ia=$("reh-tb-stop"))==null||_Ia.addEventListener("click",()=>{var _a2;stopPlay(),rehState.lineIndex=(_a2=rehState.practiceStart)!=null?_a2:0,highlightCurrentLine(),hideRecOverlay()}),(_Ja=$("reh-tb-prev"))==null||_Ja.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.max(0,rehState.lineIndex-1),highlightCurrentLine(),hideRecOverlay()}),(_Ka=$("reh-tb-next"))==null||_Ka.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.min(rehState.lines.length-1,rehState.lineIndex+1),highlightCurrentLine(),hideRecOverlay()}),(_La=$("reh-tb-repeat"))==null||_La.addEventListener("click",()=>{var _a2;rehState.repeat=!rehState.repeat,(_a2=$("reh-tb-repeat"))==null||_a2.classList.toggle("reh-btn-active",rehState.repeat)}),(_Ma=$("reh-skip-desc-toggle"))==null||_Ma.addEventListener("change",function(){rehState.skipDescriptions=this.checked}),(_Na=$("reh-edit-script-btn"))==null||_Na.addEventListener("click",openScriptEditorModal),(_Oa=$("reh-fountain-btn"))==null||_Oa.addEventListener("click",exportFountain),(_Pa=$("reh-fountain-export-p4"))==null||_Pa.addEventListener("click",exportFountain),(_Qa=$("reh-fdx-export-btn"))==null||_Qa.addEventListener("click",exportFDX),(_Ra=$("reh-osf-export-btn"))==null||_Ra.addEventListener("click",exportOSF),(_Sa=$("reh-exit-btn"))==null||_Sa.addEventListener("click",()=>{stopPlay(),stopRehMic(),rehState.clips.length?(renderSummary(),showPhase(4)):showPhase(2)}),(_Ta=$("reh-page-title"))==null||_Ta.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)}),(_Ua=$("reh-page-title"))==null||_Ua.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)}}),(_Va=$("reh-page-title"))==null||_Va.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","",_ttsBackendForVoice(rehState.narratorVoice,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,cast.voice),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,_ttsBackendForVoice(cast.voice,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,c.voice)}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,_ttsBackendForVoice(voice,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)}(_Wa=$("reh-tb-synth-all"))==null||_Wa.addEventListener("click",()=>synthAll()),(_Xa=$("reh-tb-resynth-stale"))==null||_Xa.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,_ttsBackendForVoice(c.voice,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")}),(_Ya=$("reh-synth-cancel"))==null||_Ya.addEventListener("click",()=>{rehState.synthCancelled=!0,rehState.synthRunning=!1}),(_Za=$("reh-tb-clean-cache"))==null||_Za.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,c.voice),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)}(__a=$("reh-rec-start"))==null||__a.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()}),(_ab=$("reh-rec-keep"))==null||_ab.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()}),(_bb=$("reh-rec-redo"))==null||_bb.addEventListener("click",()=>{stopRehMic();const l=rehState.lines[rehState.lineIndex];l&&showRecOverlay(l)}),(_cb=$("reh-skip-line"))==null||_cb.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,c.voice);_showReSynthBtn(idx,!1),_markSynthDot(idx,"synthesizing");try{const blob=await fetchTtsPreviewBlob(c.voice,_rehInlineTone(stripMarkdown(line.text),line.emotion),"wav",instruct,_ttsBackendForVoice(c.voice,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 _rehToneCmpRow(backend,isCurrent){const check=ok=>ok?'':'',action=isCurrent?'current':``;return` + ${escHtml(backend.label)} + ${check(backend.style_aware)} + ${check(backend.uses_wav)} + ${action} + `}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&&x.uses_wav)||all.find(x=>x.style_aware);txtEl&&(txtEl.innerHTML=styleAware?`${_rehToneCmpRow(b,!0)}${_rehToneCmpRow(styleAware,!1)}
Tone controlVoice stays identical
`:`${escHtml(b.label)} keeps each character\u2019s voice consistent but has weak tone control \u2014 tone picks may have little effect.`),warn.hidden=!1}else if(b.style_aware&&!b.uses_wav){const wavBackend=all.find(x=>x.uses_wav&&x.style_aware)||all.find(x=>x.uses_wav),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=wavBackend?`${_rehToneCmpRow(b,!0)}${_rehToneCmpRow(wavBackend,!1)}
Tone controlVoice stays identical
${qwenHint}`:`${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won\u2019t sound the same throughout.${qwenHint}`),warn.hidden=!1}else warn.hidden=!0}(_Ga=$("reh-tone-warn-close"))==null||_Ga.addEventListener("click",()=>{const w=$("reh-tone-warn");w&&(w.hidden=!0)}),(_Ha=$("reh-tone-warn"))==null||_Ha.addEventListener("click",e=>{const btn=e.target.closest(".reh-tone-switch-btn");if(!btn)return;const id=btn.dataset.backendId,sel=$("reh-backend-select");sel&&[...sel.options].some(o=>o.value===id)&&(sel.value=id),rehState.backend=id,_checkToneStyleSupport();const b=typeof backendById=="function"?backendById(id):null;toast("Switched to "+(b?b.label:id),"success")}),(_Ia=$("reh-tb-play"))==null||_Ia.addEventListener("click",()=>{rehState.playing?pausePlay():startPlay()}),(_Ja=$("reh-tb-stop"))==null||_Ja.addEventListener("click",()=>{var _a2;stopPlay(),rehState.lineIndex=(_a2=rehState.practiceStart)!=null?_a2:0,highlightCurrentLine(),hideRecOverlay()}),(_Ka=$("reh-tb-prev"))==null||_Ka.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.max(0,rehState.lineIndex-1),highlightCurrentLine(),hideRecOverlay()}),(_La=$("reh-tb-next"))==null||_La.addEventListener("click",()=>{stopPlay(),rehState.lineIndex=Math.min(rehState.lines.length-1,rehState.lineIndex+1),highlightCurrentLine(),hideRecOverlay()}),(_Ma=$("reh-tb-repeat"))==null||_Ma.addEventListener("click",()=>{var _a2;rehState.repeat=!rehState.repeat,(_a2=$("reh-tb-repeat"))==null||_a2.classList.toggle("reh-btn-active",rehState.repeat)}),(_Na=$("reh-skip-desc-toggle"))==null||_Na.addEventListener("change",function(){rehState.skipDescriptions=this.checked}),(_Oa=$("reh-edit-script-btn"))==null||_Oa.addEventListener("click",openScriptEditorModal),(_Pa=$("reh-fountain-btn"))==null||_Pa.addEventListener("click",exportFountain),(_Qa=$("reh-fountain-export-p4"))==null||_Qa.addEventListener("click",exportFountain),(_Ra=$("reh-fdx-export-btn"))==null||_Ra.addEventListener("click",exportFDX),(_Sa=$("reh-osf-export-btn"))==null||_Sa.addEventListener("click",exportOSF),(_Ta=$("reh-exit-btn"))==null||_Ta.addEventListener("click",()=>{stopPlay(),stopRehMic(),rehState.clips.length?(renderSummary(),showPhase(4)):showPhase(2)}),(_Ua=$("reh-page-title"))==null||_Ua.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)}),(_Va=$("reh-page-title"))==null||_Va.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)}}),(_Wa=$("reh-page-title"))==null||_Wa.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)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&&!rehState.skipDescriptions){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","",_ttsBackendForVoice(rehState.narratorVoice,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,cast.voice),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,_ttsBackendForVoice(cast.voice,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,c.voice)}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,_ttsBackendForVoice(voice,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)}(_Xa=$("reh-tb-synth-all"))==null||_Xa.addEventListener("click",()=>synthAll()),(_Ya=$("reh-tb-resynth-stale"))==null||_Ya.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,_ttsBackendForVoice(c.voice,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")}),(_Za=$("reh-synth-cancel"))==null||_Za.addEventListener("click",()=>{rehState.synthCancelled=!0,rehState.synthRunning=!1}),(__a=$("reh-tb-clean-cache"))==null||__a.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,c.voice),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)}(_$a=$("reh-rec-start"))==null||_$a.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")}}),(_ab=$("reh-rec-stop"))==null||_ab.addEventListener("click",()=>{var _a2;((_a2=rehState.mediaRec)==null?void 0:_a2.state)!=="inactive"&&rehState.mediaRec.stop()}),(_bb=$("reh-rec-keep"))==null||_bb.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()}),(_cb=$("reh-rec-redo"))==null||_cb.addEventListener("click",()=>{stopRehMic();const l=rehState.lines[rehState.lineIndex];l&&showRecOverlay(l)}),(_db=$("reh-skip-line"))==null||_db.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}
@@ -1099,7 +1114,7 @@ This warms each voice so the engine caches its .pt and first playback is instant ${audioHtml}
${dlHtml} -
`}).join("")}}(_db=$("reh-new-session-btn"))==null||_db.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)}),(_eb=$("reh-resume-btn"))==null||_eb.addEventListener("click",()=>{showPhase(3),highlightCurrentLine()}),(_fb=$("reh-save-session-btn"))==null||_fb.addEventListener("click",saveToLibrary),(_gb=$("reh-cast-save-btn"))==null||_gb.addEventListener("click",saveToLibrary),(_hb=$("reh-export-session-btn"))==null||_hb.addEventListener("click",exportToFile),(_ib=$("reh-tb-save"))==null||_ib.addEventListener("click",saveToLibrary),(_jb=$("reh-tb-export"))==null||_jb.addEventListener("click",exportToFile),(_kb=$("reh-lib-import-file"))==null||_kb.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))})}),(_lb=$("reh-impex-reh-btn"))==null||_lb.addEventListener("click",exportToFile),(_mb=$("reh-impex-fountain-btn"))==null||_mb.addEventListener("click",()=>{var _a2;(_a2=$("reh-fountain-btn"))==null||_a2.click()}),(_nb=$("reh-impex-fdx-btn"))==null||_nb.addEventListener("click",()=>{var _a2;(_a2=$("reh-fdx-export-btn"))==null||_a2.click()}),(_ob=$("reh-impex-osf-btn"))==null||_ob.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("")}}(_eb=$("reh-new-session-btn"))==null||_eb.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)}),(_fb=$("reh-resume-btn"))==null||_fb.addEventListener("click",()=>{showPhase(3),highlightCurrentLine()}),(_gb=$("reh-save-session-btn"))==null||_gb.addEventListener("click",saveToLibrary),(_hb=$("reh-cast-save-btn"))==null||_hb.addEventListener("click",saveToLibrary),(_ib=$("reh-export-session-btn"))==null||_ib.addEventListener("click",exportToFile),(_jb=$("reh-tb-save"))==null||_jb.addEventListener("click",saveToLibrary),(_kb=$("reh-tb-export"))==null||_kb.addEventListener("click",exportToFile),(_lb=$("reh-lib-import-file"))==null||_lb.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))})}),(_mb=$("reh-impex-reh-btn"))==null||_mb.addEventListener("click",exportToFile),(_nb=$("reh-impex-fountain-btn"))==null||_nb.addEventListener("click",()=>{var _a2;(_a2=$("reh-fountain-btn"))==null||_a2.click()}),(_ob=$("reh-impex-fdx-btn"))==null||_ob.addEventListener("click",()=>{var _a2;(_a2=$("reh-fdx-export-btn"))==null||_a2.click()}),(_pb=$("reh-impex-osf-btn"))==null||_pb.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} @@ -1114,7 +1129,7 @@ ${l.text} `);_downloadText(`# ${title} ${lines.trim()} -`,title.replace(/[^a-z0-9]/gi,"_")+".md")}(_pb=$("reh-impex-txt-btn"))==null||_pb.addEventListener("click",exportAsTxt),(_qb=$("reh-impex-md-btn"))==null||_qb.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")}(_qb=$("reh-impex-txt-btn"))==null||_qb.addEventListener("click",exportAsTxt),(_rb=$("reh-impex-md-btn"))==null||_rb.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=`
@@ -1127,11 +1142,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,pc.voice),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,_ttsBackendForVoice(cue.voice,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((_rb=$("reh-bulk-toggle"))==null||_rb.addEventListener("click",()=>setBulkMode(!rehState.bulkMode)),(_sb=$("reh-bulk-done"))==null||_sb.addEventListener("click",()=>setBulkMode(!1)),(_tb=$("reh-bulk-all"))==null||_tb.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()}),(_ub=$("reh-bulk-none"))==null||_ub.addEventListener("click",()=>{const had=[...rehState.bulkSel];rehState.bulkSel.clear(),rehState.bulkAnchor=null,had.forEach(_refreshBulkLine),_updateBulkCount()}),(_vb=$("reh-bulk-ignore"))==null||_vb.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!0})),(_wb=$("reh-bulk-unignore"))==null||_wb.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!1})),(_xb=$("reh-bulk-hide"))==null||_xb.addEventListener("click",()=>_bulkApply(l=>{l.hidden=!0})),(_yb=$("reh-bulk-delete"))==null||_yb.addEventListener("click",_bulkDelete),(_zb=$("reh-bulk-show-hidden"))==null||_zb.addEventListener("change",e=>{rehState.showHidden=e.target.checked,buildScriptPage()}),(_Ab=$("reh-page-mode-btn"))==null||_Ab.addEventListener("click",cyclePageMode),(_Bb=$("reh-train-open-btn"))==null||_Bb.addEventListener("click",enterTrainMode),(_Cb=$("reh-train-exit-btn"))==null||_Cb.addEventListener("click",exitTrainMode),(_Db=$("reh-train-play"))==null||_Db.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"))}),(_Eb=$("reh-train-record"))==null||_Eb.addEventListener("click",()=>{(trainState.phase==="ready"||trainState.phase==="done")&&_trainStartRecording()}),(_Fb=$("reh-train-stop-rec"))==null||_Fb.addEventListener("click",_trainStopRecording),(_Gb=$("reh-train-prev"))==null||_Gb.addEventListener("click",()=>{trainState.turn>0&&trainGoTo(trainState.turn-1)}),(_Hb=$("reh-train-next"))==null||_Hb.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?` +
`).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((_sb=$("reh-bulk-toggle"))==null||_sb.addEventListener("click",()=>setBulkMode(!rehState.bulkMode)),(_tb=$("reh-bulk-done"))==null||_tb.addEventListener("click",()=>setBulkMode(!1)),(_ub=$("reh-bulk-all"))==null||_ub.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()}),(_vb=$("reh-bulk-none"))==null||_vb.addEventListener("click",()=>{const had=[...rehState.bulkSel];rehState.bulkSel.clear(),rehState.bulkAnchor=null,had.forEach(_refreshBulkLine),_updateBulkCount()}),(_wb=$("reh-bulk-ignore"))==null||_wb.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!0})),(_xb=$("reh-bulk-unignore"))==null||_xb.addEventListener("click",()=>_bulkApply(l=>{l.ignored=!1})),(_yb=$("reh-bulk-hide"))==null||_yb.addEventListener("click",()=>_bulkApply(l=>{l.hidden=!0})),(_zb=$("reh-bulk-delete"))==null||_zb.addEventListener("click",_bulkDelete),(_Ab=$("reh-bulk-show-hidden"))==null||_Ab.addEventListener("change",e=>{rehState.showHidden=e.target.checked,buildScriptPage()}),(_Bb=$("reh-page-mode-btn"))==null||_Bb.addEventListener("click",cyclePageMode),(_Cb=$("reh-train-open-btn"))==null||_Cb.addEventListener("click",enterTrainMode),(_Db=$("reh-train-exit-btn"))==null||_Db.addEventListener("click",exitTrainMode),(_Eb=$("reh-train-play"))==null||_Eb.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"))}),(_Fb=$("reh-train-record"))==null||_Fb.addEventListener("click",()=>{(trainState.phase==="ready"||trainState.phase==="done")&&_trainStartRecording()}),(_Gb=$("reh-train-stop-rec"))==null||_Gb.addEventListener("click",_trainStopRecording),(_Hb=$("reh-train-prev"))==null||_Hb.addEventListener("click",()=>{trainState.turn>0&&trainGoTo(trainState.turn-1)}),(_Ib=$("reh-train-next"))==null||_Ib.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}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,` +`:" ")+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 _readerOcrAttempt(worker,canvas,psm){var _a2;await worker.setParameters({tessedit_pageseg_mode:psm});const{data}=await _readerTimeout(worker.recognize(canvas),2e4,"Heading OCR"),text=(data.text||"").replace(/\s+/g," ").trim();return!text||((_a2=data.confidence)!=null?_a2:0)40){const y0=Math.round(cropH*.45),inset=Math.round(crop.width*.03);tightCrop=document.createElement("canvas"),tightCrop.width=Math.max(crop.width-inset*2,1),tightCrop.height=cropH-y0,tightCrop.getContext("2d").putImageData(crop.getContext("2d").getImageData(inset,y0,tightCrop.width,tightCrop.height),0,0),text=await _readerOcrAttempt(worker,tightCrop,"7")}if(text||(text=await _readerOcrAttempt(worker,crop,"3")),!text)return[];const tokens=text.split(" ").filter(t=>t&&/[a-zäöüßàâçéèêëîïôûùüÿñæœ0-9]/i.test(t));if(!tokens.length)return[];const bandH=Math.max(gapPx-4,10),avgCharW=Math.max((base.width-16)/text.length,4),words=[];let x=8;for(const tok of tokens){const w=Math.max(tok.length*avgCharW,10);words.push({page:pageIdx,x,top:4,w,h:bandH,text:tok,ocr:!0}),x+=w+avgCharW}return words}catch(e){return console.warn("Heading OCR failed",e),[]}finally{crop&&(crop.width=0,crop.height=0),tightCrop&&(tightCrop.width=0,tightCrop.height=0)}}async function readerLoadPdfSkeleton(file){await loadPdfJs();const seq=readerState._seq,ab=await file.arrayBuffer(),pdf=await pdfjsLib.getDocument({data:ab}).promise;if(seq!==readerState._seq)return null;readerState.mode="pdf",readerState.pdfDoc=pdf;const doc=$("reader-doc"),nPages=pdf.numPages,page1=await pdf.getPage(1);if(seq!==readerState._seq)return null;const base1=page1.getViewport({scale:1});for(let p=1;p<=nPages;p++){const pageDiv=document.createElement("div");pageDiv.className="reader-page",pageDiv.style.width=base1.width+"px",pageDiv.style.height=base1.height+"px";const overlay=document.createElement("div");overlay.className="reader-overlay",pageDiv.appendChild(overlay),doc.appendChild(pageDiv),readerState.pages.push({pageDiv,overlay,page:null,base:base1,rendered:!1,renderTask:null}),p%50===0&&await readerYield()}return readerApplyZoom("fit-width"),readerSetupLazyRaster(),{pdf,page1,nPages}}async function readerExtractPdfText(loaded){var _a2;const seq=readerState._seq,pdf=loaded.pdf,page1=loaded.page1,nPages=loaded.nPages,progress=readerShowParseProgress(nPages),ocrHeadingsEnabled=((_a2=$("reader-ocr-headings"))==null?void 0:_a2.checked)!==!1;for(let p=1;p<=nPages;p++){const page=p===1?page1:await pdf.getPage(p);if(seq!==readerState._seq){progress.done();return}const base=page.getViewport({scale:1}),pageIdx=p-1,pgState=readerState.pages[pageIdx];pgState.page=page,pgState.base=base,pgState.pageDiv.style.width=base.width*readerState.scale+"px",pgState.pageDiv.style.height=base.height*readerState.scale+"px",readerIsPageNearView(pgState)&&readerRenderPage(pageIdx);let content;try{content=await _readerTimeout(page.getTextContent(),2e4,"Page text extraction")}catch(e){console.warn("getTextContent failed/timed out on page",p,e),content={items:[]}}if(seq!==readerState._seq)return;await readerYield();const pageWords=[];for(let itemIdx=0;itemIdxMath.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);pageUnits.length||pageUnits.push({text:"",words:[{page:pageIdx,x:0,top:0,w:0,h:0,text:""}],status:"pending",_stat:null,paraStart:!1});const 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{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 +`);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)+")")}function _readerTextForSynth(text){var _a2,_b2;const backend=((_a2=$("reader-backend-select"))==null?void 0:_a2.value)||"",emotion=((_b2=$("reader-emotion-select"))==null?void 0:_b2.value)||"";if(!/fish/i.test(backend)||!emotion||/^\s*\[/.test(text))return text;const tag=typeof _rehEmotionEnglishTag=="function"?_rehEmotionEnglishTag(emotion):"";return tag?`[${tag}] ${text}`:text}(function(){const sel=$("reader-emotion-select");!sel||typeof REH_EMOTIONS=="undefined"||(REH_EMOTIONS.forEach(e=>{if(!e.value)return;const o=document.createElement("option");o.value=e.value,o.textContent=`${e.emoji} ${e.label}`,sel.appendChild(o)}),sel.addEventListener("change",()=>{var _a2;const backend=((_a2=$("reader-backend-select"))==null?void 0:_a2.value)||"";if(!/fish/i.test(backend)){const instructInput=$("reader-instruct");instructInput&&sel.value&&(instructInput.value=sel.value)}}))})();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,_readerTextForSynth(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,_readerTextForSynth(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,_readerTextForSynth(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
@@ -1148,7 +1163,7 @@ ${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)}))}(_Jb=$("reader-file-input"))==null||_Jb.addEventListener("change",function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];this.value="",readerImportFile(f)}),(_Kb=$("reader-paste-load"))==null||_Kb.addEventListener("click",readerImportPastedText),(_Lb=$("reader-paste-text"))==null||_Lb.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&e.key==="Enter"&&(e.preventDefault(),readerImportPastedText())}),(_Mb=$("reader-fetch-voices-btn"))==null||_Mb.addEventListener("click",readerFetchVoices),(_Nb=$("reader-backend-select"))==null||_Nb.addEventListener("change",()=>{const sel=$("reader-voice-select");sel&&(sel.innerHTML=''),window.VoicePicker&&(VoicePicker.upgrade("reader-voice-select"),VoicePicker.populate("reader-voice-select",[])),readerUpdateBackendHint()}),(_Ob=$("reader-chunk-mode"))==null||_Ob.addEventListener("change",function(){readerRechunk(this.value)}),(_Pb=$("reader-normalize"))==null||_Pb.addEventListener("change",function(){readerState.normalize=this.checked}),(_Qb=$("reader-play"))==null||_Qb.addEventListener("click",()=>{readerState.playing?readerPause():readerPlay()}),(_Rb=$("reader-stop"))==null||_Rb.addEventListener("click",()=>{readerStopPlayback(),readerState.idx=0,readerSaveResume(),readerUpdateProgress()}),(_Sb=$("reader-prev"))==null||_Sb.addEventListener("click",()=>readerJumpTo(readerState.idx-1)),(_Tb=$("reader-next"))==null||_Tb.addEventListener("click",()=>readerJumpTo(readerState.idx+1)),(_Ub=$("reader-speed"))==null||_Ub.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)}),(_Vb=$("reader-zoom-fitw"))==null||_Vb.addEventListener("click",()=>readerApplyZoom("fit-width")),(_Wb=$("reader-zoom-fith"))==null||_Wb.addEventListener("click",()=>readerApplyZoom("fit-height")),(_Xb=$("reader-zoom-two"))==null||_Xb.addEventListener("click",()=>readerApplyZoom("two")),(_Yb=$("reader-zoom-in"))==null||_Yb.addEventListener("click",()=>{readerState.scale=Math.min(4,readerState.scale*1.2),readerApplyZoom("custom")}),(_Zb=$("reader-zoom-out"))==null||_Zb.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))}(__b=$("reader-search"))==null||__b.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)}),(_ac=$("reader-search-prev"))==null||_ac.addEventListener("click",()=>readerSearchStep(-1)),(_bc=$("reader-search-next"))==null||_bc.addEventListener("click",()=>readerSearchStep(1)),(_cc=$("reader-search-clear"))==null||_cc.addEventListener("click",()=>readerClearSearch({clearInput:!0})),(_dc=$("reader-save-lib"))==null||_dc.addEventListener("click",readerSaveLibrary),(_ec=$("reader-export-btn"))==null||_ec.addEventListener("click",()=>{var _a2;return readerExport(((_a2=$("reader-export-mode"))==null?void 0:_a2.value)||"page")}),(_fc=$("reader-synth-all"))==null||_fc.addEventListener("click",readerSynthAll),(_gc=$("reader-synth-cancel"))==null||_gc.addEventListener("click",()=>{readerState.synthCancel=!0}),(_hc=$("reader-select-toggle"))==null||_hc.addEventListener("click",()=>{readerState.selecting?readerSetSelecting(!1):(readerClearSelection(),readerSetSelecting(!0))}),(_ic=$("reader-sel-clear"))==null||_ic.addEventListener("click",readerClearSelection),document.addEventListener("keydown",e=>{e.key==="Escape"&&readerState.selecting&&readerSetSelecting(!1)}),(_jc=$("reader-page-from"))==null||_jc.addEventListener("change",readerUpdateScopeLabel),(_kc=$("reader-page-to"))==null||_kc.addEventListener("change",readerUpdateScopeLabel),(_lc=$("reader-doc"))==null||_lc.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)}),(_mc=$("reader-doc"))==null||_mc.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(` + `,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)}))}(_Kb=$("reader-file-input"))==null||_Kb.addEventListener("change",function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];this.value="",readerImportFile(f)}),(_Lb=$("reader-paste-load"))==null||_Lb.addEventListener("click",readerImportPastedText),(_Mb=$("reader-paste-text"))==null||_Mb.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&e.key==="Enter"&&(e.preventDefault(),readerImportPastedText())}),(_Nb=$("reader-fetch-voices-btn"))==null||_Nb.addEventListener("click",readerFetchVoices),(_Ob=$("reader-backend-select"))==null||_Ob.addEventListener("change",()=>{const sel=$("reader-voice-select");sel&&(sel.innerHTML=''),window.VoicePicker&&(VoicePicker.upgrade("reader-voice-select"),VoicePicker.populate("reader-voice-select",[])),readerUpdateBackendHint()}),(_Pb=$("reader-chunk-mode"))==null||_Pb.addEventListener("change",function(){readerRechunk(this.value)}),(_Qb=$("reader-normalize"))==null||_Qb.addEventListener("change",function(){readerState.normalize=this.checked}),(_Rb=$("reader-play"))==null||_Rb.addEventListener("click",()=>{readerState.playing?readerPause():readerPlay()}),(_Sb=$("reader-stop"))==null||_Sb.addEventListener("click",()=>{readerStopPlayback(),readerState.idx=0,readerSaveResume(),readerUpdateProgress()}),(_Tb=$("reader-prev"))==null||_Tb.addEventListener("click",()=>readerJumpTo(readerState.idx-1)),(_Ub=$("reader-next"))==null||_Ub.addEventListener("click",()=>readerJumpTo(readerState.idx+1)),(_Vb=$("reader-speed"))==null||_Vb.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)}),(_Wb=$("reader-zoom-fitw"))==null||_Wb.addEventListener("click",()=>readerApplyZoom("fit-width")),(_Xb=$("reader-zoom-fith"))==null||_Xb.addEventListener("click",()=>readerApplyZoom("fit-height")),(_Yb=$("reader-zoom-two"))==null||_Yb.addEventListener("click",()=>readerApplyZoom("two")),(_Zb=$("reader-zoom-in"))==null||_Zb.addEventListener("click",()=>{readerState.scale=Math.min(4,readerState.scale*1.2),readerApplyZoom("custom")}),(__b=$("reader-zoom-out"))==null||__b.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))}(_$b=$("reader-search"))==null||_$b.addEventListener("keydown",e=>{e.key==="Enter"&&(e.preventDefault(),readerSearchStep(e.shiftKey?-1:1)),e.key==="Escape"&&readerClearSearch({clearInput:!0})}),(_ac=$("reader-search"))==null||_ac.addEventListener("input",e=>{clearTimeout(_readerSearchTimer);const term=e.target.value.trim();if(!term){readerClearSearch();return}_readerSearchTimer=setTimeout(()=>readerSearchApply(term,{jump:!0}),160)}),(_bc=$("reader-search-prev"))==null||_bc.addEventListener("click",()=>readerSearchStep(-1)),(_cc=$("reader-search-next"))==null||_cc.addEventListener("click",()=>readerSearchStep(1)),(_dc=$("reader-search-clear"))==null||_dc.addEventListener("click",()=>readerClearSearch({clearInput:!0})),(_ec=$("reader-save-lib"))==null||_ec.addEventListener("click",readerSaveLibrary),(_fc=$("reader-export-btn"))==null||_fc.addEventListener("click",()=>{var _a2;return readerExport(((_a2=$("reader-export-mode"))==null?void 0:_a2.value)||"page")}),(_gc=$("reader-synth-all"))==null||_gc.addEventListener("click",readerSynthAll),(_hc=$("reader-synth-cancel"))==null||_hc.addEventListener("click",()=>{readerState.synthCancel=!0}),(_ic=$("reader-select-toggle"))==null||_ic.addEventListener("click",()=>{readerState.selecting?readerSetSelecting(!1):(readerClearSelection(),readerSetSelecting(!0))}),(_jc=$("reader-sel-clear"))==null||_jc.addEventListener("click",readerClearSelection),document.addEventListener("keydown",e=>{e.key==="Escape"&&readerState.selecting&&readerSetSelecting(!1)}),(_kc=$("reader-page-from"))==null||_kc.addEventListener("change",readerUpdateScopeLabel),(_lc=$("reader-page-to"))==null||_lc.addEventListener("change",readerUpdateScopeLabel),(_mc=$("reader-doc"))==null||_mc.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)}),(_nc=$("reader-doc"))==null||_nc.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(` @@ -1445,17 +1460,17 @@ ABSOLUTE REGELN: - 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(` +${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},{icon:"mdi-repeat",label:"Run until < N unknown\u2026",title:"Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls",onClick:()=>{const input=window.prompt("Stop once fewer than this many Unknown speakers remain:","10");if(input==null)return;const threshold=parseInt(input,10);if(!Number.isFinite(threshold)||threshold<0){toast("Enter a whole number of 0 or more","error");return}audiobookRecastUntilThreshold(threshold)}}])),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}try{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)}catch(e){console.error("[audiobookRecastUnknown] post-processing failed",e),view.done({stopped:!0,message:"Finished checking speakers, but saving/cleanup failed: "+e.message+" \u2014 your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted."}),toast("Casting finished but cleanup failed: "+e.message,"error")}}async function audiobookRecastUntilThreshold(threshold=10,maxPasses=8){if(_audiobook.running){toast("Casting is already running","error");return}const segs=_audiobook.segments;if(!segs||!segs.length){toast("No cast to check","error");return}const countUnknown=()=>(segs||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length;let prev=countUnknown();if(prev<=threshold){toast(`Already at ${prev} unknown speakers (target: <${threshold})`,"success");return}toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})\u2026`,"info");for(let pass=1;pass<=maxPasses;pass++){if(await audiobookRecastUnknown(null,null,{includeNarrator:!0}),_audiobook.cancel){toast("Stopped \u2014 cancelled mid-pass.","info");return}const now=countUnknown();if(now<=threshold){toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass!==1?"es":""}).`,"success");return}if(now>=prev){toast(`Stopped after ${pass} pass${pass!==1?"es":""}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`,"error");return}prev=now}toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`,"error")}window.audiobookRecastUntilThreshold=audiobookRecastUntilThreshold;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)}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+=` +`+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}try{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,c.voice):""}}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,_ttsBackendForVoice(voice,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=`
+`}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();if(!t||t.length>60)return!1;const NUM=/^(?:[ivxlcdm]+(?=[.\s]|$)|\d{1,3})\.?\s*/i,rest=t.replace(NUM,""),KEYWORD=/^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i;if(!KEYWORD.test(rest))return!1;const afterKeyword=rest.replace(KEYWORD,"").trim();return afterKeyword===""||/^[ivxlcdm\d]{1,6}\.?$/i.test(afterKeyword)?!0:/^[ivxlcdm\d]{0,6}\.?\s*[-:–—]\s*.{1,40}$/i.test(afterKeyword)}function _abPauseSettings(){const num=(key,def)=>{let v;try{v=parseFloat(localStorage.getItem(key))}catch{v=NaN}return Number.isFinite(v)&&v>=0?v:def};return{paragraphMs:num("ttsvc_ab_pause_paragraph_s",2)*1e3,chapterMs:num("ttsvc_ab_pause_chapter_s",4)*1e3}}function _abSetPauseSetting(key,seconds){try{localStorage.setItem(key,String(seconds))}catch{}}function _abChapterSfxDataUrl(){try{return localStorage.getItem("ttsvc_ab_chapter_sfx")||null}catch{return null}}function _abSetChapterSfx(dataUrl){try{dataUrl?localStorage.setItem("ttsvc_ab_chapter_sfx",dataUrl):localStorage.removeItem("ttsvc_ab_chapter_sfx")}catch{toast("Could not save chapter sound \u2014 file may be too large","error")}}async function _abChapterSfxWavBlob(fmt){const dataUrl=_abChapterSfxDataUrl();if(!dataUrl)return null;try{const arrayBuf=await(await fetch(dataUrl)).arrayBuffer(),AC=window.AudioContext||window.webkitAudioContext,OAC=window.OfflineAudioContext||window.webkitOfflineAudioContext,tmpCtx=new AC,decoded=await tmpCtx.decodeAudioData(arrayBuf.slice(0));typeof tmpCtx.close=="function"&&tmpCtx.close();const offline=new OAC(fmt.channels,Math.max(1,Math.ceil(decoded.duration*fmt.sampleRate)),fmt.sampleRate),src=offline.createBufferSource();src.buffer=decoded,src.connect(offline.destination),src.start();const rendered=await offline.startRendering(),frames=rendered.length,pcm=new Int16Array(frames*fmt.channels);for(let ch=0;ch_abSetPauseSetting("ttsvc_ab_pause_paragraph_s",Math.max(0,parseFloat(pEl.value)||0))),cEl.addEventListener("change",()=>_abSetPauseSetting("ttsvc_ab_pause_chapter_s",Math.max(0,parseFloat(cEl.value)||0))),sfxClear==null||sfxClear.addEventListener("click",()=>{_abSetChapterSfx(null),sfxFile&&(sfxFile.value=""),sfxCurrent&&(sfxCurrent.textContent="No custom sound")}),sfxFile==null||sfxFile.addEventListener("change",()=>{const file=sfxFile.files&&sfxFile.files[0];if(!file)return;if(file.size>2*1024*1024){toast("Chapter sound is too large (max 2 MB) \u2014 trim it to a short chime/sting","error"),sfxFile.value="";return}const reader=new FileReader;reader.onload=()=>{_abSetChapterSfx(reader.result),sfxCurrent&&(sfxCurrent.textContent=file.name)},reader.onerror=()=>toast("Could not read that audio file","error"),reader.readAsDataURL(file)}))}window._abInitPauseUI=_abInitPauseUI;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,c.voice):""}}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;if(typeof _lineAudioCacheKey=="function"&&(book=_lineAudioBookName(),cacheKey=await _lineAudioCacheKey(text,voice,instruct),blob=await _lineAudioCacheGet(book,cacheKey)),!blob){let lastErr;for(let attempt=1;attempt<=3;attempt++)try{blob=await fetchTtsPreviewBlob(voice,text,"wav",instruct,_ttsBackendForVoice(voice,rehState.backend)),lastErr=null;break}catch(e){lastErr=e,attempt<3&&await new Promise(r=>setTimeout(r,3e3*attempt))}if(lastErr)throw lastErr;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 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"),pauses=_abPauseSettings();for(let c=0;cwavClips.get(i)).filter(Boolean);if(!blobs.length)continue;encMsg&&(encMsg.textContent=`Merging chapter ${c+1} / ${buckets.length}\u2026`);let mergedWav=await mergeWavBlobs(blobs,pauses.paragraphMs);if(c>0&&buckets[c].title&&(pauses.chapterMs>0||_abChapterSfxDataUrl())){const ref=_parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt,lead=[],sfx=await _abChapterSfxWavBlob(ref);sfx&&lead.push(sfx),pauses.chapterMs>0&&lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs,ref),ref)),lead.length&&(mergedWav=await mergeWavBlobs([...lead,mergedWav]))}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.

@@ -1467,7 +1482,7 @@ ${preview}`,{title:"Clean up character library?",okLabel:`Remove ${toDelete.leng
`).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")}}(_nc=$("reh-tb-browse-exports"))==null||_nc.addEventListener("click",audiobookBrowseExports),(_oc=$("reader-audiobook-btn"))==null||_oc.addEventListener("click",audiobookOpenCastView),(_pc=$("reh-tb-audiobook"))==null||_pc.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. + `,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")}}(_oc=$("reh-tb-browse-exports"))==null||_oc.addEventListener("click",audiobookBrowseExports),(_pc=$("reader-audiobook-btn"))==null||_pc.addEventListener("click",audiobookOpenCastView),(_qc=$("reh-tb-audiobook"))==null||_qc.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. @@ -1610,7 +1625,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"),`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=`
+
`}function csBuildImagePrompt(s,bookProfile){var _a2;if(_csStr(s.image_prompt).trim())return _csStr(s.image_prompt).trim();const parts=[];s.race_species&&parts.push(s.race_species),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;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");const bp=bookProfile||{},settingBits=[bp.genre,bp.setting,bp.era].filter(Boolean),settingClause=settingBits.length?`This character belongs to the following book/world: ${settingBits.join(", ")}. Every visual choice \u2014 architecture, clothing materials, weaponry, ethnicity mix, technology level \u2014 must fit THAT world, not a generic modern or real-world default. `:"";return`Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(", ")}. `+settingClause+`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)} @@ -1745,7 +1760,7 @@ 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),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,(_qc=$("reader-charsheets-btn"))==null||_qc.addEventListener("click",csForReader),(_rc=$("reh-charsheets-btn"))==null||_rc.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=`
+
`,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,(_rc=$("reader-charsheets-btn"))==null||_rc.addEventListener("click",csForReader),(_sc=$("reh-charsheets-btn"))==null||_sc.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.

@@ -1781,4 +1796,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(){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)}function _charLineCount(c){if(window.rehState&&rehState.lines&&rehState.lines.length){const key=String(c.name||"").toUpperCase().trim(),live=rehState.lines.filter(function(l){return l.type==="dialog"&&String(l.speaker||"").toUpperCase().trim()===key}).length;if(live)return live}return Number(c.sheet&&c.sheet.line_count)||0}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){return _charLineCount(b)-_charLineCount(a)}).map(function(c){const h=_charHue(c.name),count=_charLineCount(c);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&&typeof _voiceRoundtripCheck=="function")try{const rt=await _voiceRoundtripCheck(tryId,sampleText,"voice_design");rt.score<.5&&(bad=!0,console.warn("[voice design] STT roundtrip mismatch (score "+rt.score.toFixed(2)+'): said "'+rt.transcript+'"'))}catch(e){console.warn("[voice design roundtrip]",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),voice_design_prompt:instruct}).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))}(_sc=document.getElementById("stu-mode-audiobook"))==null||_sc.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))})}),(_tc=document.getElementById("stu-phase-prev"))==null||_tc.addEventListener("click",function(){_stuActive>1&&showStudioPhase(_stuActive-1)}),(_uc=document.getElementById("stu-phase-next"))==null||_uc.addEventListener("click",function(){_stuActive<4&&showStudioPhase(_stuActive+1)});function studioOnShow(){_stuInstallNavGuardOnce(),_stuIsActive=!0,showStudioPhase(_stuActive)}window.studioOnShow=studioOnShow; +`),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)}function _charLineCount(c){if(window.rehState&&rehState.lines&&rehState.lines.length){const key=String(c.name||"").toUpperCase().trim(),live=rehState.lines.filter(function(l){return l.type==="dialog"&&String(l.speaker||"").toUpperCase().trim()===key}).length;if(live)return live}return Number(c.sheet&&c.sheet.line_count)||0}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){return _charLineCount(b)-_charLineCount(a)}).map(function(c){const h=_charHue(c.name),count=_charLineCount(c);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,bookProfile=typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},prompt=_libStr(sh.image_prompt).trim()||(typeof csBuildImagePrompt=="function"?csBuildImagePrompt(sh,bookProfile):"");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,_getBookProfileSync(rec.book)):""),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}function _getBookProfileSync(book){return _bookProfileCache.get(String(book||"").trim())||{}}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.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&&typeof _voiceRoundtripCheck=="function")try{const rt=await _voiceRoundtripCheck(tryId,sampleText,"voice_design");rt.score<.5&&(bad=!0,console.warn("[voice design] STT roundtrip mismatch (score "+rt.score.toFixed(2)+'): said "'+rt.transcript+'"'))}catch(e){console.warn("[voice design roundtrip]",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),voice_design_prompt:instruct}).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 bookProfile=typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},prompt=hasExplicitPrompt?_libStr(sh.image_prompt).trim():typeof csBuildImagePrompt=="function"?csBuildImagePrompt(sh,bookProfile):"";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"),_stuBorrow("reh-tone-warn","stu-tone-warn-slot"),typeof _abInitPauseUI=="function"&&_abInitPauseUI(),_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))}(_tc=document.getElementById("stu-mode-audiobook"))==null||_tc.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),audiobookMode&&!rehState.narratorVoice&&typeof toast=="function"&&toast("No narrator voice assigned yet \u2014 assign one in the Voices phase so narration actually plays","error")}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))})}),(_uc=document.getElementById("stu-phase-prev"))==null||_uc.addEventListener("click",function(){_stuActive>1&&showStudioPhase(_stuActive-1)}),(_vc=document.getElementById("stu-phase-next"))==null||_vc.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 57f2105..095b9c0 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 2d18798..917d3dd 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -4112,6 +4112,13 @@ ABSOLUTE REGELN: { 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 }, + { icon: 'mdi-repeat', label: 'Run until < N unknown…', title: 'Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls', onClick: () => { + const input = window.prompt('Stop once fewer than this many Unknown speakers remain:', '10'); + if (input == null) return; + const threshold = parseInt(input, 10); + if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; } + audiobookRecastUntilThreshold(threshold); + } }, ])); 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 }, @@ -4465,48 +4472,89 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) _audiobook.running = false; _audiobook.abort = null; } - if (pendingReplacements.size) { - [...pendingReplacements.entries()] - .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.`); - } + // Everything below only ever ran when nothing above threw — but nothing + // here was itself guarded, so any failure in this post-processing (dedup, + // rollback check, saving the draft) left the UI permanently stuck showing + // "Stop Casting" with no error and no way out, even though the LLM work + // itself had already genuinely finished (GPU load back to idle). Wrapping + // it means a failure here still reaches a terminal view.done()/view.note() + // call instead of silently hanging forever. + try { + if (pendingReplacements.size) { + [...pendingReplacements.entries()] + .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) { - 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'); - } - - // Recast-unknown only fixes speakers on already-cast segments — it never adds - // new passages, so the original cast's real done/total must carry through - // unchanged rather than being overwritten with a fake "100% done" value. - const _rcDone = _audiobook.completedChunks || segs.length; - const _rcTotal = _audiobook.completedTotal || _rcDone; + const afterUnknownCount = countUnknownDialogue(segs); + if (!_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'); + } + + // Recast-unknown only fixes speakers on already-cast segments — it never adds + // new passages, so the original cast's real done/total must carry through + // unchanged rather than being overwritten with a fake "100% done" value. + const _rcDone = _audiobook.completedChunks || segs.length; + const _rcTotal = _audiobook.completedTotal || _rcDone; + + if (_audiobook.cancel) { + if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal); + view.done({ stopped: true, message: 'Character definition stopped. Existing cast preserved.' }); + toast('Character definition stopped. Existing cast preserved.', 'info'); + return; + } - if (_audiobook.cancel) { if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal); - view.done({ stopped: true, message: 'Character definition stopped. Existing cast preserved.' }); - toast('Character definition stopped. Existing cast preserved.', 'info'); - return; + const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); + const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`; + view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); + } catch (e) { + console.error('[audiobookRecastUnknown] post-processing failed', e); + view.done({ stopped: true, message: 'Finished checking speakers, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' }); + toast('Casting finished but cleanup failed: ' + e.message, 'error'); } - - if (_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)); - const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`; - view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); } +// Repeatedly runs the recast-unknown + narrator-verify pass (the exact same +// { includeNarrator: true } combination used manually — via console — for a +// full autonomous end-to-end run this app was already driven through once) +// until the number of Unknown-speaker dialogue lines drops below `threshold` +// or two consecutive passes make no further progress (LLM/GPU contention or a +// genuinely irreducible residue of ambiguous lines — either way, more passes +// won't help). User-requested follow-up: this used to only be doable by +// calling audiobookRecastUnknown() directly from the browser console. +async function audiobookRecastUntilThreshold(threshold = 10, maxPasses = 8) { + if (_audiobook.running) { toast('Casting is already running', 'error'); return; } + const segs = _audiobook.segments; + if (!segs || !segs.length) { toast('No cast to check', 'error'); return; } + const countUnknown = () => (segs || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length; + let prev = countUnknown(); + if (prev <= threshold) { toast(`Already at ${prev} unknown speakers (target: <${threshold})`, 'success'); return; } + toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})…`, 'info'); + for (let pass = 1; pass <= maxPasses; pass++) { + await audiobookRecastUnknown(null, null, { includeNarrator: true }); + if (_audiobook.cancel) { toast('Stopped — cancelled mid-pass.', 'info'); return; } + const now = countUnknown(); + if (now <= threshold) { toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass !== 1 ? 'es' : ''}).`, 'success'); return; } + if (now >= prev) { toast(`Stopped after ${pass} pass${pass !== 1 ? 'es' : ''}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`, 'error'); return; } + prev = now; + } + toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`, 'error'); +} +window.audiobookRecastUntilThreshold = audiobookRecastUntilThreshold; + async function audiobookOpenCastView() { if (_audiobook.running) return; const text = audiobookScopeText(); @@ -4942,32 +4990,45 @@ 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; - _audiobook.narratedPassages = narrationOnly; - _audiobook.degraded = degraded; - _audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length; - _audiobook.completedTotal = chunks.length; - _abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length); - audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB + // Everything below only ever ran when nothing above threw, but wasn't + // itself guarded — a failure here (e.g. in the quote-fix/merge passes or + // saving the draft) used to leave the panel stuck showing "Stop Casting" + // forever, with no error and the LLM work already genuinely finished + // (confirmed live: GPU load back to idle, button never resets). Wrapping + // it means a failure here still reaches a terminal view.done() instead of + // silently hanging. + try { + // 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; + _audiobook.narratedPassages = narrationOnly; + _audiobook.degraded = degraded; + _audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length; + _audiobook.completedTotal = chunks.length; + _abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length); + audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB - if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); + if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); - // Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off) - const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); - const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length; - const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`; - view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); + // Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off) + const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); + const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length; + const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`; + view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); + } catch (e) { + console.error('[audiobookCast] post-processing failed', e); + view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' }); + toast('Casting finished but cleanup failed: ' + e.message, 'error'); + } } // ── Editable attribution preview ───────────────────────────────────────────── @@ -5141,9 +5202,154 @@ function audiobookIsChapter(line) { if (line.type === 'act' || line.type === 'scene') return true; const t = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim(); if (!t || t.length > 60) return false; - return /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t); + // OCR'd headings (chapter titles baked into the PDF as images, recovered via + // Tesseract) commonly lead with the chapter number rather than the keyword — + // "1.Kapitel", "I. Kapitel", "Kapitel 1" — so the keyword can't be required + // at position 0. Allow an optional leading number/roman numeral (with an + // optional trailing period, and no requirement for a space before the + // keyword, since OCR frequently drops the space in "1.Kapitel"). + // A lone leading letter from the roman-numeral set is also just the first + // letter of an ordinary word — "C" (100) is real Roman notation, but it's + // also literally how "Chapter" starts. Confirmed live: without the + // lookahead requiring whitespace/period/end right after the numeral run, + // this silently ate the "C" off "Chapter 1: The Beginning", leaving + // "hapter 1..." — which then obviously failed the keyword match entirely. + const NUM = /^(?:[ivxlcdm]+(?=[.\s]|$)|\d{1,3})\.?\s*/i; + const rest = t.replace(NUM, ''); + const KEYWORD = /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i; + if (!KEYWORD.test(rest)) return false; + // "part"/"book"/"teil" ("teil" = German "part") are common ordinary words, + // not just chapter markers — "Teil des Grundes war unklar." ("Part of the + // reason was unclear.") is a perfectly normal, short narration sentence + // that starts with the same word. Requiring the keyword at position 0 was + // never enough on its own; this used to only affect export file-splitting + // (harmless — one fewer bucket boundary), but wiring the same check into + // the Stage view's rendering made it consequential: an ordinary sentence + // getting misdetected now silently replaces real paragraph text with a + // thin chapter-marker line (confirmed live: "lots of empty pages" in the + // A4 pagination view, from real narration vanishing this way). Whatever + // follows the keyword must therefore look like part of a heading — empty, + // a bare trailing number/numeral, or a colon/dash-separated subtitle — + // never a normal grammatical continuation. + const afterKeyword = rest.replace(KEYWORD, '').trim(); + if (afterKeyword === '') return true; + if (/^[ivxlcdm\d]{1,6}\.?$/i.test(afterKeyword)) return true; + return /^[ivxlcdm\d]{0,6}\.?\s*[-:–—]\s*.{1,40}$/i.test(afterKeyword); } +// ── Pause settings (paragraph/chapter gaps + optional chapter sound) ──────── +// Persisted per-browser (not per-book) via localStorage — these are pacing +// preferences, not book content, so they should carry over between exports. + +function _abPauseSettings() { + const num = (key, def) => { + let v; + try { v = parseFloat(localStorage.getItem(key)); } catch (_) { v = NaN; } + return Number.isFinite(v) && v >= 0 ? v : def; + }; + return { + paragraphMs: num('ttsvc_ab_pause_paragraph_s', 2) * 1000, + chapterMs: num('ttsvc_ab_pause_chapter_s', 4) * 1000, + }; +} + +function _abSetPauseSetting(key, seconds) { + try { localStorage.setItem(key, String(seconds)); } catch (_) {} +} + +function _abChapterSfxDataUrl() { + try { return localStorage.getItem('ttsvc_ab_chapter_sfx') || null; } catch (_) { return null; } +} + +function _abSetChapterSfx(dataUrl) { + try { + if (dataUrl) localStorage.setItem('ttsvc_ab_chapter_sfx', dataUrl); + else localStorage.removeItem('ttsvc_ab_chapter_sfx'); + } catch (e) { toast('Could not save chapter sound — file may be too large', 'error'); } +} + +// Decodes the user's custom chapter-transition sound (any format the browser +// can decode) and re-renders it at the EXACT sample rate/channels the +// narration clips use — mergeWavBlobs concatenates raw PCM bytes assuming a +// single shared format, so a mismatched sample rate here would play back +// pitched/sped-up or corrupt the merge, not just sound wrong. +async function _abChapterSfxWavBlob(fmt) { + const dataUrl = _abChapterSfxDataUrl(); + if (!dataUrl) return null; + try { + const resp = await fetch(dataUrl); + const arrayBuf = await resp.arrayBuffer(); + const AC = window.AudioContext || window.webkitAudioContext; + const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext; + const tmpCtx = new AC(); + const decoded = await tmpCtx.decodeAudioData(arrayBuf.slice(0)); + if (typeof tmpCtx.close === 'function') tmpCtx.close(); + const offline = new OAC(fmt.channels, Math.max(1, Math.ceil(decoded.duration * fmt.sampleRate)), fmt.sampleRate); + const src = offline.createBufferSource(); + src.buffer = decoded; + src.connect(offline.destination); + src.start(); + const rendered = await offline.startRendering(); + const frames = rendered.length; + const pcm = new Int16Array(frames * fmt.channels); + for (let ch = 0; ch < fmt.channels; ch++) { + const data = rendered.getChannelData(Math.min(ch, rendered.numberOfChannels - 1)); + for (let i = 0; i < frames; i++) { + const s = Math.max(-1, Math.min(1, data[i])); + pcm[i * fmt.channels + ch] = s < 0 ? s * 0x8000 : s * 0x7fff; + } + } + return _buildWavBlob(new Uint8Array(pcm.buffer), fmt); + } catch (e) { + console.error('[audiobook export] chapter sound decode failed', e); + toast('Could not use the custom chapter sound — check the audio file', 'error'); + return null; + } +} + +// Wires the Perform & Export pause-settings fields to localStorage. Called +// every time Studio's phase 4 is shown (studio.js) — guarded so listeners +// are only attached once, since the fields themselves are never reparented +// away (unlike the borrowed Stage/tone-warning elements). +let _abPauseUIInited = false; +function _abInitPauseUI() { + const pEl = $('stu-pause-paragraph'), cEl = $('stu-pause-chapter'); + const sfxFile = $('stu-chapter-sfx-file'), sfxClear = $('stu-chapter-sfx-clear'), sfxCurrent = $('stu-chapter-sfx-current'); + if (!pEl || !cEl) return; + const s = _abPauseSettings(); + pEl.value = s.paragraphMs / 1000; + cEl.value = s.chapterMs / 1000; + if (sfxCurrent) sfxCurrent.textContent = _abChapterSfxDataUrl() ? 'Custom sound set' : 'No custom sound'; + if (_abPauseUIInited) return; + _abPauseUIInited = true; + pEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_paragraph_s', Math.max(0, parseFloat(pEl.value) || 0))); + cEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_chapter_s', Math.max(0, parseFloat(cEl.value) || 0))); + sfxClear?.addEventListener('click', () => { + _abSetChapterSfx(null); + if (sfxFile) sfxFile.value = ''; + if (sfxCurrent) sfxCurrent.textContent = 'No custom sound'; + }); + sfxFile?.addEventListener('change', () => { + const file = sfxFile.files && sfxFile.files[0]; + if (!file) return; + // Capped well under localStorage's ~5-10MB origin quota (base64 inflates + // size by ~33%) — a chapter chime doesn't need to be a whole song. + if (file.size > 2 * 1024 * 1024) { + toast('Chapter sound is too large (max 2 MB) — trim it to a short chime/sting', 'error'); + sfxFile.value = ''; + return; + } + const reader = new FileReader(); + reader.onload = () => { + _abSetChapterSfx(reader.result); + if (sfxCurrent) sfxCurrent.textContent = file.name; + }; + reader.onerror = () => toast('Could not read that audio file', 'error'); + reader.readAsDataURL(file); + }); +} +window._abInitPauseUI = _abInitPauseUI; + function audiobookLineVoice(l) { if (l.type === 'dialog') { const c = rehState.cast[l.speaker] || {}; @@ -5220,7 +5426,29 @@ async function audiobookExport() { blob = await _lineAudioCacheGet(book, cacheKey); } if (!blob) { - blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend)); + // fetchTtsPreviewBlob's own retry (_ttsPreviewFetchWithRetry) only + // covers a connection-level failure — fetch() doesn't throw on a + // non-2xx response, so a backend hiccup (confirmed live: transient + // 500s during the first ~50 lines, most likely GPU/engine warm-up + // contention from the two parallel workers both starting cold) + // throws straight out of fetchTtsPreviewBlob itself, past that + // retry layer entirely. A single blip like that used to doom the + // ENTIRE multi-hour export — confirmed live, different ordinary, + // unremarkable lines failed on repeated attempts, so this isn't + // about any one line's content. Give a transient failure a few + // seconds to pass before giving up on this line for real. + let lastErr; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend)); + lastErr = null; + break; + } catch (e) { + lastErr = e; + if (attempt < 3) await new Promise(r => setTimeout(r, 3000 * attempt)); + } + } + if (lastErr) throw lastErr; if (cacheKey) _lineAudioCachePut(book, cacheKey, blob); } wavClips.set(i, blob); @@ -5230,7 +5458,17 @@ async function audiobookExport() { prog.update(++done, `Synthesising line ${done} / ${allIdx.length}…`); } }; - try { await Promise.all(Array.from({ length: Math.min(2, allIdx.length) }, worker)); } + // Used to run 2 workers concurrently to speed up bulk export. Every TTS + // backend this app talks to (voice_clone, voice_design, fishspeech, the + // other local engines) is a single self-hosted GPU model instance, not a + // horizontally-scaled service — confirmed live, twice, with two different + // backends: 2 concurrent requests reliably push at least one past the + // reverse proxy's 60s timeout under real load, producing exactly the + // "some lines just fail, no clear reason" pattern that made a multi-hour + // book export fail outright. Serial is slower per line but doesn't waste + // time on doomed, retried, ultimately-failing requests — net faster and + // actually finishes. + try { await worker(); } finally { prog.done(); _audiobook.running = false; } if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; } @@ -5249,6 +5487,7 @@ async function audiobookExport() { const savedFiles = []; // {name, url} — for the results panel below const bookForExport = _lineAudioBookName(); const encMsg = $('audiobook-msg'); + const pauses = _abPauseSettings(); for (let c = 0; c < buckets.length; c++) { const blobs = buckets[c].idx.map(i => wavClips.get(i)).filter(Boolean); if (!blobs.length) continue; @@ -5256,8 +5495,25 @@ async function audiobookExport() { // 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. + // A silent gap is inserted between every pair of lines (`pauses.paragraphMs`) + // — mergeWavBlobs used to butt clips together with literally zero space, + // which read as characters teleporting mid-scene with no beat between them. if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${buckets.length}…`; - const mergedWav = await mergeWavBlobs(blobs); + let mergedWav = await mergeWavBlobs(blobs, pauses.paragraphMs); + // Every chapter after the first gets a longer lead-in pause (and, if the + // user configured one, a custom transition sound) prepended — the first + // chapter needs no lead-in since there's nothing before it to separate + // from. This survives even when chapters export as separate files (the + // normal case — see `realChapters` below): most audiobook players queue + // consecutive tracks back-to-back with no gap of their own. + if (c > 0 && buckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) { + const ref = _parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt; + const lead = []; + const sfx = await _abChapterSfxWavBlob(ref); + if (sfx) lead.push(sfx); + if (pauses.chapterMs > 0) lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs, ref), ref)); + if (lead.length) mergedWav = await mergeWavBlobs([...lead, mergedWav]); + } // 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, diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index b473683..c23cee1 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -1264,9 +1264,10 @@ function csAlignmentBar(score, arcDirection, arcNote) { // 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) { +function csBuildImagePrompt(s, bookProfile) { if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim(); const parts = []; + if (s.race_species) parts.push(s.race_species); if (s.archetype) parts.push(s.archetype); if (s.physical) parts.push(s.physical); if (s.clothing) parts.push(s.clothing); @@ -1275,7 +1276,20 @@ 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'); + // Without this, per-character prompts had no idea what kind of book they belonged + // to at all — confirmed live as "generals" rendered as 1920s-era generals, and + // East-Asian-styled portraits, in a Western medieval-fantasy book. A one-time book + // profile (genre/setting/era) grounds every character's prompt in the same world + // instead of each one guessing independently — see _getBookProfile. + const bp = bookProfile || {}; + const settingBits = [bp.genre, bp.setting, bp.era].filter(Boolean); + const settingClause = settingBits.length + ? `This character belongs to the following book/world: ${settingBits.join(', ')}. Every visual choice — architecture, ` + + `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or ` + + `real-world default. ` + : ''; return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. ` + + settingClause + `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 ` diff --git a/static/js/conversation.js b/static/js/conversation.js index 44508f7..8c7a497 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -143,10 +143,28 @@ $('s-import-voices-file')?.addEventListener('change', async function () { const ttsBkSel = $('conv-tts-backend-select'); const ttsFetchBtn = $('conv-tts-fetch-btn'); const ttsVoiceSel = $('conv-tts-voice-select'); + const ttsEmotionSel = $('conv-emotion-select'); const systemPrompt = $('conv-system-prompt'); const turnHistory = $('conv-turn-history'); if (!chatWindow || !micBtn) return; + // Conversation replies previously had no style/emotion control at all, + // regardless of backend. Same REH_EMOTIONS quick-pick used in Try a Voice / + // Read Aloud — sent to the server as a plain English phrase; the backend + // decides server-side (see _conv_tts_text_and_instruct in + // routes/conversation.py) whether it becomes an inline Fish [tag] or an + // instruct-field phrase for style-aware backends. + if (ttsEmotionSel && typeof REH_EMOTIONS !== 'undefined' && !ttsEmotionSel.dataset.populated) { + REH_EMOTIONS.forEach(e => { + if (!e.value) return; + const o = document.createElement('option'); + o.value = e.value; + o.textContent = `${e.emoji} ${e.label}`; + ttsEmotionSel.appendChild(o); + }); + ttsEmotionSel.dataset.populated = '1'; + } + // Warn if microphone API is unavailable (HTTP on non-localhost = insecure context) if (!navigator.mediaDevices?.getUserMedia) { if (micStatus) { @@ -963,6 +981,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () { form.append('llm_model', llmModelSel?.value || ''); form.append('tts_backend', ttsBkSel?.value || 'voice_clone'); form.append('tts_voice', ttsVoiceSel?.value || ''); + form.append('tts_emotion', ttsEmotionSel?.value || ''); form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.'); form.append('history', JSON.stringify(conversationHistory.slice(-20))); @@ -1087,6 +1106,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () { form.append('llm_model', llmModelSel?.value || ''); form.append('tts_backend', ttsBkSel?.value || 'voice_clone'); form.append('tts_voice', ttsVoiceSel?.value || ''); + form.append('tts_emotion', ttsEmotionSel?.value || ''); form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.'); form.append('history', JSON.stringify(conversationHistory.slice(-20))); diff --git a/static/js/generation.js b/static/js/generation.js index c673b5a..445fd70 100644 --- a/static/js/generation.js +++ b/static/js/generation.js @@ -1,55 +1,79 @@ // ── WAV merge utility (chunked TTS + playlist export) ────────────────────── -async function mergeWavBlobs(blobs) { - if (!blobs || blobs.length === 0) return null; - if (blobs.length === 1) return blobs[0]; - - function parseWav(bytes) { - const v = new DataView(bytes.buffer); - let off = 12, fmt = null, dataOff = 0, dataSize = 0; - while (off + 8 <= bytes.length) { - const id = v.getUint32(off, false); - const sz = v.getUint32(off + 4, true); - if (id === 0x666d7420) { - fmt = { channels: v.getUint16(off+10,true), sampleRate: v.getUint32(off+12,true), bitDepth: v.getUint16(off+22,true) }; - } else if (id === 0x64617461) { - dataOff = off + 8; dataSize = sz; - } - off += 8 + sz; +function _parseWavBytes(bytes) { + const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let off = 12, fmt = null, dataOff = 0, dataSize = 0; + while (off + 8 <= bytes.length) { + const id = v.getUint32(off, false); + const sz = v.getUint32(off + 4, true); + if (id === 0x666d7420) { + fmt = { channels: v.getUint16(off+10,true), sampleRate: v.getUint32(off+12,true), bitDepth: v.getUint16(off+22,true) }; + } else if (id === 0x64617461) { + dataOff = off + 8; dataSize = sz; } - return { fmt, dataOff, dataSize }; + off += 8 + sz; } + return { fmt, dataOff, dataSize }; +} - const parsed = []; - for (const b of blobs) { - const bytes = new Uint8Array(await b.arrayBuffer()); - const p = parseWav(bytes); - if (!p.fmt) throw new Error('Invalid WAV in chunk'); - parsed.push({ bytes, ...p }); - } - const ref = parsed[0].fmt; - const totalPcm = parsed.reduce((s, p) => s + p.dataSize, 0); - const out = new Uint8Array(44 + totalPcm); +// Wraps raw PCM bytes in a standalone WAV file — shared by the silence-gap +// helper below and by anything else that needs to hand mergeWavBlobs a +// ready-made clip (e.g. a resampled custom chapter-transition sound). +function _buildWavBlob(pcmBytes, fmt) { + const out = new Uint8Array(44 + pcmBytes.length); const dv = new DataView(out.buffer); dv.setUint32(0, 0x52494646, false); - dv.setUint32(4, 36 + totalPcm, true); + dv.setUint32(4, 36 + pcmBytes.length, true); dv.setUint32(8, 0x57415645, false); dv.setUint32(12, 0x666d7420, false); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); - dv.setUint16(22, ref.channels, true); - dv.setUint32(24, ref.sampleRate, true); - dv.setUint32(28, ref.sampleRate * ref.channels * (ref.bitDepth >> 3), true); - dv.setUint16(32, ref.channels * (ref.bitDepth >> 3), true); - dv.setUint16(34, ref.bitDepth, true); + dv.setUint16(22, fmt.channels, true); + dv.setUint32(24, fmt.sampleRate, true); + dv.setUint32(28, fmt.sampleRate * fmt.channels * (fmt.bitDepth >> 3), true); + dv.setUint16(32, fmt.channels * (fmt.bitDepth >> 3), true); + dv.setUint16(34, fmt.bitDepth, true); dv.setUint32(36, 0x64617461, false); - dv.setUint32(40, totalPcm, true); - let pos = 44; - for (const p of parsed) { + dv.setUint32(40, pcmBytes.length, true); + out.set(pcmBytes, 44); + return new Blob([out], { type: 'audio/wav' }); +} + +// A zero-filled (silent) raw-PCM byte run matching a reference WAV's format — +// used to insert an audible gap between clips that mergeWavBlobs otherwise +// concatenates with zero space between them (confirmed live: an audiobook +// exported with literally no pause between paragraphs or chapters reads as +// characters teleporting mid-scene). +function _silencePcmBytes(ms, fmt) { + const bytesPerSample = fmt.bitDepth >> 3; + const frames = Math.round((ms / 1000) * fmt.sampleRate); + return new Uint8Array(frames * fmt.channels * bytesPerSample); +} + +// `gapMs` inserts silence BETWEEN each pair of clips (not before the first or +// after the last) — the natural "beat" between two lines of narration. +async function mergeWavBlobs(blobs, gapMs = 0) { + if (!blobs || blobs.length === 0) return null; + if (blobs.length === 1 && !gapMs) return blobs[0]; + + const parsed = []; + for (const b of blobs) { + const bytes = new Uint8Array(await b.arrayBuffer()); + const p = _parseWavBytes(bytes); + if (!p.fmt) throw new Error('Invalid WAV in chunk'); + parsed.push({ bytes, ...p }); + } + const ref = parsed[0].fmt; + const silence = gapMs > 0 ? _silencePcmBytes(gapMs, ref) : null; + const totalPcm = parsed.reduce((s, p) => s + p.dataSize, 0) + (silence ? silence.length * (parsed.length - 1) : 0); + const out = new Uint8Array(totalPcm); + let pos = 0; + parsed.forEach((p, i) => { out.set(p.bytes.slice(p.dataOff, p.dataOff + p.dataSize), pos); pos += p.dataSize; - } - return new Blob([out], { type: 'audio/wav' }); + if (silence && i < parsed.length - 1) { out.set(silence, pos); pos += silence.length; } + }); + return _buildWavBlob(out, ref); } // ── Chunked TTS ──────────────────────────────────────────────────────────── diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 207dc75..d64649a 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -1294,7 +1294,8 @@ async function _charDetailPage(rec, allChars, opts) { pg.querySelector('.lcd-avatar-gen-btn')?.addEventListener('click', async function (e) { e.stopPropagation(); const btn = this; - const prompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh) : ''); + const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}; + const prompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : ''); if (!prompt) { toast('No image prompt to work from — generate the Character Image Prompt below first', 'error'); return; } const orig = btn.innerHTML; btn.disabled = true; @@ -1679,7 +1680,7 @@ window._charDetailModal = _charDetailModal; function _openAvatarLightbox(rec, onSaved) { document.getElementById('avatar-lightbox')?.remove(); const sh = rec.sheet || {}; - const currentPrompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh) : ''); + const currentPrompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, _getBookProfileSync(rec.book)) : ''); const ov = document.createElement('div'); ov.id = 'avatar-lightbox'; ov.className = 'audiobook-overlay'; @@ -2103,6 +2104,12 @@ async function _getBookProfile(book) { _bookProfileCache.set(key, profile); return profile; } +// Best-effort sync read for callers that build a display prompt outside an async +// handler (e.g. a lightbox opened from a plain click listener) — returns {} on a +// cache miss rather than blocking; the async path above is authoritative. +function _getBookProfileSync(book) { + return _bookProfileCache.get(String(book || '').trim()) || {}; +} async function _saveBookProfile(book, profile) { const key = String(book || '').trim(); const r = await fetch('/api/book-profile', { @@ -2302,10 +2309,12 @@ function _confirmVoiceReuse(rec, reuse) { try { const langHint = (typeof _resolveBookLang === 'function') ? await _resolveBookLang(rec).catch(function () { return ''; }) : ''; const text = (typeof _charSampleTextFor === 'function' && _charSampleTextFor(rec, langHint)) || ('Hallo, ich bin ' + rec.name + '.'); - // Same fix as _libPreviewCharVoice — a designed (no-reference-WAV) - // voice can't play through the voice_clone engine at all. + // A voice with no reference clip can't play through voice_clone at + // all; once it has one (even if it started life as a designed + // voice) it can and should, for the same reproducibility reasons as + // _ttsBackendForVoice. const rv = (window._voices || []).find(x => x.id === reuse.voiceId); - const rBackend = (rv && (rv.origin === 'designed' || !rv.has_ref)) ? 'voice_design' : 'voice_clone'; + const rBackend = (rv && !rv.has_ref) ? 'voice_design' : 'voice_clone'; const blob = await fetchTtsPreviewBlob(reuse.voiceId, text, 'wav', '', rBackend); if (!audioEl) { audioEl = new Audio(); audioEl.addEventListener('ended', function () { icon.className = 'mdi mdi-play'; }); } audioEl.src = URL.createObjectURL(blob); @@ -2749,7 +2758,8 @@ async function _charAutoGenerateImage(rec, provider) { throw new Error('Not enough character detail to generate a meaningful portrait — skipped instead of using a generic placeholder'); } } - const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh) : ''); + const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}; + const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : ''); if (!prompt) throw new Error('No image prompt to work from yet'); const body = { prompt: prompt }; if (provider) body.provider = provider; diff --git a/static/js/reader.js b/static/js/reader.js index dad1a63..69f9322 100644 --- a/static/js/reader.js +++ b/static/js/reader.js @@ -440,8 +440,30 @@ function _readerTimeout(promise, ms, label) { // Rasterize the top `gapPx` (scale-1 px) of `page` and OCR it, returning // synthetic word entries (same shape as real getTextContent words) spread // across the band so they slot into the normal sentence/highlight pipeline. +// Decorative chapter-heading images commonly combine a small icon/border +// graphic ABOVE or AROUND the actual number/title text (confirmed live: a +// real book's heading was an octagonal badge with a bold border and an icon +// sitting on top of the text) — Tesseract's default full-page layout +// analysis gets thrown off by the graphic, either finding no text at all +// (high confidence, empty result) or confidently misreading the border as a +// stray character. Tightening the crop to exclude the graphic and telling +// Tesseract to expect a single line of text (PSM 7) fixed this in testing: +// the same heading that returned "" or ">" under default settings read back +// as the correct "3.Kapitel" at 80% confidence once cropped to just the +// bottom ~55% of the gap (where centered chapter-title text typically sits, +// below any icon) with PSM 7. Tried first since it's the more surgical, +// less error-prone read; the untrimmed full-gap crop remains as a fallback +// for headings that are already plain text with no surrounding graphic. +async function _readerOcrAttempt(worker, canvas, psm) { + await worker.setParameters({ tessedit_pageseg_mode: psm }); + const { data } = await _readerTimeout(worker.recognize(canvas), 20000, 'Heading OCR'); + const text = (data.text || '').replace(/\s+/g, ' ').trim(); + if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return null; + return text; +} + async function readerOcrPageHeading(page, base, pageIdx, gapPx) { - let crop; + let crop, tightCrop; try { const worker = await readerGetOcrWorker(); const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE }); @@ -454,11 +476,27 @@ async function readerOcrPageHeading(page, base, pageIdx, gapPx) { crop.height = cropH; await _readerTimeout(page.render({ canvasContext: crop.getContext('2d'), viewport }).promise, 15000, 'Page render'); - const { data } = await _readerTimeout(worker.recognize(crop), 20000, 'Heading OCR'); - const text = (data.text || '').replace(/\s+/g, ' ').trim(); - if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return []; + let text = null; + if (cropH > 40) { + const y0 = Math.round(cropH * 0.45); + const inset = Math.round(crop.width * 0.03); + tightCrop = document.createElement('canvas'); + tightCrop.width = Math.max(crop.width - inset * 2, 1); + tightCrop.height = cropH - y0; + tightCrop.getContext('2d').putImageData(crop.getContext('2d').getImageData(inset, y0, tightCrop.width, tightCrop.height), 0, 0); + text = await _readerOcrAttempt(worker, tightCrop, '7'); + } + if (!text) text = await _readerOcrAttempt(worker, crop, '3'); + if (!text) return []; - const tokens = text.split(' ').filter(Boolean); + // Stray single-symbol tokens (a leftover border-line fragment misread as + // "|" or similar) don't affect confidence enough to fail the threshold, + // but do end up glued onto the real heading text — confirmed live: a + // clean "3.Kapitel" recognition also produced a trailing "|" token, + // which broke chapter-detection's stricter "nothing after the heading + // but a number/subtitle" check by leaving that stray symbol behind it. + const tokens = text.split(' ').filter(t => t && /[a-zäöüßàâçéèêëîïôûùüÿñæœ0-9]/i.test(t)); + if (!tokens.length) return []; const bandH = Math.max(gapPx - 4, 10); const avgCharW = Math.max((base.width - 16) / text.length, 4); const words = []; @@ -474,6 +512,7 @@ async function readerOcrPageHeading(page, base, pageIdx, gapPx) { return []; } finally { if (crop) { crop.width = 0; crop.height = 0; } + if (tightCrop) { tightCrop.width = 0; tightCrop.height = 0; } } } @@ -600,6 +639,24 @@ async function readerExtractPdfText(loaded) { // Build + append this page's sentences and units const pageBase = readerBuildSentences(pageWords); const pageUnits = readerGroupUnits(pageBase, readerState.chunkMode); + // A page with zero extracted text — no real text layer AND heading OCR + // either found nothing or failed confidence (common for a page that's + // ENTIRELY a decorative image, e.g. a chapter-divider graphic with no + // recognizable characters at all) — used to contribute nothing to + // readerState.sentences whatsoever. Since page numbers downstream + // (audiobookScopeText's pageMarks, used to reconstruct page breaks after + // LLM speaker-attribution) are derived entirely from real units' own + // .words[0].page, a page with no units is invisible to that system — + // not just missing its own content, but silently shifting every + // SUBSEQUENT page number in the rehearsal/export view out of sync with + // the actual PDF (confirmed live: reported page breaks drifting from + // the source PDF on a book with several such divider pages). A blank + // placeholder unit (empty text — never spoken, never shown as a line; + // audiobookScopeText already skips any unit with no text) keeps this + // page's mere existence visible to page tracking either way. + if (!pageUnits.length) { + pageUnits.push({ text: '', words: [{ page: pageIdx, x: 0, top: 0, w: 0, h: 0, text: '' }], status: 'pending', _stat: null, paraStart: false }); + } const startUnit = readerState.sentences.length; readerState.baseSentences.push(...pageBase); readerState.sentences.push(...pageUnits); @@ -1081,6 +1138,36 @@ function readerUpdateScopeLabel() { // merged/exported file with paragraphs missing and no indication anything // had gone wrong. Callers now get the failed-index list back and must // surface it instead of reporting a clean success. +// Same fix as Try a Voice / Studio: Fish-Speech ignores the free-text +// #reader-instruct field entirely (it only reads an inline [tag] in the text +// itself) — the emotion quick-pick applies as a tag on that backend instead. +function _readerTextForSynth(text) { + const backend = $('reader-backend-select')?.value || ''; + const emotion = $('reader-emotion-select')?.value || ''; + if (!/fish/i.test(backend) || !emotion) return text; + if (/^\s*\[/.test(text)) return text; + const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotion) : ''; + return tag ? `[${tag}] ${text}` : text; +} +(function initReaderEmotionPicker() { + const sel = $('reader-emotion-select'); + if (!sel || typeof REH_EMOTIONS === 'undefined') return; + REH_EMOTIONS.forEach(e => { + if (!e.value) return; + const o = document.createElement('option'); + o.value = e.value; + o.textContent = `${e.emoji} ${e.label}`; + sel.appendChild(o); + }); + sel.addEventListener('change', () => { + const backend = $('reader-backend-select')?.value || ''; + if (!/fish/i.test(backend)) { + const instructInput = $('reader-instruct'); + if (instructInput && sel.value) instructInput.value = sel.value; + } + }); +})(); + async function readerSynthIndices(targets) { targets = targets.filter(i => !readerState.blobCache.has(i)); if (!targets.length || readerState.synthRunning) return { done: 0, failed: [] }; @@ -1108,7 +1195,7 @@ async function readerSynthIndices(targets) { if (readerState.blobCache.has(i)) { done++; update(); continue; } if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth'); try { - const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams()); + const blob = await fetchTtsPreviewBlob(voice, _readerTextForSynth(readerState.sentences[i].text), READER_FMT, instruct, backend, false, readerGenParams()); readerState.blobCache.set(i, blob); if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready'); } catch (e) { @@ -1281,7 +1368,7 @@ async function readerGetBuffer(idx) { if (!backend) throw new Error('No TTS backend selected'); const instruct = $('reader-instruct')?.value.trim() || ''; if (readerState.sentences[idx].status !== 'reading') readerSetStatus(idx, 'synth'); - blob = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams()); + blob = await fetchTtsPreviewBlob(voice, _readerTextForSynth(readerState.sentences[idx].text), READER_FMT, instruct, backend, false, readerGenParams()); readerState.blobCache.set(idx, blob); if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready'); } @@ -1311,7 +1398,7 @@ async function readerPrefetch(idx) { const instruct = $('reader-instruct')?.value.trim() || ''; if (readerState.sentences[idx].status === 'pending') readerSetStatus(idx, 'synth'); try { - const b = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams()); + const b = await fetchTtsPreviewBlob(voice, _readerTextForSynth(readerState.sentences[idx].text), READER_FMT, instruct, backend, false, readerGenParams()); readerState.blobCache.set(idx, b); if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready'); } catch (_) { if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'pending'); return; } diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 7c1389d..030f826 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -75,7 +75,7 @@ const rehState = { staleLines: new Set(), // lines that were cached but had their tone changed synthCancelled: false, synthRunning: false, - skipDescriptions: true, + skipDescriptions: false, narratorVoice: '', practiceStart: null, practiceEnd: null, @@ -1899,11 +1899,82 @@ function _rehBackendIsFish() { catch (_) { id = rehState.backend || ''; } return /fish/i.test(id); } +// Fish-Speech's docs are explicit: "Use English emotion tags in square brackets +// regardless of the spoken language." Per-line auto emotions are LLM-generated in the +// book's own language (the casting prompt literally asks for "1-2 deutsche Wörter"), +// which is what _buildInstruct's native-language sentence needs for Qwen3-TTS — but +// that same raw word is useless as a Fish [tag] untranslated. Confirmed live: German +// tags were silently ignored, producing flat/emotionless Fish-Speech output even +// though the audio itself was fine. This table only needs to cover common tone/mood +// adjectives, not a full dictionary — unrecognized words fall through to no tag at +// all (line 8 below), which is a NOP for Fish, safer than sending a foreign-language +// word it will just ignore anyway. +const _REH_EMOTION_DE_EN = { + 'wütend':'angry','zornig':'angry','erzürnt':'angry','verärgert':'annoyed','gereizt':'irritated', + 'traurig':'sad','melancholisch':'melancholic','betrübt':'sad','niedergeschlagen':'dejected', + 'ängstlich':'scared','furchtsam':'fearful','verängstigt':'frightened','panisch':'panicked','nervös':'nervous', + 'fröhlich':'happy','glücklich':'happy','freudig':'joyful','heiter':'cheerful','vergnügt':'delighted', + 'flüsternd':'whispering','leise':'quiet','gedämpft':'hushed', + 'aufgeregt':'excited','begeistert':'enthusiastic','euphorisch':'euphoric', + 'überrascht':'surprised','erstaunt':'astonished','verblüfft':'amazed', + 'genervt':'annoyed','frustriert':'frustrated', + 'verzweifelt':'desperate','hoffnungslos':'hopeless','resigniert':'resigned', + 'entschlossen':'determined','entschieden':'decisive', + 'selbstbewusst':'confident','stolz':'proud','arrogant':'arrogant', + 'schüchtern':'shy','verlegen':'embarrassed','unsicher':'uncertain', + 'ironisch':'sarcastic','sarkastisch':'sarcastic','spöttisch':'mocking','höhnisch':'scornful','verächtlich':'contemptuous', + 'ernst':'serious','streng':'stern','autoritär':'authoritative','befehlend':'commanding', + 'sanft':'gentle','zärtlich':'tender','liebevoll':'loving','warm':'warm', + 'kalt':'cold','distanziert':'distant','gleichgültig':'indifferent','gelangweilt':'bored', + 'geheimnisvoll':'mysterious','unheimlich':'eerie','düster':'ominous','bedrohlich':'threatening', + 'dramatisch':'dramatic','theatralisch':'theatrical','pathetisch':'melodramatic', + 'ruhig':'calm','gelassen':'composed','besonnen':'measured', + 'schockiert':'shocked','entsetzt':'horrified','fassungslos':'stunned', + 'zögernd':'hesitant','verwirrt':'confused','ratlos':'bewildered','unschlüssig':'undecided', + 'weinend':'tearful','schluchzend':'sobbing','trauernd':'grieving','gebrochen':'broken', + 'schroff':'curt','barsch':'gruff','grob':'rough','abweisend':'dismissive', + 'freundlich':'friendly','herzlich':'warm','einladend':'welcoming', + 'spielerisch':'playful','neckend':'teasing','frech':'cheeky','schelmisch':'mischievous', + 'romantisch':'romantic','sehnsüchtig':'longing','verliebt':'infatuated', + 'triumphierend':'triumphant','siegessicher':'victorious', + 'erleichtert':'relieved','beruhigt':'reassured', + 'schuldbewusst':'guilty','reumütig':'remorseful', + 'neugierig':'curious','interessiert':'interested', + 'müde':'weary','erschöpft':'exhausted', + 'wehmütig':'wistful','nostalgisch':'nostalgic', + 'bemerkend':'remarking','feststellend':'noting','sachlich':'matter-of-fact','nüchtern':'plain', + 'flehend':'pleading','bittend':'imploring', + 'warnend':'warning','mahnend':'admonishing', + 'trotzig':'defiant','rebellisch':'rebellious', + 'erschrocken':'startled','verstört':'disturbed', +}; +function _rehEmotionEnglishTag(emotion) { + const raw = (emotion || '').trim(); + if (!raw) return ''; + // Preset emotions (REH_EMOTIONS / custom) are already English descriptive phrases — + // the first, most tag-like word is what Fish actually needs. + const firstWord = raw.split(/[,;]\s*/)[0].trim(); + const lower = firstWord.toLowerCase(); + // Check the DE→EN table BEFORE assuming "looks ASCII" means "is English" — most + // German emotion adjectives (e.g. "bedrohlich") are pure a-z too, so charset alone + // can't distinguish them from English; the dict must win first. + if (_REH_EMOTION_DE_EN[lower]) return _REH_EMOTION_DE_EN[lower]; + // Try stripping a common German adjective ending (declined forms LLMs sometimes + // produce, e.g. "ängstliche" instead of "ängstlich") and match on the stem. + const stem = lower.replace(/(e|er|es|en|em)$/, ''); + if (stem.length >= 4) { + for (const key in _REH_EMOTION_DE_EN) { + if (key.startsWith(stem)) return _REH_EMOTION_DE_EN[key]; + } + } + if (/^[a-z\- ]+$/.test(lower)) return lower; // not in the table, but looks English (e.g. REH_EMOTIONS presets) + return ''; // no reliable English tag — omit rather than send a word Fish will ignore +} function _rehInlineTone(text, emotion) { - const e = (emotion || '').trim(); - if (!e || !_rehBackendIsFish()) return text; + if (!_rehBackendIsFish()) return text; if (/^\s*\[/.test(text)) return text; // already carries an inline [tag] - return `[${e.toLowerCase()}] ${text}`; + const tag = _rehEmotionEnglishTag(emotion); + return tag ? `[${tag}] ${text}` : text; } // ── Auto-design voices with LLM + Design a Voice ──────────────────────────── @@ -2575,6 +2646,23 @@ function buildScriptPage() { : ''; const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : ''); + // Same detection audiobookExport() uses to decide chapter/file boundaries + // (audiobook.js) — surfaced here too so a chapter is visible in the script + // itself, not just audible as a pause in the finished export. Runs before + // the normal type switch so a chapter heading (however it was tagged + // during parsing — 'act', 'scene', or plain 'action' text recovered via + // heading OCR) always gets this treatment instead of its usual rendering. + if (typeof audiobookIsChapter === 'function' && audiobookIsChapter(line)) { + const label = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim(); + return `
+ ${bulkCheck} + + ${label ? escHtml(label) : 'Chapter'} + + ${editBtn} ${synthDot} +
`; + } + switch (line.type) { case 'act': return `
${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}
`; @@ -3348,6 +3436,22 @@ function selectEmotion(idx, value, anchorBtn) { if (value) _checkToneStyleSupport(); } +// One row of the tone/identity comparison table. +function _rehToneCmpRow(backend, isCurrent) { + const check = (ok) => ok + ? '' + : ''; + const action = isCurrent + ? 'current' + : ``; + return ` + ${escHtml(backend.label)} + ${check(backend.style_aware)} + ${check(backend.uses_wav)} + ${action} + `; +} + function _checkToneStyleSupport() { const warn = $('reh-tone-warn'); if (!warn) return; const txtEl = $('reh-tone-warn-txt'); @@ -3359,20 +3463,39 @@ function _checkToneStyleSupport() { // Two opposite engine trade-offs, surfaced so the user can choose knowingly: // • clone backends → consistent character identity, but weak tone control // • design backends → strong tone, but a fresh persona each call (voices drift) + // Originally a single run-on sentence with a button awkwardly wedged into + // the middle of it (confirmed live: read badly, wrapped worse). A table + // says the same two facts as two columns instead of two clauses. if (_rehBackendIsFish()) { // Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} keeps each character’s 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] — S2 supports free-form descriptions. Fish-Speech S2 ↗`; warn.hidden = false; } else if (!b.style_aware && hasTone) { - const styleAware = all.find(x => x.style_aware); - const suggest = styleAware ? ` Switch to ${escHtml(styleAware.label)} for reliable tone — but expect each voice to drift between lines.` : ''; - if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.${suggest}`; + // Prefer a backend that fixes BOTH weaknesses at once (tone-aware AND + // clones from a reference WAV, e.g. Fish-Speech) over one that only + // fixes this one (tone-aware but re-rolls the voice each line, e.g. + // Voice Design) — confirmed live: with both available, `.find()` was + // silently suggesting whichever happened to come first in the backend + // list, which was never Fish-Speech despite it being the strictly + // better option whenever it's actually running. + const styleAware = all.find(x => x.style_aware && x.uses_wav) || all.find(x => x.style_aware); + if (txtEl) { + txtEl.innerHTML = styleAware + ? `${_rehToneCmpRow(b, true)}${_rehToneCmpRow(styleAware, false)}
Tone controlVoice stays identical
` + : `${escHtml(b.label)} keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.`; + } warn.hidden = false; } else if (b.style_aware && !b.uses_wav) { - const wavBackend = all.find(x => x.uses_wav); - const suggest = wavBackend ? ` Switch to ${escHtml(wavBackend.label)} to keep each character’s voice identical throughout.` : ''; - const 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.' : ''; - if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}${suggest}`; + // Same preference as above, mirrored: a wav-cloning backend that's ALSO + // tone-aware (Fish-Speech) beats one that drops tone control entirely + // (Voice Clone) as the suggested alternative. + const wavBackend = all.find(x => x.uses_wav && x.style_aware) || all.find(x => x.uses_wav); + const 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.

' : ''; + if (txtEl) { + txtEl.innerHTML = wavBackend + ? `${_rehToneCmpRow(b, true)}${_rehToneCmpRow(wavBackend, false)}
Tone controlVoice stays identical
${qwenHint}` + : `${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}`; + } warn.hidden = false; } else { warn.hidden = true; @@ -3381,6 +3504,21 @@ function _checkToneStyleSupport() { $('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; }); +// The suggestion buttons above get rebuilt (via innerHTML) every time +// _checkToneStyleSupport() re-runs, so a delegated listener on the +// container — bound once — is the only reliable way to catch clicks on them. +$('reh-tone-warn')?.addEventListener('click', (e) => { + const btn = e.target.closest('.reh-tone-switch-btn'); + if (!btn) return; + const id = btn.dataset.backendId; + const sel = $('reh-backend-select'); + if (sel && [...sel.options].some(o => o.value === id)) sel.value = id; + rehState.backend = id; + _checkToneStyleSupport(); + const b = (typeof backendById === 'function') ? backendById(id) : null; + toast('Switched to ' + (b ? b.label : id), 'success'); +}); + // ── Transport controls ────────────────────────────────────────────────────── $('reh-tb-play')?.addEventListener('click', () => { @@ -3658,9 +3796,18 @@ async function playNextLine() { return playNextLine(); } - // Skip non-dialog lines only when skip mode is on AND no narrator voice is set. - // When a narrator voice exists, every line with text gets spoken — never skip. - if (rehState.skipDescriptions && !rehState.narratorVoice) { + // Skip non-dialog lines whenever skip mode is on, full stop — regardless of + // whether a narrator voice happens to be assigned. This used to also require + // !rehState.narratorVoice, on the theory that skipping was only meaningful + // when there was nothing to skip TO — but the individual-line branch below + // never re-checked skipDescriptions at all, so once ANY narrator voice was + // configured (the common case once a book is actually set up), narration + // played regardless of this toggle. Confirmed live: Studio's "Rehearse ⇄ + // Audiobook" toggle (which just flips this same flag) had zero observable + // effect once a narrator voice existed — exactly the reported "doesn't + // read the narrator" / toggle-does-nothing behavior, just inverted from + // what it looked like (narration was stuck ON, not stuck OFF). + if (rehState.skipDescriptions) { while ( rehState.lineIndex < rehState.lines.length && rehState.lines[rehState.lineIndex].type !== 'dialog' && @@ -3698,7 +3845,7 @@ async function playNextLine() { if (line.type !== 'dialog') { // Any line with text can be narrated — direction/transition/scene/act/action all included const hasText = !!(line.text || '').trim(); - if (rehState.narratorVoice && hasText) { + if (rehState.narratorVoice && hasText && !rehState.skipDescriptions) { showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : '')); const cached = rehState.synthCache.get(rehState.lineIndex); if (cached) { diff --git a/static/js/routing.js b/static/js/routing.js index ff0463a..2fb330a 100644 --- a/static/js/routing.js +++ b/static/js/routing.js @@ -131,6 +131,7 @@ function playRouteSound(path, btn) { _routeSoundPlayingButton = btn; btn.textContent = '❚❚'; audio.hidden = false; + audio.playbackRate = 1; // reset — the route-speed preview below reuses this same element audio.src = routeSoundUrl(path); audio.onended = () => { btn.textContent = '▶'; }; audio.onpause = () => { if (_routeSoundPlayingButton === btn) btn.textContent = '▶'; }; @@ -141,6 +142,61 @@ function playRouteSound(path, btn) { }); } +// Short, natural-sounding test lines per language — same idea as the fixed +// German phrase in the "Test route" box above the table, just localized so a +// route's own language column previews sensibly instead of always reading +// English/German regardless of what the route actually routes. +const ROUTE_SPEED_PREVIEW_TEXT = { + EN: 'This is a quick playback speed test.', + DE: 'Das ist ein kurzer Test der Wiedergabegeschwindigkeit.', + FR: "Ceci est un test rapide de la vitesse de lecture.", + ES: 'Esta es una prueba rápida de la velocidad de reproducción.', + IT: 'Questo è un rapido test della velocità di riproduzione.', + PT: 'Este é um teste rápido da velocidade de reprodução.', + NL: 'Dit is een snelle test van de afspeelsnelheid.', + PL: 'To jest krótki test szybkości odtwarzania.', +}; + +// Synthesizes the row's output voice directly (bypassing the app/voice +// routing-table lookup that /v1/audio/speech would do) so speed changes are +// audible instantly without saving the route first. Applies speed via the +//