From f7264a8c075a1f06ea61dd16aaa52ee225920c55 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 23 Aug 2026 09:12:57 +0200 Subject: [PATCH] chore: release 1.21.0 - Consistent ComfyUI Character Renders and Audiobook Export - Deterministic hash-based character portraits and reference sheets - Implemented strict early Renaissance aesthetic controls and role-specific headwear rules - Added audiobook batch export scripts and progress tooling - Verified book profiles with a new 'confirmed' state tracking mechanism - Updated Pillow dependency for robust image handling - Configured .gitignore for audiobook and audio cache exports --- .gitignore | 3 + CHANGELOG.md | 9 + VERSION | 2 +- requirements.txt | 1 + routes/conversation.py | 893 +++++++++++++++++- routes/library.py | 4 +- scripts/export_all_audiobooks.py | 269 ++++++ scripts/generate_character_image_samples.py | 52 + scripts/render_trial_chapters.mjs | 137 +++ scripts/run_voice_quality_pass.mjs | 65 ++ static/dist/main.min.js | 10 +- static/js/audiobook.js | 30 +- static/js/library-characters.js | 374 +++++++- static/js/reader.js | 5 + static/js/studio.js | 78 ++ static/samples/ab/stats.json | 1 + static/samples/canon/stats.json | 1 + static/samples/clone/stats.json | 1 + static/samples/clone/stats_only.json | 1 + static/samples/cv/stats.json | 1 + static/samples/en5/stats.json | 1 + static/samples/fishmap/stats.json | 1 + static/samples/fixed/stats.json | 1 + static/samples/index.html | 283 ++++++ static/samples/manifest.json | 272 ++++++ static/samples/manifest_basic.json | 302 ++++++ static/samples/voicectl/stats.json | 1 + static/samples/works/stats.json | 1 + static/sections/s-caststudio.html | 2 + static/style.css | 91 ++ .../comfyui-character-consistent-images.json | 652 +++++++++++++ workflows/comfyui-character-identity-v3.json | 89 ++ .../comfyui-character-turnaround-v3.json | 81 ++ 33 files changed, 3670 insertions(+), 44 deletions(-) create mode 100644 scripts/export_all_audiobooks.py create mode 100644 scripts/generate_character_image_samples.py create mode 100644 scripts/render_trial_chapters.mjs create mode 100644 scripts/run_voice_quality_pass.mjs create mode 100644 static/samples/ab/stats.json create mode 100644 static/samples/canon/stats.json create mode 100644 static/samples/clone/stats.json create mode 100644 static/samples/clone/stats_only.json create mode 100644 static/samples/cv/stats.json create mode 100644 static/samples/en5/stats.json create mode 100644 static/samples/fishmap/stats.json create mode 100644 static/samples/fixed/stats.json create mode 100644 static/samples/index.html create mode 100644 static/samples/manifest.json create mode 100644 static/samples/manifest_basic.json create mode 100644 static/samples/voicectl/stats.json create mode 100644 static/samples/works/stats.json create mode 100644 workflows/comfyui-character-consistent-images.json create mode 100644 workflows/comfyui-character-identity-v3.json create mode 100644 workflows/comfyui-character-turnaround-v3.json diff --git a/.gitignore b/.gitignore index 1a6be48..5e52729 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ config/*.db config/*.db-shm config/*.db-wal config/reader_library/ +config/audiobook_batch_cache/ +config/audiobook_exports/ +config/line_audio_cache/ logs/*.log logs/*.log.* diff --git a/CHANGELOG.md b/CHANGELOG.md index f807d34..c172f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to **TTS Voice Creator — Clone and Design** are documented Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned with [Semantic Versioning](https://semver.org/). --- + + ## [1.21.0] — 2026-08-23 + + ### Added + - **Consistent Character Renders in ComfyUI.** Replaced the generic fallback image pipeline with a deterministic, layout-first prompting strategy. A new `/api/character-generate-consistent-images` endpoint generates matching 1:1 portrait avatars and 3:2 character reference sheets (turnarounds) for the entire cast, maintaining strict character identity via hash-based deterministic seeds. + - **Period and Setting Enforcement.** Image prompts now strictly enforce an Aventurian early-Renaissance (c. 1420-1500) style, explicitly clamping down on anachronistic modern fashion, high-fantasy glowing magic, and generic medieval tropes. Roles and classes automatically assign period-accurate clothing layers and headwear (e.g., chaperons, coifs). + - **Automated Audiobook Export.** Added a batch script (`scripts/export_all_audiobooks.py`) for sequential, resumable exporting of all completed audiobooks into merged per-chapter MP3s. + - **Book Profile Validation.** Added a `confirmed` status field to book profiles to track data completion. + - **Dependency Updates.** Added `Pillow>=10.3.0` to requirements to support the new image processing pipeline. ## [1.20.46] — 2026-08-19 diff --git a/VERSION b/VERSION index 0ef939e..3500250 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.20.46 +1.21.0 diff --git a/requirements.txt b/requirements.txt index 78947f8..5741806 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ numpy>=1.26.0 pedalboard>=0.9.0 scipy>=1.12.0 requests>=2.31.0 +Pillow>=10.3.0 yt-dlp>=2024.3.10 diff --git a/routes/conversation.py b/routes/conversation.py index a0bda8f..51cea1f 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -5,6 +5,7 @@ import asyncio import base64 import contextlib import copy +import hashlib import io import json import re @@ -17,6 +18,7 @@ from urllib.parse import quote import requests from typing import Optional +from PIL import Image, ImageDraw, ImageOps from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import Response, StreamingResponse @@ -1248,6 +1250,482 @@ def _silly_tavern_prompt_instruction(sheet: dict) -> str: ) +def _visual_prompt_value(value) -> str: + """Flatten character-sheet values without leaking Python reprs into prompts.""" + if isinstance(value, list): + return ", ".join(_visual_prompt_value(item) for item in value if _visual_prompt_value(item)) + if isinstance(value, dict): + return ", ".join( + f"{key}: {_visual_prompt_value(item)}" + for key, item in value.items() if _visual_prompt_value(item) + ) + return str(value or "").strip() + + +def _primary_visual_role(sheet: dict) -> str: + """Choose the role most directly supported by the established costume. + + Extracted profiles often accumulate plot roles (guard, commander, parent) + alongside the day-to-day occupation. Sending every role to an image model + caused it to add unrelated armor and weapons. Prefer the shortest role + explicitly echoed by the clothing description, otherwise the first role. + """ + profession = _visual_prompt_value(sheet.get("profession")) + roles = [part.strip() for part in re.split(r"[,;/\n]+", profession) if part.strip()] + if not roles: + return "" + explicit = _visual_prompt_value(sheet.get("visual_role")).strip() + if explicit: + return explicit + clothing = _established_visual_value(sheet, "visual_costume", "clothing", 320).casefold() + supported = [role for role in roles if len(role) >= 4 and role.casefold() in clothing] + return min(supported, key=len) if supported else roles[0] + + +def _established_visual_value(sheet: dict, override_key: str, source_key: str, + limit: int = 320) -> str: + """Return visual facts that are established rather than speculative prose. + + Character extraction is intentionally generous and sometimes records its + reasoning ("probably wears...", "not described, but suitable for..."). + Those guesses are useful in the profile, but they must not become costume + facts. A user-supplied visual override is authoritative; otherwise only + non-speculative sentences survive. + """ + override = _visual_prompt_value(sheet.get(override_key)).strip() + if override: + return override[:limit] + raw = _visual_prompt_value(sheet.get(source_key)).strip() + if not raw: + return "" + uncertain = re.compile( + r"\b(probably|possibly|perhaps|likely|assumed|inferred|uncertain|not described|" + r"wahrscheinlich|vermutlich|vielleicht|möglicherweise|angenommen|unsicher|" + r"nicht (?:näher |genau )?beschrieben|nicht detailliert beschrieben|" + r"wir müssen vorsichtig|es ist unklar|laut (?:dem )?text|im kontext|" + r"wird bei einem anderen|could be|might be)\b", + re.IGNORECASE, + ) + sentences = re.split(r"(?<=[.!?])\s+", raw) + grounded = [sentence.strip() for sentence in sentences if sentence.strip() and not uncertain.search(sentence)] + value = " ".join(grounded).strip() + if len(value) > limit: + value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…" + return value + + +def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str: + """Build the replaceable Subject block used by every character image.""" + def clipped(key: str, limit: int) -> str: + value = _visual_prompt_value(sheet.get(key)) + if len(value) <= limit: + return value + return value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…" + + species = _established_visual_value(sheet, "visual_species", "race_species", 100) + appearance = _established_visual_value(sheet, "visual_appearance", "physical", 320) + costume = _established_visual_value(sheet, "visual_costume", "clothing", 320) + rows = [ + ("name", name), + ("gender", clipped("gender", 40)), + ("age", clipped("age_estimate", 100)), + ("species", species), + ("title", clipped("title", 100)), + ("role", clipped("profession", 180) if include_items else _primary_visual_role(sheet)), + ("archetype", clipped("archetype", 100)), + ("appearance", appearance), + ("established worn costume", costume), + ] + if include_items: + props = _character_prop_item_list(sheet) + if props: + rows.append(("verified portable items", ", ".join(props))) + return "; ".join(f"{label}: {value}" for label, value in rows if value) + + +def _book_visual_context(book: str, book_profile: dict) -> str: + values = [ + _visual_prompt_value(book_profile.get("genre")), + _visual_prompt_value(book_profile.get("setting")), + _visual_prompt_value(book_profile.get("era")), + _visual_prompt_value(book_profile.get("visual_context")), + ] + context = "; ".join(value for value in values if value) + return context or ( + f"the established world and period of {book}" if book else + "the story's established world and historical period" + ) + + +def _character_has_established_headwear(sheet: dict) -> bool: + # Inventory is deliberately excluded. A helmet/hood listed as a prop or + # recovered object does not mean the character wears it. Treat only the + # established appearance/costume as authority for worn headgear. + # A user/visual costume correction is more authoritative than an older + # extracted clothing paragraph. This matters for characters such as + # Lysandra, whose early armor description must not override the selected + # forest-fighter design. + costume = _visual_prompt_value(sheet.get("visual_costume")).strip() + established = " ".join(filter(None, ( + _visual_prompt_value(sheet.get("visual_appearance") or sheet.get("physical")).lower(), + (costume or _visual_prompt_value(sheet.get("clothing"))).lower(), + ))) + return bool(re.search( + # Do not treat German "hat" ("has") as the English noun "hat" — + # that exact false positive let Gamba keep his modern felt hat. + r"\b(headwear|hood|cap|helmet|coif|veil|turban|hut|m[üu]tze|kapuze|haube|helm|schleier)\b|" + r"\b(?:wears?|wearing|with)\s+(?:an?\s+)?(?:[a-z-]+\s+){0,2}hat\b", + established, + )) + + +def _period_lock(book: str, book_profile: dict, sheet: dict) -> str: + headwear = ( + "Use only the explicitly established period headwear and reproduce it unchanged in every view." + if _character_has_established_headwear(sheet) else + "The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed." + ) + return ( + f"Book lock: {_book_visual_context(book, book_profile)}. " + "Use museum-authentic Aventurian early-Renaissance c.1420-1500 wool, linen, leather, forged metal, hand stitching, period armor, tools, weapons, fastenings, and footwear. " + "Garments use ties, lacing, toggles, hand-sewn buttons, hooks, or simple forged buckles; footwear uses stitched leather soles and period construction. " + "Every visible design element is pre-industrial and period-authentic, with natural materials, hand-worked construction, flat stitched-leather soles, and age-appropriate wear. " + f"{headwear}" + ) + + +def _character_prop_item_list(sheet: dict) -> list[str]: + """Return zero to three verified, visually distinct portable items. + + Companions, creatures and worn costume pieces belong neither in a prop + callout nor in the identity pass. Empty slots stay empty: inventing a + generic tool is worse than showing fewer props. Near-synonyms also share + a category, so three sword variants cannot fill all three slots. + """ + explicit = sheet.get("visual_props") + raw = explicit if isinstance(explicit, list) else sheet.get("inventory") + values = raw if isinstance(raw, list) else re.split(r"[,;\n]+", str(raw or "")) + excluded = re.compile( + r"\b(location|room|place|viewpoint|observation point|residence|stairs?|tower|" + r"post|outpost|station|ort|raum|platz|posten|beobachtungspunkt|beobachtungsposten|wendeltreppe|wohnort|turm|" + r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|" + r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|" + r"hat|headwear|hood|cap|helmet|veil|cloak|coat|shirt|robe|armor set|armour set|" + r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|kleidung|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer)\b", + re.IGNORECASE, + ) + uncertain = re.compile(r"\b(implied|inferred|possible|impliziert|vermutlich|wahrscheinlich)\b", re.IGNORECASE) + + def category(item: str) -> str: + lower = item.casefold() + groups = ( + ("edged-weapon", r"\b(sword|blade|sabre|saber|rapier|dagger|knife|zweih[aä]nder)\b|(?:schwert|s[äa]bel|degen|dolch|messer)\b"), + ("ranged-weapon", r"\b(bow|crossbow|longbow|shortbow)\b|(?:bogen|armbrust)\b"), + ("staff", r"\b(staff|wand|rod|stave)\b|(?:stab|st[äa]be|rute)\b"), + ("key", r"\b(key|keys)\b|schl[üu]ssel\b"), + ("hammer", r"hammer\b"), + ("tongs", r"\btongs\b|zange(?:n)?\b"), + ("document", r"\b(scroll|map|book|letter|seal|document|rolle|karte|buch|brief|siegel)\b"), + ("vial", r"\b(vial|flask|bottle|phiole|fl[äa]schchen|flasche)\b"), + ("light", r"\b(torch|lantern|fackel|laterne)\b"), + ) + for label, pattern in groups: + if re.search(pattern, lower): + return label + # Unknown items remain eligible, but duplicate normalized wording is + # still collapsed below. + return re.sub(r"\W+", "-", lower).strip("-") or "item" + + clean: list[str] = [] + seen_categories: set[str] = set() + for value in values: + item = _visual_prompt_value(value).strip(" .;,") + item_category = category(item) + if (item and not excluded.search(item) and not uncertain.search(item) + and item.casefold() not in {v.casefold() for v in clean} + and item_category not in seen_categories): + clean.append(item) + seen_categories.add(item_category) + if len(clean) == 3: + break + return clean + + +def _character_prop_items(sheet: dict) -> str: + """Backward-compatible text form used by prompt previews/tests.""" + return ", ".join(_character_prop_item_list(sheet)) + + +def _prop_render_description(item: str) -> str: + """Add an English silhouette lock to short, grounded prop labels.""" + value = _visual_prompt_value(item).strip() + lowered = value.lower() + rules = ( + (r"\bcrossbow\b|armbrust\b", "one complete early-Renaissance crossbow: a horizontal wooden bow mounted across a wooden stock, with visible string, nut, trigger, and stirrup"), + (r"\b(longbow|shortbow|bow)\b|bogen\b", "one complete traditional wooden archery bow: one long curved wooden stave with one taut bowstring running continuously from tip to tip"), + (r"\b(zweihänder|sword|blade)\b|schwert\b", "one complete forged sword with exactly one blade, one guard, one grip, and one pommel"), + (r"\b(dagger|knife)\b|(?:dolch|messer)\b", "one complete short dagger with exactly one blade, one guard, one grip, and one pommel"), + (r"\bkey\b|schlüssel\b", "one single large hand-forged iron key with one circular bow, one straight shaft, and one toothed bit"), + (r"\b(staff|rod|wand)\b|stab\b", "one complete 180-centimeter ash-wood walking staff: one continuous straight round wooden shaft from its plain ferrule to one modest carved spiral handgrip, shown vertically at full length"), + (r"hammer\b", "one complete blacksmith's forging hammer with one dark forged-iron hammer head fixed to one worn ash-wood handle"), + (r"\b(tongs|pliers)\b|zange(?:n)?\b", "one complete pair of long blacksmith's forging tongs: two forged arms joined at one pivot with gripping jaws"), + (r"\bhorseshoe\b|hufeisen\b", "one single U-shaped hand-forged iron horseshoe with evenly spaced nail holes"), + (r"\b(rolle|schriftrolle|scroll|document|map|karte)\b", "one complete rolled parchment document tied with a plain cord, its surface blank and unlettered"), + (r"\b(phiole|vial|fläschchen|bottle)\b", "one small complete period glass vial with a plain fitted stopper"), + (r"\b(laterne|lantern|lamp)\b", "one complete early-Renaissance hand lantern of forged iron and translucent horn panels"), + ) + for pattern, description in rules: + if re.search(pattern, lowered, re.IGNORECASE): + return f"{value} — {description}" + return f"{value} — one complete portable object matching this exact noun, silhouette, and function" + + +def _character_scene_evidence(sheet: dict) -> str: + """Choose one representative, source-grounded action for the lower panel.""" + override = _visual_prompt_value(sheet.get("visual_scene")).strip() + if override: + return override[:700] + sources = sheet.get("sources") if isinstance(sheet.get("sources"), list) else [] + priorities = { + "action": 12, "mannerisms": 10, "capabilities": 9, + "conflict_style": 8, "communication_style": 7, + "profession": 5, "motivation": 4, + } + action_words = re.compile( + r"\b(said|spoke|shouted|ordered|fought|raised|led|watched|looked|signaled|" + r"sprach|sagte|rief|schrie|befahl|k[äa]mpfte|hob|f[üu]hrte|blickte|beobachtete|winkte|griff|zog|st[üu]rzte|schlug)\b", + re.IGNORECASE, + ) + best: tuple[int, str] | None = None + for source in sources: + if not isinstance(source, dict): + continue + quote_text = _visual_prompt_value(source.get("quote")).strip() + if not quote_text: + continue + hint = _visual_prompt_value(source.get("line_hint")).casefold() + score = max((value for key, value in priorities.items() if key in hint), default=0) + if action_words.search(quote_text): + score += 3 + else: + score -= 8 + if any(word in hint for word in ("physical", "inventory", "identity")): + score -= 3 + candidate = (score, quote_text) + if best is None or candidate[0] > best[0]: + best = candidate + if best: + return best[1][:700] + for key in ("mannerisms", "capabilities", "conflict_style", "profession"): + value = _visual_prompt_value(sheet.get(key)).strip() + if value: + return value[:700] + return "The character performs their established everyday role in a natural, in-character moment." + + +def _build_character_portrait_prompt(name: str, book: str, sheet: dict, book_profile: dict, + visual_style: str = "cinematic") -> str: + # Tools belong in the equipment panel, not in the portrait identity + # anchor. Including inventory here made the image model hang every tool + # from the costume even when the composition explicitly requested empty + # hands. + facts = _character_visual_facts(name, sheet, include_items=False) + if visual_style == "concept": + style = ( + "detailed semi-realistic hand-painted fantasy production portrait from the same fixed studio artist as the cast sheets, " + "with consistent facial construction, restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, " + "and believable weathered materials; no photorealism, anime, cartoon, glossy 3D render, or mixed media" + ) + else: + style = ( + "photorealistic documentary-editorial historical fantasy photography from one fixed production photographer, " + "with honest skin texture, natural hair, weathered period materials, restrained warm-neutral color science, " + "subtle film grain, and no illustration, anime, painterly rendering, beauty retouching, plastic skin, or glossy game-render finish" + ) + return ( + "Task: Generate one historical-fantasy audiobook cast portrait. " + f"Subject: original fictional character — {facts}. " + f"Context: cast portrait for {book or 'this audiobook'}; {_period_lock(book, book_profile, sheet)} " + f"Style: {style}. " + "Composition: one person, chest-up square at eye level, 70 mm, calm three-quarter gaze, warm-gray studio background, face unobstructed. " + "Lighting: fixed soft north-window key camera-right, restrained fill and rim, natural shadows, no color cast. " + "Constraints: identity, gender, age, species, occupation, clothing, and marks must be unmistakable; no extra people, text, logos, watermark, modern styling, or real-person likeness. " + "Output: production-ready 1024x1024 portrait, safe for a circular crop." + ) + + +def _build_character_sheet_prompt(name: str, book: str, sheet: dict, book_profile: dict, + visual_style: str = "concept") -> str: + facts = _character_visual_facts(name, sheet) + if visual_style == "cinematic": + style = ( + "photorealistic historical-fantasy costume-continuity photography from the same fixed production photographer as the cast portraits, " + "with a real performer, honest skin texture, restrained warm-neutral color science, grounded anatomy, and physically believable " + "weathered wool, linen, leather, wood, and forged metal; no illustration, anime, cartoon, glossy 3D render, photo collage, or mixed media" + ) + else: + style = ( + "detailed semi-realistic hand-painted fantasy production concept art from one fixed studio artist, with consistent facial construction, " + "restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, and believable weathered wool, linen, leather, wood, " + "and forged metal; no anime, cartoon, glossy 3D render, photo collage, or mixed media" + ) + return ( + "Task: Generate one production character turnaround and equipment reference sheet. " + f"Subject: the same original fictional character — {facts}. " + f"Context: costume and continuity handoff for {book or 'this audiobook'}; {_period_lock(book, book_profile, sheet)} " + f"Style: {style}. " + "Composition: clean production board; three equal-scale, fully visible full-body orthographic views across the top: strict front, true left profile, strict back, same neutral stance. " + "Below, one source-grounded story moment showing the character acting, speaking, working, or fighting as they do in the book, plus zero to three verified portable items; each item is visually distinct, complete, shown once, assembled, isolated, and unlabeled. Empty prop slots remain empty. " + "Lighting: identical neutral museum-soft light, gentle contact shadows, no color cast. " + "Constraints: identical face, age, species, proportions, silhouette, garment seams, palette, and wear in every view; inventory never implies that armor or clothing is worn; no invented or duplicate gear, no repeated weapon category, no disassembled/floating fragments, extra limbs, generated text, logos, or watermark. " + "Output: crisp production-ready square continuity sheet assembled from individually controlled panels." + ) + + +def _saved_prompt_subject(prompt: str, fallback: str, limit: int = 1100) -> str: + """Keep user edits to the Subject clause without re-sending a long master. + + Layout/style/period are application invariants; the editable Subject is + the character-specific portion users reasonably need to refine. + """ + match = re.search(r"\bSubject:\s*(.*?)(?=\s+Context:)", prompt or "", flags=re.DOTALL | re.IGNORECASE) + value = (match.group(1).strip() if match else "") or fallback + if len(value) > limit: + value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…" + return value + + +def _positive_conditioning_text(value: str) -> str: + """Remove negated visual concepts before positive image conditioning. + + Qwen's positive encoder can visualize nouns even when they occur after + words such as ``no``, ``without`` or ``ohne``. Structured positive facts + remain authoritative; exclusions belong in the workflow's negative + conditioning. + """ + value = re.sub( + r"\b(?:no|without|never|not\s+wearing|do\s+not|don't|" + r"kein(?:e|en|em|er|es)?|ohne)\b[^;,.·]*", + "", + value, + flags=re.IGNORECASE, + ) + value = re.sub( + r"\b(?:absolutely\s+)?bareheaded\b", + "with the complete natural hairstyle visible", + value, + flags=re.IGNORECASE, + ) + value = re.sub(r"\s+([,;.])", r"\1", value) + value = re.sub(r"([,;])\s*([,;])", r"\1", value) + return re.sub(r"\s{2,}", " ", value).strip(" ,;") + + +def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, + book_profile: dict, visual_style: str, + saved_profile: str, saved_reference: str) -> tuple[str, str, str, str, str, list[str]]: + """Short, layout-first prompts for the local Qwen/Flux encoder. + + The detailed masters remain visible/editable in the UI. These render + prompts avoid truncating the constraints and stop the edit model turning + a requested turnaround into another portrait poster. + """ + facts = _character_visual_facts(name, sheet, include_items=False) + portrait_subject = _saved_prompt_subject(saved_profile, facts) + sheet_subject = _saved_prompt_subject(saved_reference, facts) + # Standard generated prompts append inventory as a final labelled clause. + # Strip that clause from the shared person/costume source; the dedicated + # equipment pass below remains the sole place where those objects appear. + portrait_subject = re.sub( + r"\s*;\s*(?:signature items|verified portable items)\s*:\s*.*$", "", portrait_subject, + flags=re.IGNORECASE | re.DOTALL, + ).strip() + sheet_subject = re.sub( + r"\s*;\s*(?:signature items|verified portable items)\s*:\s*.*$", "", sheet_subject, + flags=re.IGNORECASE | re.DOTALL, + ).strip() + # A saved v3 Subject is the user's editable character direction. Do not + # silently replace its carefully chosen visual role with an extracted + # profession token (for example "Vampir" or "Inquisitor"), because those + # broad labels strongly trigger generic costume stereotypes. The + # structured fallback already uses _primary_visual_role when no edited + # Subject is available. + props = _character_prop_item_list(sheet) + scene_evidence = _positive_conditioning_text(_character_scene_evidence(sheet)) + has_headwear = _character_has_established_headwear(sheet) + portrait_subject = _positive_conditioning_text(portrait_subject) + sheet_subject = _positive_conditioning_text(sheet_subject) + no_headwear = ( + "Use only the explicitly established period headwear." + if has_headwear else + "The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed." + ) + bareheaded = not has_headwear + head_lock = ( + "HEAD CONTINUITY: In all three figures, clearly show the exposed scalp, crown, hairline, ears, and complete natural hairstyle in front, profile, and rear views. " + if bareheaded else + "HEADWEAR LOCK: reproduce only the explicitly established period headwear, identically in all three views. " + ) + period = ( + f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. Aventurian early Renaissance c.1420-1500 only: " + "wool, linen, leather, hand-worked fastenings, restrained forged-metal details, and flat stitched-leather footwear. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. " + f"{no_headwear}" + ) + if visual_style == "concept": + portrait_style = "One fixed semi-realistic hand-painted fantasy production artist with grounded anatomy and weathered materials." + sheet_style = portrait_style + else: + portrait_style = "Strict live-action photorealistic historical-fantasy production photography; real actor, honest skin, natural hair, restrained warm-neutral film color." + sheet_style = "Strict live-action photorealistic costume-continuity studio photography by that same production photographer, with a real actor and physically believable period materials." + portrait = ( + ("HEAD CONTINUITY: Clearly show the exposed scalp, crown, hairline, ears, and complete natural hairstyle. " if bareheaded else "") + + "CREATE ONE VERTICAL FULL-BODY CHARACTER IDENTITY. " + f"CHARACTER: {portrait_subject}. {period} {portrait_style} " + "EXACTLY ONE ADULT PERSON. Interpret all metaphors, memories, relationships, titles, and story actions only as character direction. The background is completely empty. " + "Complete head-to-boots silhouette, relaxed front three-quarter stance, eye level, 50mm, plain uniform warm-white seamless studio background, fixed soft north-window key camera-left. " + "Occupation is immediately obvious from the costume and material wear alone. Show occupational work clothing only. Both hands are empty; the belt, back, and clothing are clear of carried equipment. Face unobstructed. " + "Leave generous empty space above the hair and around the complete body. Keep the complete head, hair, hands, and feet fully inside frame. Output 1024x1536." + ) + # Generate the three orthographic views together in one T2I frame. The + # edit model preserved a source portrait too literally: it frequently + # returned three frontal portraits and even copied a stick-figure pose + # guide into the costume. A single native triptych gives Qwen explicit + # spatial slots while keeping one face/costume interpretation across all + # three views. The app then crops the fixed thirds and assembles them. + front_view = ( + f"{head_lock}CREATE ONE CLEAN THREE-PANEL ORTHOGRAPHIC CHARACTER TURNAROUND, WITH EXACTLY THREE FULL-BODY FIGURES IN ONE HORIZONTAL ROW. " + f"EXACT CHARACTER: {sheet_subject}. {period} {sheet_style} " + "LEFT THIRD: one complete full-body strict front view, shoulders hips knees and feet square to camera. " + "CENTER THIRD: one complete full-body strict side view with the entire body rotated exactly 90 degrees toward image-left. " + "RIGHT THIRD: one complete full-body strict rear view looking directly away, showing the back of head, back garment seams, backs of legs and heels. " + "All three figures are the identical adult person with identical face, species, age, body proportions, hair, facial hair, costume construction, seams, palette, material wear, and footwear. Keep the exact anatomically established facial structure and species traits from Subject in every view. Use the same full-length 50mm camera distance in every panel. Align all three crowns and all three soles horizontally; each complete head-to-boots silhouette occupies the same height and remains centered in its equal-width third. Use a neutral stance, relaxed arms, visibly empty hands, unobstructed belts, and unobstructed backs. " + "Plain empty warm-white seamless studio background, neutral museum-soft light, soft contact shadows. Keep generous margin above every head and below every foot. The frame contains exactly the three required figures and the plain seamless background. Keep every surface unlettered and free of graphic overlays. Output 1536x1024." + ) + side_view = ( + "Unused: the side view is generated in the controlled turnaround triptych." + ) + back_view = ( + "Unused: the rear view is generated in the controlled turnaround triptych." + ) + scene_prompt = ( + ("HEAD CONTINUITY: Every visible person's scalp, crown, hairline, ears, and natural hairstyle are clearly visible and unobstructed. " if bareheaded else "") + + "CREATE ONE HORIZONTAL PHOTOREALISTIC SOURCE-GROUNDED STORY MOMENT WITH NATURAL ACTION. " + f"EXACT CHARACTER: {sheet_subject}. " + f"BOOK-SOURCE EVIDENCE: {scene_evidence}. " + "Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor. Preserve the stated face, age, species, build, hair, costume, materials, and period. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. " + f"{period} {sheet_style} Use a believable location from the book, restrained cinematic staging, and complete uncropped head and hands. Keep all surfaces unlettered and free of graphic overlays. Output 768x512." + ) + prop_prompts = [ + ( + "CREATE ONE SINGLE ISOLATED MUSEUM-CATALOG OBJECT PHOTOGRAPH. " + f"OBJECT IDENTITY — FOLLOW LITERALLY: {_prop_render_description(item)}. Show exactly one complete assembled object, fully visible and centered on a plain uniform warm-white sweep with at least fifteen percent empty margin on every side. " + f"{period} Use neutral museum-soft product lighting and physically believable, hand-worked period materials. The frame is an uncluttered object-only catalog plate containing a single centered object and empty space around it. Keep every surface unlettered and free of graphic overlays. Output 512x512." + ) + for item in props + ] + return portrait, front_view, side_view, back_view, scene_prompt, prop_prompts + + @router.post("/api/character-generate-prompts") async def character_generate_prompts(request: Request): """Turn an already-extracted character sheet into four ready-to-use external @@ -1262,6 +1740,11 @@ async def character_generate_prompts(request: Request): name: str = (data.get("name") or "").strip() book: str = (data.get("book") or "").strip() sheet: dict = data.get("sheet") or {} + book_profile: dict = data.get("book_profile") or {} + voice_feedback: str = (data.get("voice_feedback") or "").strip() + visual_style: str = (data.get("visual_style") or "cinematic").strip().lower() + if visual_style not in {"cinematic", "concept"}: + visual_style = "cinematic" language: str = (data.get("language") or "").strip() _settings = _load_settings() llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") @@ -1284,6 +1767,19 @@ async def character_generate_prompts(request: Request): ("Secret/flaw", _f("secret")), ("Inventory", _f("inventory")), ] if v) + if voice_feedback: + profile += "\nPrevious voice review feedback — correct these issues explicitly: " + voice_feedback + feedback_lower = voice_feedback.lower() + hard_corrections = [] + if "us accent" in feedback_lower or "american" in feedback_lower: + hard_corrections.append("HARD CORRECTION: use an authentic native German accent with zero American or English accent coloring") + if "female" in feedback_lower and ("male" in feedback_lower or "not female" in feedback_lower): + hard_corrections.append("HARD CORRECTION: the result must unmistakably sound female, never male or androgynous") + elif "male" in feedback_lower and ("female" in feedback_lower or "not male" in feedback_lower): + hard_corrections.append("HARD CORRECTION: the result must unmistakably sound male, never female or androgynous") + if hard_corrections: + profile += "\n" + "\n".join(hard_corrections) + lang_note = f" Write every prompt in {language} EXCEPT where told to use English." if language else "" preamble = ( "You are a prompt engineer who turns a fiction character's profile into ready-to-paste prompts for " @@ -1296,7 +1792,13 @@ async def character_generate_prompts(request: Request): "weapons, or props. Never add lifestyle items, underwear, cosmetics, brands, or extra gear unless the profile " "explicitly establishes them.\n\n" ) - user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\n" + context = ", ".join(str(book_profile.get(k) or "").strip() for k in ("genre", "setting", "era") if str(book_profile.get(k) or "").strip()) + context_block = ( + f"BOOK PRODUCTION CONTEXT: {context}\n" + "Treat this as a hard continuity constraint. Clothing, uniforms, hats, tools, architecture, materials, and props must belong to this period and setting. Do not introduce modern or 19th/20th-century items unless the book context explicitly says so.\n\n" + if context else "" + ) + user = f"BOOK: {book or 'unspecified'}\n\n{context_block}CHARACTER PROFILE:\n{profile or name}\n\n" visual_identity_rule = ( "VISUAL IDENTITY LOCK: Treat the profile as a production design bible. " "Gender is mandatory and must be stated explicitly; never reinterpret a male character as female or vice versa. " @@ -1330,7 +1832,7 @@ async def character_generate_prompts(request: Request): "deliberate pauses before key words), and a specific emotional baseline drawn from THIS character's " "own personality/backstory (guarded warmth, brittle confidence, weary sarcasm — not just 'friendly' " "or 'energetic'). Two characters with the same age and gender in your profile should still end up " - "with visibly different prompts once you've done this. Do not mention plot events — describe only " + "with visibly different prompts once you've done this. If previous voice review feedback appears in the profile, treat every item as a hard correction and make the new prompt audibly address it. Do not mention plot events — describe only " "how the voice should SOUND.\n\n" 'Respond with STRICT JSON only: {"voice_design_prompt":""}/no-think' ), @@ -1345,11 +1847,11 @@ async def character_generate_prompts(request: Request): " Task: Generate a Character portrait.\n" " Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established " "content. Include every relevant visual fact from the profile.\n" - " Context: a believable live-action character in the book's established original world and era.\n" + " Context: a believable live-action character in the book's established original world and era; if the book context says medieval, use historically plausible medieval fantasy clothing and materials only.\n" " Style: photorealistic cinematic character photography, as a premium live-action fantasy-film portrait; natural skin texture and pores, anatomically plausible face and hands, real cloth, leather and metal materials, and no illustration, painting, anime, game render, or plastic skin.\n" " Composition: a simple square headshot/bust on a subdued neutral studio background, facing forward with a subtle in-character expression.\n" " Lighting: flattering neutral photographic key light with soft rim separation, realistic shadow falloff, and no stylized color cast.\n" - " Constraints: preserve the exact gender, age impression, species traits, face, hairstyle, garment layers and signature details established in the profile; no existing game references, logos, readable labels, floating fragments, or watermark.\n" + " Constraints: preserve the exact gender, age impression, species traits, face, hairstyle, garment layers and signature details established in the profile; no modern shirts, jeans, business suits, neckties, wristwatches, firearms, zippers, synthetic sportswear, 1950s hats, or other anachronistic items unless explicitly established by the book; no existing game references, logos, readable labels, floating fragments, or watermark.\n" " Output: one production-ready square live-action character portrait.\n\n" 'Respond with STRICT JSON only: {"image_prompt":""}/no-think' ), @@ -1359,22 +1861,31 @@ async def character_generate_prompts(request: Request): + visual_identity_rule + "Produce exactly this field:\n" "- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC " - "reference sheet, written as labelled clauses in this EXACT order — Subject, Context, Style, " + "reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, " "Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph " "(not a list). You MUST use the following STRICT stylistic constraints for the clauses, but replace the Subject and Prop facts with the current character profile:\n" - " Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established " - "content. Include every relevant visual fact from the profile. For each prop/accessory, state its exact count.\n" - " Context: a costume and prop handoff reference for the book's established original world and era.\n" - " Style: refined traditional pencil-and-ink concept sketch with restrained watercolor washes, clear silhouette and costume construction, grounded materials, visible linework, and believable proportions; intentionally illustrative, not photorealistic and not cartoonish.\n" - " Composition: full-body front, side, and back views across the top; below them, one dialogue pose and a single clean callout row containing exactly [list exactly the props/accessories from the profile] on a muted parchment landscape board.\n" - " Lighting: neutral studio reference lighting with gentle shading that keeps every construction detail readable.\n" - " Constraints: consistent face, silhouette, garment layers, [prop specifics], and palette in every view; no duplicate gear, no existing game references, logos, readable labels, floating fragments, extra limbs, or watermark.\n" - " Output: one production-ready 3:2 NPC reference sheet.\n\n" + " Task: create a three-view character continuity sheet with a source-grounded story moment and optional verified prop callouts.\n" + " Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY established profile content. Include age, species, face, skin, hair, eyes, facial hair, marks, build, and established worn clothing. Inventory does NOT prove that clothing or armor is worn. Include only portable props explicitly established by the profile; omit uncertain and inferred objects. Never copy the herbalist/cartographer examples.\n" + " Context: state the book's profile-derived genre, setting, era, and the production purpose of the sheet.\n" + " Style: retain the reference style principles — detailed hand-painted or painterly game concept art, grounded leather/metal/cloth/wood materials, clear readable shape language, and coherent wear — but choose the visual medium and palette treatment appropriate to this book's profile.\n" + " Composition: aligned full-body strict front, true left-side profile, and strict back views across the top; below them, one representative action/dialogue/work scene supported by the book evidence and zero to three isolated portable props. Keep empty prop slots empty. Use generous safe margins and keep every head, foot, hand, and object fully visible.\n" + " Lighting: use warm-neutral or neutral studio light that reveals material texture, with soft contact shadows and no cinematic color cast unless the profile or book context explicitly requires one.\n" + " Constraints: same exact gender, species, face identity anchor, body proportions, silhouette, garment seams, colors, and wear in every view; each prop appears once; choose at most one item from each visual category so several swords or near-identical tools cannot fill the row; no invented gear, duplicate gear, disassembled fragments, floating objects, extra limbs, generated text, logos, real-world emblems, franchise references, or watermark.\n" + " Output: one organized, production-ready square character reference sheet assembled from separately controlled panels.\n\n" 'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think' ), } requested = [f for f in (data.get("fields") or []) if f in all_groups] - field_groups = [((f,), all_groups[f]) for f in (requested or all_groups.keys())] + wanted = requested or list(all_groups.keys()) + # Camera, lighting, layout, artist and period are fixed across the cast; + # only structured character facts vary. This is intentionally + # deterministic instead of asking the LLM to redesign each production. + deterministic = {} + if "image_prompt" in wanted: + deterministic["image_prompt"] = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style) + if "concept_art_prompt" in wanted: + deterministic["concept_art_prompt"] = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style) + field_groups = [((f,), all_groups[f]) for f in wanted if f not in deterministic] # The SillyTavern card is a full structured card + scenario + first # message now, not a few short labelled lines — needs a much bigger # budget than the other three (single-paragraph) prompt fields or it @@ -1425,7 +1936,7 @@ async def character_generate_prompts(request: Request): results = await asyncio.gather(*[ asyncio.to_thread(_call_group, fields, system) for fields, system in field_groups ]) - out = {} + out = dict(deterministic) for (fields, _), result in zip(field_groups, results): for f in fields: out[f] = str(result.get(f) or "").strip() @@ -1586,7 +2097,8 @@ def _apply_character_image_rendering_style(prompt: str, image_kind: str) -> str: def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str, - prompt_field: str, output_node_id: str, prompt: str) -> str: + prompt_field: str, output_node_id: str, prompt: str, + seed_key: str = "") -> str: """Submits a pre-exported ComfyUI API-format workflow with the given prompt text injected into one designated node/field, waits for it to finish, and returns the resulting image as a data: URI. @@ -1611,6 +2123,19 @@ def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str, graph = copy.deepcopy(workflow) graph[prompt_node_id].setdefault("inputs", {})[prompt_field or "text"] = prompt + # Bundled diffusion graphs used to ship with seed 0 for every character. + # Combined with recurring fantasy terms, that repeatedly selected the + # same unwanted broad-brimmed-hat composition across the cast. Derive a + # stable prompt-specific seed instead: regenerating an unchanged prompt + # remains reproducible while different identities no longer share one + # latent stereotype. + prompt_seed = int.from_bytes( + hashlib.sha256(("voice-creator-image-v5|" + (seed_key or prompt)).encode("utf-8")).digest()[:8], + "big", + ) + for node in graph.values(): + if node.get("class_type") == "KSampler" and "seed" in node.get("inputs", {}): + node["inputs"]["seed"] = prompt_seed client_id = str(uuid.uuid4()) base = comfyui_url.rstrip("/") @@ -1675,6 +2200,261 @@ def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str, return f"data:{mime};base64,{b64}" +def _comfyui_upload_data_uri(comfyui_url: str, data_uri: str) -> str: + """Upload a browser-stored identity image into ComfyUI's input folder.""" + if not data_uri or not data_uri.startswith("data:"): + raise HTTPException(400, "A valid identity image is required") + try: + header, encoded = data_uri.split(",", 1) + raw = base64.b64decode(encoded) + except Exception as exc: + raise HTTPException(400, f"Identity image is not valid base64: {exc}") + mime = header.split(";", 1)[0].split(":", 1)[-1].lower() + ext = ".jpg" if mime in {"image/jpeg", "image/jpg"} else ".webp" if mime == "image/webp" else ".png" + filename = f"voice_creator_identity_{uuid.uuid4().hex}.png" + try: + resp = requests.post( + f"{comfyui_url.rstrip('/')}/upload/image", + files={"image": (filename, raw, mime or "image/png")}, + data={"overwrite": "true", "type": "input"}, + timeout=60, + ) + if not resp.ok: + raise HTTPException(resp.status_code, f"ComfyUI rejected the identity image: {resp.text[:400]}") + result = resp.json() + return str(result.get("name") or filename) + except HTTPException: + raise + except requests.exceptions.RequestException as exc: + raise HTTPException(502, f"Cannot upload the identity image to ComfyUI: {exc}") + + +def _pose_guide_data_uri(view: str) -> str: + """Create a neutral mannequin guide for Qwen's second reference image. + + Text-only camera directions were not reliable enough: a front-facing + identity frequently stayed front-facing even when the prompt requested a + side or rear view. The guide carries orientation only; the first Qwen + image remains the identity/costume authority. + """ + width, height = 512, 1024 + image = Image.new("RGB", (width, height), (245, 241, 233)) + draw = ImageDraw.Draw(image) + ink = (76, 79, 82) + shade = (151, 155, 158) + floor = (205, 199, 188) + draw.line((74, 930, 438, 930), fill=floor, width=4) + if view == "side": + draw.ellipse((222, 72, 282, 162), fill=ink) + draw.polygon([(222, 105), (205, 115), (222, 126)], fill=ink) # nose points left + draw.rectangle((238, 154, 268, 194), fill=ink) + draw.rounded_rectangle((215, 185, 285, 565), radius=30, fill=shade, outline=ink, width=5) + draw.rounded_rectangle((216, 220, 246, 610), radius=14, fill=ink) + draw.rounded_rectangle((252, 220, 282, 610), radius=14, fill=(105, 108, 111)) + draw.rounded_rectangle((220, 545, 252, 900), radius=15, fill=ink) + draw.rounded_rectangle((255, 545, 287, 900), radius=15, fill=(105, 108, 111)) + draw.ellipse((192, 884, 252, 924), fill=ink) + draw.ellipse((226, 884, 288, 924), fill=(105, 108, 111)) + else: + draw.ellipse((211, 72, 301, 162), fill=ink) + draw.rectangle((233, 154, 279, 200), fill=ink) + draw.polygon([(190, 195), (322, 195), (302, 570), (210, 570)], fill=shade, outline=ink) + draw.rounded_rectangle((145, 205, 205, 625), radius=25, fill=ink) + draw.rounded_rectangle((307, 205, 367, 625), radius=25, fill=ink) + draw.rounded_rectangle((206, 548, 252, 900), radius=20, fill=ink) + draw.rounded_rectangle((260, 548, 306, 900), radius=20, fill=ink) + draw.ellipse((170, 880, 252, 925), fill=ink) + draw.ellipse((260, 880, 342, 925), fill=ink) + if view == "front": + draw.ellipse((232, 108, 240, 116), fill=(230, 230, 230)) + draw.ellipse((272, 108, 280, 116), fill=(230, 230, 230)) + else: # rear guide: visible center-back seam, no facial marks + draw.line((256, 198, 256, 565), fill=ink, width=5) + out = io.BytesIO() + image.save(out, format="PNG") + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") + + +def _contain_image(raw: bytes, width: int, height: int, padding: int = 12) -> Image.Image: + panel = Image.new("RGB", (width, height), (246, 242, 234)) + with Image.open(io.BytesIO(raw)) as source: + fitted = ImageOps.contain(source.convert("RGB"), (max(1, width - 2 * padding), max(1, height - 2 * padding)), Image.Resampling.LANCZOS) + panel.paste(fitted, ((width - fitted.width) // 2, (height - fitted.height) // 2)) + return panel + + +def _compose_character_reference(images: dict[str, bytes], prop_count: int) -> str: + """Assemble panels without cropping or asking the model to draw a layout.""" + canvas = Image.new("RGB", (1536, 1536), (246, 242, 234)) + draw = ImageDraw.Draw(canvas) + with Image.open(io.BytesIO(images["turnaround"])) as source: + turnaround = ImageOps.fit(source.convert("RGB"), (1536, 1024), Image.Resampling.LANCZOS) + # The model receives an explicit equal-thirds triptych. Crop only those + # fixed panel boundaries; every generated person must already fit inside + # their third with safe head/foot margins. + for index in range(3): + raw_panel = turnaround.crop((index * 512, 0, (index + 1) * 512, 1024)) + buffer = io.BytesIO() + raw_panel.save(buffer, format="PNG") + panel = _contain_image(buffer.getvalue(), 512, 1024, 8) + canvas.paste(panel, (index * 512, 0)) + + if prop_count == 0: + scene_width = 1536 + elif prop_count == 1: + scene_width = 1024 + else: + scene_width = 768 + scene = _contain_image(images["scene"], scene_width, 512, 12) + canvas.paste(scene, (0, 1024)) + remaining = 1536 - scene_width + if prop_count: + tile_width = remaining // prop_count + for index in range(prop_count): + width = remaining - tile_width * index if index == prop_count - 1 else tile_width + panel = _contain_image(images[f"prop{index + 1}"], width, 512, 18) + x = scene_width + tile_width * index + canvas.paste(panel, (x, 1024)) + draw.line((x, 1024, x, 1536), fill=(205, 198, 186), width=3) + draw.line((0, 1024, 1536, 1024), fill=(205, 198, 186), width=4) + draw.line((512, 0, 512, 1024), fill=(218, 212, 202), width=2) + draw.line((1024, 0, 1024, 1024), fill=(218, 212, 202), width=2) + out = io.BytesIO() + canvas.save(out, format="PNG", optimize=True) + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") + + +def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, + identity_image: str, profile_prompt: str, + front_prompt: str, side_prompt: str, + back_prompt: str, scene_prompt: str, + prop_prompts: list[str], style_key: str = "") -> dict: + """Generate independently controlled views, scene and unique prop tiles.""" + try: + workflow = json.loads(workflow_json) + except Exception as exc: + raise HTTPException(400, f"Consistent-image workflow isn't valid JSON: {exc}") + graph = copy.deepcopy(workflow) + graph["4"].setdefault("inputs", {})["image"] = _comfyui_upload_data_uri(comfyui_url, identity_image) + graph["66"].setdefault("inputs", {})["text"] = front_prompt + graph["70"].setdefault("inputs", {})["text"] = scene_prompt + # Disconnect the failed image-edit camera branches. Structural camera and + # action changes are handled by native T2I branches below. + for old_output in ("front_save", "side_save", "back_save", "scene_save"): + graph.pop(old_output, None) + prop_prompts = prop_prompts[:3] + for index, (prompt_node, save_node) in enumerate((("23", "prop1_save"), ("50", "prop2_save"), ("54", "prop3_save"))): + if index < len(prop_prompts): + graph[prompt_node].setdefault("inputs", {})["text"] = prop_prompts[index] + else: + # A disconnected branch is not executed by ComfyUI. Empty slots + # therefore stay truly empty instead of becoming invented props. + graph.pop(save_node, None) + # Stable view-specific seeds keep the cast reproducible without forcing + # front, side and rear edits toward the same latent camera pose. + if style_key: + digest = hashlib.sha256(style_key.encode("utf-8")).digest() + shared_seed = int.from_bytes(digest[:8], "big") + else: + shared_seed = int(uuid.uuid4().int % 18446744073709551615) + def view_seed(label: str) -> int: + return int.from_bytes( + hashlib.sha256(f"{shared_seed}|{label}".encode("utf-8")).digest()[:8], + "big", + ) + graph["68"].setdefault("inputs", {})["seed"] = view_seed("identity-turnaround") + graph["72"].setdefault("inputs", {})["seed"] = view_seed("source-scene") + for node_id, label in (("26", "prop-1"), ("52", "prop-2"), ("56", "prop-3")): + graph[node_id].setdefault("inputs", {})["seed"] = view_seed(label) + client_id = str(uuid.uuid4()) + base = comfyui_url.rstrip("/") + try: + resp = requests.post(f"{base}/prompt", json={"prompt": graph, "client_id": client_id}, timeout=30) + if not resp.ok: + detail = resp.text[:500] + try: + detail = resp.json().get("error", {}).get("message", detail) + except Exception: + pass + raise HTTPException(resp.status_code, f"ComfyUI rejected the consistent-image workflow: {detail}") + prompt_id = resp.json().get("prompt_id") + if not prompt_id: + raise HTTPException(502, "ComfyUI didn't return a prompt_id") + except HTTPException: + raise + except requests.exceptions.RequestException as exc: + raise HTTPException(502, f"Cannot reach ComfyUI at {base}: {exc}") + + # Qwen Image Edit runs the profile and the larger 3:2 sheet serially on + # this local GPU. At the current model/runtime speed a pair can exceed + # ten minutes; keep the HTTP request alive so a successful Comfy job is + # not reported as a misleading 502 just after it finishes. + deadline = time.time() + 1800 + history = None + while time.time() < deadline: + try: + hr = requests.get(f"{base}/history/{prompt_id}", timeout=10) + if hr.ok: + data = hr.json() + if prompt_id in data: + history = data[prompt_id] + status = history.get("status", {}) + if status.get("completed") is True or status.get("status_str") == "success": + break + if status.get("status_str") == "error": + msgs = status.get("messages", []) + raise HTTPException(502, f"ComfyUI consistent-image generation failed: {msgs[-1] if msgs else 'unknown error'}") + except HTTPException: + raise + except Exception: + pass + time.sleep(2) + if not history: + raise HTTPException(504, "ComfyUI consistent-image generation timed out after 30 minutes") + + raw_images: dict[str, bytes] = {} + output_nodes = [ + ("profile", "profile_save"), + ("turnaround", "turnaround_save"), + ("scene", "scene_t2i_save"), + ] + [(f"prop{index + 1}", f"prop{index + 1}_save") for index in range(len(prop_prompts))] + result: dict[str, str] = {} + for key, node_id in output_nodes: + images = (history.get("outputs", {}).get(node_id, {}) or {}).get("images") or [] + if not images: + raise HTTPException(502, f"ComfyUI output '{node_id}' produced no image") + img = images[0] + try: + vr = requests.get(f"{base}/view", params={ + "filename": img.get("filename", ""), "subfolder": img.get("subfolder", ""), + "type": img.get("type", "output"), + }, timeout=30) + vr.raise_for_status() + except Exception as exc: + raise HTTPException(502, f"Fetching the ComfyUI {key} image failed: {exc}") + raw_images[key] = vr.content + if key == "profile": + result["profile"] = f"data:{vr.headers.get('Content-Type', 'image/png')};base64,{base64.b64encode(vr.content).decode('ascii')}" + result["reference"] = _compose_character_reference(raw_images, len(prop_prompts)) + return result + + +def _bundled_consistent_comfyui_workflow() -> str: + path = Path(__file__).resolve().parent.parent / "workflows" / "comfyui-character-consistent-images.json" + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise HTTPException(500, f"Bundled consistent-image workflow is unavailable: {exc}") + + +def _bundled_character_identity_workflow() -> str: + path = Path(__file__).resolve().parent.parent / "workflows" / "comfyui-character-identity-v3.json" + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise HTTPException(500, f"Bundled character-identity workflow is unavailable: {exc}") + + @router.post("/api/character-image-from-url") async def character_image_from_url(request: Request): """Download an image from a user-supplied URL server-side and return it @@ -1743,6 +2523,87 @@ async def character_generate_image(request: Request): return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind) +@router.post("/api/character-generate-consistent-images") +async def character_generate_consistent_images(request: Request): + """Generate a matching profile portrait and character reference sheet. + + The identity image is face-cropped first so source backgrounds, extra + people, and unrelated props cannot leak into the result. The clean profile + output then becomes the identity source for the reference sheet, keeping + the face consistent across both deliverables. + """ + data = await request.json() + name = str(data.get("name") or "").strip() + book = str(data.get("book") or "").strip() + sheet: dict = data.get("sheet") or {} + saved_profile_prompt = str(data.get("profile_prompt") or data.get("image_prompt") or "").strip() + saved_reference_prompt = str(data.get("reference_prompt") or data.get("concept_art_prompt") or "").strip() + book_profile: dict = data.get("book_profile") or {} + visual_style = str(data.get("visual_style") or "cinematic").strip().lower() + if visual_style not in {"cinematic", "concept"}: + visual_style = "cinematic" + if not saved_profile_prompt or not saved_reference_prompt: + raise HTTPException(400, "Both a profile image prompt and a character reference prompt are required") + settings = _load_settings() + comfy_url = str(settings.get("comfyui_url") or "").strip() + if not comfy_url: + raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)") + # Rebuild the fixed production frame from structured data. Saved prompts + # stay usable as user-editable direction, but cannot override the book + # lock, headwear rule, camera, or turnaround layout. + portrait_master = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style) + sheet_master = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style) + # Prompts created before the standardized v3 masters may contain the old + # hats, modern clothing, variable layouts and conflicting art styles. Do + # not import those into the corrected render. Once a v3 prompt has been + # generated, user edits retain its marker and are honored here. + profile_direction = saved_profile_prompt if sheet.get("image_prompt_user_edited") is True and saved_profile_prompt.startswith( + "Task: Generate one historical-fantasy audiobook cast portrait." + ) else "" + reference_direction = saved_reference_prompt if sheet.get("concept_art_prompt_user_edited") is True and saved_reference_prompt.startswith( + "Task: Generate one production character turnaround and equipment reference sheet." + ) else "" + # The UI stores the full editable production masters. The local image + # encoder receives compact, layout-first versions so its limited context + # cannot truncate FRONT/SIDE/BACK or the period/headwear constraints. + profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts = _build_compact_character_render_prompts( + name, book, sheet, book_profile, visual_style, + profile_direction or portrait_master, + reference_direction or sheet_master, + ) + # Do not perpetuate the modern hats, clothing, drinks, or scenery present + # in a bad legacy portrait. Reuse is opt-in only after visual review. + identity_image = str(data.get("identity_image") or "").strip() if data.get("reuse_identity") is True else "" + if identity_image.startswith("/"): + # Library records store same-origin image URLs rather than embedding + # large data URIs. Resolve those URLs server-side before uploading the + # image to ComfyUI. + try: + local_image = requests.get(f"http://127.0.0.1:7890{identity_image}", timeout=30) + local_image.raise_for_status() + identity_image = "data:" + (local_image.headers.get("Content-Type") or "image/png") + ";base64," + base64.b64encode(local_image.content).decode("ascii") + except Exception as exc: + raise HTTPException(502, f"Could not load the existing identity image: {exc}") + # Each click gets a fresh latent family so a rejected composition is not + # reproduced forever. The same family key is shared by the identity, + # turnaround, scene and prop branches to preserve within-run cohesion. + generation_key = f"voice-creator-cast-v6|{book}|{visual_style}|{name}|{uuid.uuid4().hex}" + if not identity_image: + anchor = await asyncio.to_thread( + _comfyui_run, + comfy_url, _bundled_character_identity_workflow(), + "5", "text", "identity_save", profile_prompt, generation_key, + ) + identity_image = anchor + result = await asyncio.to_thread( + _comfyui_run_consistent_images, + comfy_url, _bundled_consistent_comfyui_workflow(), identity_image, + profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts, + generation_key, + ) + return result + + def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict: if provider == "openai": api_key = (keys.get("openai_image") or "").strip() diff --git a/routes/library.py b/routes/library.py index 003d014..58348e9 100644 --- a/routes/library.py +++ b/routes/library.py @@ -126,16 +126,18 @@ async def get_book_profile(book: str): @router.post("/api/book-profile") async def save_book_profile(request: Request): - from core.database import state_put + from core.database import state_get, state_put data = await request.json() book = str(data.get("book", "")).strip() if not book: raise HTTPException(400, "book is required") + previous = state_get(_book_profile_key(book), {}) or {} profile = { "genre": str(data.get("genre", "")).strip()[:200], "setting": str(data.get("setting", "")).strip()[:200], "era": str(data.get("era", "")).strip()[:200], "language": str(data.get("language", "")).strip()[:60], + "confirmed": bool(data.get("confirmed", previous.get("confirmed", False))), } state_put(_book_profile_key(book), profile) return {"ok": True, "profile": profile} diff --git a/scripts/export_all_audiobooks.py b/scripts/export_all_audiobooks.py new file mode 100644 index 0000000..5b2b2c0 --- /dev/null +++ b/scripts/export_all_audiobooks.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Resumable overnight export of the three DSA audiobooks, one MP3 per chapter.""" +from __future__ import annotations + +import io +import hashlib +import json +import os +import re +import subprocess +import sys +import time +import wave +from pathlib import Path +from urllib.parse import quote + +import requests + +BASE = "http://127.0.0.1:7890" +ROOT = Path(__file__).resolve().parents[1] +CACHE = ROOT / "config" / "audiobook_batch_cache" +PAUSE_SENTENCE_MS = 120 +PAUSE_PARAGRAPH_MS = 300 +CHUNK_LIMIT = 900 +BOOK_ORDER = [ + "Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", + "Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", + "Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", +] + +CHAPTER = re.compile( + r"(?im)(?:^|\n\s*\n|\n\s*)(?P
(?:(?:\d{1,3}|[ivxlcdm]+)[.)]?\s*)?" + r"(?:kapitel|chapter|prolog|epilog)\b[^\n]{0,80})" +) + + +def key(value: str) -> str: + return str(value or "").strip().casefold() + + +def safe(value: str, limit: int = 80) -> str: + value = re.sub(r"[^A-Za-z0-9ÄÖÜäöüß _.-]+", "", value).strip() + return re.sub(r"\s+", "_", value)[:limit] or "chapter" + + +def split_text(text: str) -> list[tuple[str, int]]: + """Return (text, pause-after-ms), preferring paragraph/sentence edges. + + The old exporter flattened whitespace and cut at commas/spaces, which made + the TTS engine restart in the middle of a sentence. A paragraph is kept as + one request whenever possible. Only an oversized paragraph is divided, + first at sentence endings and only as a last resort at a clause/word edge. + """ + raw = str(text or "").replace("\r\n", "\n").strip() + paragraphs = [re.sub(r"\s+", " ", p).strip() for p in re.split(r"\n\s*\n+", raw) if p.strip()] + result: list[tuple[str, int]] = [] + sentence_re = re.compile(r".+?(?:[.!?…]+[\"»”’']*|$)", re.S) + for paragraph in paragraphs: + if len(paragraph) <= CHUNK_LIMIT: + result.append((paragraph, PAUSE_PARAGRAPH_MS)) + continue + sentences = [s.strip() for s in sentence_re.findall(paragraph) if s.strip()] + pieces: list[str] = [] + current = "" + for sentence in sentences or [paragraph]: + if len(sentence) > CHUNK_LIMIT: + clauses = re.split(r"(?<=[,;:])\s+", sentence) + else: + clauses = [sentence] + for clause in clauses: + clause = clause.strip() + if not clause: + continue + joined = f"{current} {clause}".strip() if current else clause + if len(joined) <= CHUNK_LIMIT: + current = joined + else: + if current: + pieces.append(current) + current = clause + while len(current) > CHUNK_LIMIT: + cut = current.rfind(" ", 80, CHUNK_LIMIT + 1) + if cut <= 80: + cut = CHUNK_LIMIT + pieces.append(current[:cut].strip()) + current = current[cut:].strip() + if current: + pieces.append(current) + for index, piece in enumerate(pieces): + result.append((piece, PAUSE_PARAGRAPH_MS if index == len(pieces) - 1 else PAUSE_SENTENCE_MS)) + return result + + +def wav_pcm(body: bytes) -> tuple[bytes, wave._wave_params]: + with wave.open(io.BytesIO(body), "rb") as stream: + params = stream.getparams() + if params.sampwidth != 2: + raise RuntimeError(f"TTS returned non-16-bit WAV: {params}") + return stream.readframes(stream.getnframes()), params + + +def make_wav(raw: bytes, params: wave._wave_params) -> bytes: + output = io.BytesIO() + with wave.open(output, "wb") as stream: + stream.setnchannels(params.nchannels) + stream.setsampwidth(params.sampwidth) + stream.setframerate(params.framerate) + stream.writeframes(raw) + return output.getvalue() + + +def request_json(method: str, url: str, **kwargs): + kwargs.setdefault("timeout", 90) + for attempt in range(1, 4): + try: + response = requests.request(method, url, **kwargs) + if response.ok: + return response + if response.status_code < 500: + raise RuntimeError(f"{method} {url}: {response.status_code} {response.text[:300]}") + raise RuntimeError(f"server {response.status_code}: {response.text[:300]}") + except Exception: + if attempt == 3: + raise + time.sleep(attempt * 3) + + +def tts(voice: str, text: str) -> tuple[bytes, wave._wave_params]: + seed = int.from_bytes(hashlib.sha256(voice.encode("utf-8")).digest()[:8], "big") % 2147483647 + response = request_json( + "POST", f"{BASE}/api/tts-preview", + json={"voice": voice, "text": text, "response_format": "wav", "backend": "voice_clone", "seed": seed}, + timeout=180, + ) + return wav_pcm(response.content) + + +def chapter_parts(segments: list[dict]) -> list[dict]: + """Split segments at headings even when OCR put a heading inside a segment.""" + buckets: list[dict] = [{"title": "", "segments": []}] + for segment in segments: + text = str(segment.get("text") or "").strip() + if not text: + continue + matches = list(CHAPTER.finditer(text)) + if not matches: + buckets[-1]["segments"].append({**segment, "text": text}) + continue + prefix = text[:matches[0].start()].strip() + if prefix: + buckets[-1]["segments"].append({**segment, "text": prefix}) + for index, match in enumerate(matches): + if buckets[-1]["segments"] or buckets[-1]["title"]: + buckets.append({"title": match.group("header").strip(), "segments": []}) + else: + buckets[-1]["title"] = match.group("header").strip() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body = text[match.end():end].strip() + if body: + buckets[-1]["segments"].append({**segment, "text": body, "type": "narration", "speaker": "Narrator"}) + return [bucket for bucket in buckets if bucket["segments"]] + + +def voice_map(book: str) -> tuple[dict[str, str], str]: + characters = request_json("GET", f"{BASE}/api/characters").json().get("characters", []) + mapping: dict[str, str] = {} + for character in characters: + if character.get("book") != book or not character.get("voice"): + continue + voice = str(character["voice"]) + mapping[key(character.get("name"))] = voice + for alias in re.split(r"[,;|]", str((character.get("sheet") or {}).get("aliases") or "")): + if alias.strip(): + mapping[key(alias)] = voice + narrator = mapping.get("narrator") + if not narrator: + raise RuntimeError(f"{book}: no Narrator voice assignment found") + return mapping, narrator + + +def render_book(doc: dict) -> None: + book = doc["title"] + doc_id = doc["id"] + cast = request_json("GET", f"{BASE}/api/reader/docs/{doc_id}/scripts/cast").json() + mapping, narrator = voice_map(book) + buckets = chapter_parts(cast.get("segments", [])) + book_dir = CACHE / safe(book) + book_dir.mkdir(parents=True, exist_ok=True) + print(f"[{book}] {len(buckets)} chapter buckets", flush=True) + for chapter_index, bucket in enumerate(buckets, 1): + title = bucket["title"] or f"Chapter {chapter_index}" + chapter_dir = book_dir / f"{chapter_index:03d}_{safe(title, 48)}" + # New cache namespace: chunk boundaries changed from unsafe fixed + # character cuts to paragraph/sentence boundaries. + chunks_dir = chapter_dir / "chunks_v2_sentence_aware" + chunks_dir.mkdir(parents=True, exist_ok=True) + chunks: list[tuple[str, str, int]] = [] + fallback = 0 + for segment in bucket["segments"]: + speaker = key(segment.get("speaker") or "narrator") + voice = mapping.get(speaker, narrator) + if speaker not in mapping and speaker != "narrator": + fallback += 1 + for text, pause_ms in split_text(segment.get("text")): + chunks.append((voice, text, pause_ms)) + pcm_parts: list[bytes] = [] + params = None + for index, (voice, text, pause_ms) in enumerate(chunks): + chunk_path = chunks_dir / f"{index:05d}.pcm" + meta_path = chunks_dir / f"{index:05d}.json" + if chunk_path.is_file() and meta_path.is_file(): + pcm_parts.append(chunk_path.read_bytes()) + if params is None: + params = wave._wave_params(**json.loads(meta_path.read_text())) + continue + raw, current = tts(voice, text) + if params is None: + params = current + elif (current.nchannels, current.sampwidth, current.framerate, current.comptype, current.compname) != (params.nchannels, params.sampwidth, params.framerate, params.comptype, params.compname): + raise RuntimeError(f"{book} chapter {chapter_index}: WAV format changed mid-chapter") + chunk_path.write_bytes(raw) + meta_path.write_text(json.dumps({"nchannels": current.nchannels, "sampwidth": current.sampwidth, "framerate": current.framerate, "nframes": len(raw) // current.sampwidth // current.nchannels, "comptype": current.comptype, "compname": current.compname})) + pcm_parts.append(raw) + if (index + 1) % 25 == 0: + print(f"[{book}] chapter {chapter_index} {index + 1}/{len(chunks)} chunks", flush=True) + if not pcm_parts or params is None: + continue + merged_parts = [] + for i, part in enumerate(pcm_parts): + if i: + pause_ms = chunks[i - 1][2] + silence = b"\0" * int(params.framerate * pause_ms / 1000) * params.sampwidth * params.nchannels + merged_parts.append(silence) + merged_parts.append(part) + merged = b"".join(merged_parts) + wav = make_wav(merged, params) + encoded = request_json("POST", f"{BASE}/api/audio/encode-mp3", data=wav, timeout=180).content + filename = f"{safe(book, 45)} - {chapter_index:02d} - {safe(title, 55)}.mp3" + upload_url = f"{BASE}/api/audiobook-export/{quote(book, safe='')}/{quote(filename, safe='')}" + request_json("POST", upload_url, data=encoded, timeout=180) + (chapter_dir / "complete.json").write_text(json.dumps({"filename": filename, "title": title, "chunks": len(chunks), "narrator_fallbacks": fallback, "bytes": len(encoded)})) + print(f"[{book}] COMPLETE chapter {chapter_index}/{len(buckets)}: {filename} ({len(encoded) / 1048576:.1f} MiB)", flush=True) + + +def main() -> None: + docs = request_json("GET", f"{BASE}/api/reader/docs").json().get("docs", []) + by_title = {doc.get("title"): doc for doc in docs} + for title in BOOK_ORDER: + if title not in by_title: + raise RuntimeError(f"Book document not found: {title}") + render_book(by_title[title]) + (CACHE / "ALL_THREE_BOOKS_COMPLETE").write_text(time.strftime("%Y-%m-%dT%H:%M:%S%z")) + print("ALL THREE AUDIOBOOKS COMPLETE", flush=True) + if os.environ.get("RUN_IMAGE_SAMPLES") == "1": + print("Restarting Voice Creator before the ten image samples…", flush=True) + subprocess.run(["docker", "compose", "restart", "tts-voice-creator-clone-and-design-2"], cwd=ROOT, check=True) + time.sleep(3) + subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_character_image_samples.py")], cwd=ROOT, check=True) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("Interrupted; completed chunks remain cached for resume.", file=sys.stderr) + raise + except Exception as exc: + print(f"BATCH FAILED: {exc}", file=sys.stderr) + raise diff --git a/scripts/generate_character_image_samples.py b/scripts/generate_character_image_samples.py new file mode 100644 index 0000000..41be26c --- /dev/null +++ b/scripts/generate_character_image_samples.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Generate ten concept-sheet shared-identity character image pairs after export.""" +from __future__ import annotations + +import requests + +BASE = "http://127.0.0.1:7890" +TARGETS = [ + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Darrag"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Eolan"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Baronin Ira von Seewiesen"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Gamba"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Alrik von Blautann"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Andra"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Cindira"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Admiral Sanin"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Arthag"), + ("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Anshelm"), +] + + +def main() -> None: + all_chars = requests.get(f"{BASE}/api/characters", timeout=30).json()["characters"] + for book, name in TARGETS: + item = next(c for c in all_chars if c.get("book") == book and c.get("name") == name) + record = requests.get(f"{BASE}/api/characters/{item['id']}", timeout=30).json() + sheet = record.get("sheet") or {} + print(f"[{name}] generating concept profile + reference sheet", flush=True) + response = requests.post( + f"{BASE}/api/character-generate-consistent-images", + json={ + "profile_prompt": sheet.get("image_prompt", ""), + "reference_prompt": sheet.get("concept_art_prompt", ""), + "visual_style": "concept", + "identity_image": record.get("image", ""), + }, + # The local Qwen graph renders the portrait and 3:2 sheet in + # sequence; allow the app's 25-minute server-side deadline. + timeout=1800, + ) + response.raise_for_status() + result = response.json() + record["image"] = result["profile"] + record["sheet"] = {**sheet, "concept_art_image": result["reference"]} + saved = requests.put(f"{BASE}/api/characters/{item['id']}", json=record, timeout=60) + saved.raise_for_status() + print(f"[{name}] saved", flush=True) + print("TEN CHARACTER IMAGE SAMPLES COMPLETE", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/render_trial_chapters.mjs b/scripts/render_trial_chapters.mjs new file mode 100644 index 0000000..9c16609 --- /dev/null +++ b/scripts/render_trial_chapters.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** Render six dialogue-focused audiobook chapter trials through the live API. + * + * This deliberately reuses each saved character's assigned voice. Unknown + * one-off speakers use the book's Narrator voice rather than creating a new + * voice during a render. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const base = process.env.VOICE_CREATOR_URL || 'http://127.0.0.1:7890'; +const selections = [ + { cast: '/tmp/b1-cast.json', chapter: 6, slug: 'book-1-chapter-6' }, + { cast: '/tmp/b1-cast.json', chapter: 9, slug: 'book-1-chapter-9' }, + { cast: '/tmp/b2-cast.json', chapter: 5, slug: 'book-2-chapter-5' }, + { cast: '/tmp/b2-cast.json', chapter: 13, slug: 'book-2-chapter-13' }, + { cast: '/tmp/b3-cast.json', chapter: 4, slug: 'book-3-chapter-4' }, + { cast: '/tmp/b3-cast.json', chapter: 9, slug: 'book-3-chapter-9' }, +]; + +const outDir = '/tmp/voice-creator-trials'; +fs.mkdirSync(outDir, { recursive: true }); +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +const key = value => String(value || '').trim().toLocaleLowerCase('de-DE'); + +function chapterBounds(segments, chapter) { + const starts = []; + for (let i = 0; i < segments.length; i++) { + const match = String(segments[i].text || '').match(/(?:^|\s)(\d{1,2})\.\s*Kapitel\b/i); + if (match) starts.push({ index: i, chapter: Number(match[1]) }); + } + const startAt = starts.findIndex(s => s.chapter === chapter); + if (startAt < 0) throw new Error(`Chapter ${chapter} was not found`); + return [starts[startAt].index, starts[startAt + 1]?.index ?? segments.length]; +} + +function splitText(text, limit = 620) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + if (!clean) return []; + const pieces = []; + let remaining = clean; + while (remaining.length > limit) { + const candidates = [remaining.lastIndexOf('. ', limit), remaining.lastIndexOf('! ', limit), remaining.lastIndexOf('? ', limit), remaining.lastIndexOf(', ', limit), remaining.lastIndexOf(' ', limit)]; + const cut = Math.max(...candidates); + const end = cut > 80 ? cut + 1 : limit; + pieces.push(remaining.slice(0, end).trim()); + remaining = remaining.slice(end).trim(); + } + if (remaining) pieces.push(remaining); + return pieces; +} + +function wavData(buffer) { + const b = Buffer.from(buffer); + if (b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') throw new Error('TTS response was not a WAV file'); + let p = 12; + while (p + 8 <= b.length) { + const id = b.toString('ascii', p, p + 4); + const size = b.readUInt32LE(p + 4); + if (id === 'data') return b.subarray(p + 8, p + 8 + size); + p += 8 + size + (size % 2); + } + throw new Error('WAV data chunk was not found'); +} + +function wavFile(data) { + const header = Buffer.alloc(44); + header.write('RIFF', 0); header.writeUInt32LE(36 + data.length, 4); header.write('WAVEfmt ', 8); + header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); + header.writeUInt32LE(24000, 24); header.writeUInt32LE(48000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); + header.write('data', 36); header.writeUInt32LE(data.length, 40); + return Buffer.concat([header, data]); +} + +async function charactersFor(title) { + const response = await fetch(`${base}/api/characters`); + if (!response.ok) throw new Error(`Cannot read character library: ${response.status}`); + const all = (await response.json()).characters || []; + const relevant = all.filter(c => c.book === title); + const voices = new Map(); + for (const c of relevant) { + if (!c.voice) continue; + voices.set(key(c.name), c.voice); + for (const a of String(c.sheet?.aliases || '').split(/[,;|]/)) if (a.trim()) voices.set(key(a), c.voice); + } + return voices; +} + +async function tts(voice, text) { + const response = await fetch(`${base}/api/tts-preview`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voice, text, response_format: 'wav', backend: 'voice_clone' }), + }); + if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); + return wavData(await response.arrayBuffer()); +} + +async function render(selection) { + const cast = JSON.parse(fs.readFileSync(selection.cast, 'utf8')); + const [from, to] = chapterBounds(cast.segments, selection.chapter); + const voiceMap = await charactersFor(cast.title); + const narrator = voiceMap.get('narrator'); + if (!narrator) throw new Error(`${cast.title}: narrator has no voice`); + const chunks = []; + let fallbackCount = 0; + for (const segment of cast.segments.slice(from, to)) { + const speaker = key(segment.speaker || 'Narrator'); + const voice = voiceMap.get(speaker) || narrator; + if (!voiceMap.has(speaker) && speaker !== 'narrator') fallbackCount++; + for (const text of splitText(segment.text)) chunks.push({ voice, text }); + } + const chunkDir = path.join(outDir, selection.slug); + fs.mkdirSync(chunkDir, { recursive: true }); + let start = 0; + while (start < chunks.length && fs.existsSync(path.join(chunkDir, `${String(start).padStart(4, '0')}.pcm`))) start++; + console.log(`[${selection.slug}] chapter ${selection.chapter}, segments ${to - from}, requests ${chunks.length}, resume ${start}, narrator fallbacks ${fallbackCount}`); + for (let i = start; i < chunks.length; i++) { + let lastError; + for (let attempt = 1; attempt <= 3; attempt++) { + try { fs.writeFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`), await tts(chunks[i].voice, chunks[i].text)); lastError = null; break; } + catch (err) { lastError = err; console.warn(`[${selection.slug}] request ${i + 1}/${chunks.length} attempt ${attempt}: ${err.message}`); await pause(1500 * attempt); } + } + if (lastError) throw lastError; + if ((i + 1) % 10 === 0 || i + 1 === chunks.length) console.log(`[${selection.slug}] ${i + 1}/${chunks.length}`); + } + const silence = Buffer.alloc(24000 / 4 * 2); // 250 ms 24kHz / mono / 16-bit + const raw = chunks.map((_, i) => fs.readFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`))); + const wav = wavFile(Buffer.concat(raw.flatMap((x, i) => i ? [silence, x] : [x]))); + const file = `${selection.slug}.wav`; + fs.writeFileSync(path.join(outDir, file), wav); + const upload = await fetch(`${base}/api/audiobook-export/${encodeURIComponent(cast.title)}/${file}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav }); + if (!upload.ok) throw new Error(`Export upload failed: ${upload.status} ${await upload.text()}`); + console.log(`[${selection.slug}] complete ${(wav.length / 1024 / 1024).toFixed(1)} MiB ${await upload.text()}`); +} + +for (const selection of selections) await render(selection); +console.log('All six trial chapters are complete.'); diff --git a/scripts/run_voice_quality_pass.mjs b/scripts/run_voice_quality_pass.mjs new file mode 100644 index 0000000..5f9b245 --- /dev/null +++ b/scripts/run_voice_quality_pass.mjs @@ -0,0 +1,65 @@ +import { chromium } from '@playwright/test'; + +const base = process.env.TTSC_URL || 'http://localhost:7890'; +const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || '/snap/bin/chromium' }); +const page = await browser.newPage(); +page.on('console', msg => { if (msg.type() === 'error') console.error('[browser]', msg.text()); }); +await page.goto(base + '/#s-library', { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => typeof window.clGetAll === 'function' && typeof window._charAutoDesignVoice === 'function', null, { timeout: 30000 }); +await page.evaluate(async () => { + if (typeof window.loadVoiceLibrary === 'function') await window.loadVoiceLibrary(); +}); + +const targets = await page.evaluate(async () => { + const books = new Set([ + 'Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm', + 'Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung', + 'Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone', + ]); + const chars = await window.clGetAll(); + const voices = window._voices || []; + const ratings = new Map(voices.map(v => [v.id, Number(v.rating || 0)])); + return chars.filter(c => books.has(c.book) && c.voice && ratings.get(typeof c.voice === 'object' ? c.voice.id : c.voice) >= 1 && ratings.get(typeof c.voice === 'object' ? c.voice.id : c.voice) <= 2) + .map(c => { + const voiceId = typeof c.voice === 'object' ? c.voice.id : c.voice; + const voice = voices.find(v => v.id === voiceId) || {}; + return { id: c.id, book: c.book, name: c.name, rating: ratings.get(voiceId), feedback: String(voice.note || '').trim() }; + }); +}); + +console.log(`Quality pass starting for ${targets.length} voices`); +let done = 0; +for (const target of targets) { + const result = await page.evaluate(async (target) => { + const all = await window.clGetAll(); + const rec = all.find(c => c.id === target.id); + if (!rec) throw new Error('Character no longer exists'); + const sheet = rec.sheet || {}; + const sample = [sheet.physical, sheet.backstory, sheet.motivation].filter(Boolean).join(' '); + const language = typeof window.detectLang === 'function' && sample ? (window.detectLang(sample) || '') : ''; + const llm = typeof window.statusLlmTarget === 'function' ? window.statusLlmTarget() : { url: '', model: '' }; + const promptResponse = await fetch('/api/character-generate-prompts', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet, voice_feedback: target.feedback || '', language, llm_url: llm.url, model: llm.model, fields: ['voice_design_prompt'] }), + }); + if (!promptResponse.ok) throw new Error('Prompt generation: ' + promptResponse.status + ' ' + promptResponse.statusText); + const promptData = await promptResponse.json(); + const prompt = String(promptData.voice_design_prompt || '').trim(); + if (!prompt) throw new Error('Empty regenerated prompt'); + rec.sheet = rec.sheet || {}; + rec.sheet.voice_design_prompt = prompt; + rec.updated = new Date(); + await window.clPut(rec); + await window._charAutoDesignVoice(rec, true, prompt); + return { name: rec.name, prompt }; + }, target); + done += 1; + console.log(`[${done}/${targets.length}] ${target.rating}★ ${target.book} — ${result.name}`); +} +console.log('Quality pass complete'); +await page.evaluate(async () => { + const r = await fetch('/api/tts/restart', { method: 'POST' }); + if (!r.ok) throw new Error('TTS restart failed: ' + r.status); +}); +console.log('TTS backend restarted; redesigned audio cache flushed'); +await browser.close(); diff --git a/static/dist/main.min.js b/static/dist/main.min.js index e9580ed..a17b169 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,_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`
+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,_wc,_xc;(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)} @@ -1156,7 +1156,7 @@ ${lines.trim()} `:" ")+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)+")")}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}setTimeout(function(){const sel=$("reader-emotion-select");!sel||typeof window.REH_EMOTIONS=="undefined"||(window.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)}}))},0);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 +`);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}setTimeout(function(){const sel=$("reader-emotion-select");!sel||typeof window.REH_EMOTIONS=="undefined"||(window.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)}}))},0);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,typeof window.studioRememberBook=="function"&&window.studioRememberBook(id,rec.title),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
@@ -1483,7 +1483,7 @@ ABSOLUTE REGELN: - Alle Segmente zusammen M\xDCSSEN den Originaltext exakt, l\xFCckenlos und wortgetreu rekonstruieren. - Erfinde NIEMALS Text. Lasse NIEMALS W\xF6rter weg. F\xFCge NIEMALS etwas hinzu. - 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.`;window._abVerificationPrompt=verificationPrompt;const runVerificationPass=()=>{const 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),runRepairNarration=()=>{const segs=_audiobook.segments||[],n=typeof _abSuspectNarration=="function"?_abSuspectNarration(segs).size:0;if(!n){toast("No narration lines look like mislabeled dialogue","success");return}toast(`Re-checking ${n} narration line${n!==1?"s":""} that look like spoken dialogue\u2026`,"info"),audiobookRecastUnknown(null,null,{repairNarration:!0,prompt:verificationPrompt})},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. +- 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.`;window._abVerificationPrompt=verificationPrompt;const runVerificationPass=()=>{const 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()=>{var _a4;const bookTitle2=((_a4=window.readerState)==null?void 0:_a4.title)||"";bookTitle2&&typeof window.openBookContextEditor=="function"&&!(typeof _getBookProfile=="function"?await _getBookProfile(bookTitle2):{}).confirmed&&!await window.openBookContextEditor(bookTitle2)||!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),runRepairNarration=()=>{const segs=_audiobook.segments||[],n=typeof _abSuspectNarration=="function"?_abSuspectNarration(segs).size:0;if(!n){toast("No narration lines look like mislabeled dialogue","success");return}toast(`Re-checking ${n} narration line${n!==1?"s":""} that look like spoken dialogue\u2026`,"info"),audiobookRecastUnknown(null,null,{repairNarration:!0,prompt:verificationPrompt})},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:"1. Identify all characters",title:"Scan the text and build the cast list from scratch",onClick:runIdentifyAll,danger:!0},{icon:"mdi-auto-fix",label:"2. Auto-repair cast (2.1\u20132.5)",title:"Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed",onClick:()=>audiobookAutoRepairCast()},{icon:"mdi-comment-alert-outline",label:"2.1 Repair mislabeled dialogue",title:"Finds spoken lines that were typed as narration (usually because the \xBB \xAB quote marks were lost in extraction) and re-checks only those \u2014 far cheaper than verifying every narration line",onClick:runRepairNarration},{icon:"mdi-account-question-outline",label:"2.2 Identify unknown characters",title:"Re-scan only the unknown segments with the current prompt",onClick:runIdentifyUnknown},{icon:"mdi-repeat",label:"2.3 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)}},{icon:"mdi-shield-check-outline",label:"2.4 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:"2.5 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}])),window._abRunConsistencyPass=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:"Select characters to cast or recast roles",title:existing.length?"Choose character roles to generate or refresh":"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(_abHideCastingControl(),!(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=[],_abRepairSuspects=options.repairNarration?_abSuspectNarration(segs):null;for(let i=0;iac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang();let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel()),promptOverride=typeof options.prompt=="string"?options.prompt:null;!promptOverride&&options.repairNarration&&typeof window._abVerificationPrompt=="string"&&(promptOverride=window._abVerificationPrompt);const 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,_abHideCastingControl()}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);if(afterUnknownCount>beforeUnknownCount){segs.splice(0,segs.length,...originalSegments);const how=_audiobook.cancel?"Stopped run":"Quality run";view.note(`${how} rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`),toast(`${how} 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}{const _ph=_audiobookDropPhantomSpeakers(segs);_ph.dropped&&console.info("[recast] dropped",_ph.dropped,"phantom speaker line(s):",_ph.names.join(", "))}{const _ident=_audiobookConsolidateSpeakerAliases(segs);_ident.merged&&(console.info("[recast] consolidated",_ident.merged,"speaker label variant(s),",_ident.moved,"line(s)"),view.note(`Merged ${_ident.merged} duplicate character label${_ident.merged!==1?"s":""} (${_ident.moved} lines).`))}_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")}}const AB_AUTO_REPAIR_KEY="ab-auto-repair-enabled";function audiobookAutoRepairEnabled(){try{return localStorage.getItem(AB_AUTO_REPAIR_KEY)!=="0"}catch{return!0}}async function audiobookAutoRepairCast(threshold=10,maxRounds=4){if(!(_audiobook.segments||[]).length){toast("Nothing cast yet to repair","error");return}const countUnknown=()=>(_audiobook.segments||[]).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(`Cast looks clean \u2014 ${prev} unknown speaker${prev!==1?"s":""}`,"success");return}toast(`Auto-repair: ${prev} unknown speakers \u2014 running repair passes\u2026`,"info");for(let round=1;round<=maxRounds;round++){if(await audiobookRecastUnknown(null,null,{repairNarration:!0}),_audiobook.cancel){toast("Auto-repair stopped.","info");return}const now=countUnknown();if(now<=threshold){toast(`Auto-repair done: ${now} unknown left after ${round} round${round!==1?"s":""}.`,"success");return}if(now>=prev)break;prev=now}toast(`2.4 Still ${prev} unknown \u2014 running full verification pass\u2026`,"info"),await audiobookRecastUnknown(null,null,{includeNarrator:!0});const afterVerify=countUnknown();if(typeof window._abRunConsistencyPass=="function"&&!_audiobook.cancel){toast("2.5 Checking voice consistency\u2026","info");try{await window._abRunConsistencyPass()}catch(e){console.warn("[auto-repair 2.5]",e)}}const final=countUnknown();toast(final<=threshold?`Auto-repair done: ${final} unknown left.`:`Auto-repair finished: ${final} unknown remain (target was under ${threshold}).`,final<=threshold?"success":"error")}window.audiobookAutoRepairCast=audiobookAutoRepairCast;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,{repairNarration:!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),bookId2=_abBookId();let chosen=_localDraft,source="local",serverDraft=null;if(bookId2){try{serverDraft=await _abLoadDraftServer(bookId2)}catch{}if(serverDraft&&(serverDraft.savedAt||0)>(_localDraft.savedAt||0)){chosen=serverDraft,source="server";try{localStorage.setItem(_abDraftKey(bookId2),JSON.stringify(serverDraft))}catch{}}}_applyDraft(chosen,view,source),bookId2&&source==="local"&&(!serverDraft||(_localDraft.savedAt||0)>(serverDraft.savedAt||0))&&fetch(`/api/reader/docs/${encodeURIComponent(bookId2)}/scripts/cast`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({..._localDraft,bookId:bookId2,title:((_a2=window.readerState)==null?void 0:_a2.title)||_localDraft.title||""})}).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()+` @@ -1495,7 +1495,7 @@ ${preview}`,{title:"Clean up character library?",okLabel:`Remove ${toDelete.leng `+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();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=`
+`}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 _abSynthesizeLongText(voice,text,instruct,backend){const pieces=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,900):[text];if(pieces.length<=1)return fetchTtsPreviewBlob(voice,text,"wav",instruct,backend);const blobs=[];for(const piece of pieces)blobs.push(await fetchTtsPreviewBlob(voice,piece,"wav",instruct,backend));return mergeWavBlobs(blobs,120)}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 _abSynthesizeLongText(voice,text,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.

@@ -1823,4 +1823,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;const _CHAR_PROFILE_COMPLETENESS_FIELDS=["gender","age_estimate","race_species","languages","nationality_background","social_class","profession","title","archetype","physical","clothing","alignment","arc_direction","arc_note","attribute_high","attribute_low","skills","capabilities","backstory","relationships","motivation","fears","mannerisms","communication_style","voice_pattern","secret","conflict_style","win_condition","reputation","religious_beliefs","notes","inventory"],_CHAR_TABLE_COLUMN_DEFS=[["select","Select"],["avatar","Avatar"],["name","Name"],["gender","Gender"],["age","Age"],["lines","Lines"],["language","Language"],["alignment","Good / Evil"],["profile","Profile"],["voice","Voice"],["rating","Rating"],["prompt","Voice Design Prompt"],["notes","Notes"],["tags","Tags"],["book","Book / Script"],["export","Export"]];function _charTableHiddenColumns(){try{const saved=JSON.parse(localStorage.getItem("ttsvc_libchars_columns")||"{}");return saved&&typeof saved=="object"?saved:{}}catch{return{}}}function _applyCharTableColumns(table,hidden){table&&_CHAR_TABLE_COLUMN_DEFS.forEach(function(def,index){var _a2;const isHidden=hidden[def[0]]===!1;table.querySelectorAll("tr").forEach(function(row){const cell=row.children[index];cell&&cell.classList.toggle("lib-chars-col-hidden",isHidden)});const col=(_a2=table.querySelector("colgroup"))==null?void 0:_a2.children[index];col&&col.classList.toggle("lib-chars-col-hidden",isHidden)})}function _charProfileCompleteness(sheet){const sh=sheet||{};return{filled:_CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function(key){return _libStr(sh[key]).trim().length>0}).length,total:_CHAR_PROFILE_COMPLETENESS_FIELDS.length}}async function libraryRenderCharacters(){var _a2;const container=document.getElementById("lib-chars-list");if(!container)return;if((!Array.isArray(window._voices)||!window._voices.length)&&typeof loadVoiceLibrary=="function")try{await loadVoiceLibrary()}catch(e){console.warn("[characters] voice library load failed",e)}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"],["rating","Stimmenbewertung"],["profile","Profil vollst\xE4ndig"]],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||"")},rating:function(a,b){const idOf=function(r){return r.voice?typeof r.voice=="object"?r.voice.id:r.voice:""},ratingOf=function(r){var _a3;return Number(((_a3=(window._voices||[]).find(function(v){return v.id===idOf(r)}))==null?void 0:_a3.rating)||0)};return ratingOf(b)-ratingOf(a)||(a.name||"").localeCompare(b.name||"")},profile:function(a,b){const filledA=_charProfileCompleteness(a.sheet).filled;return _charProfileCompleteness(b.sheet).filled-filledA||(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 columnWrap=prod.querySelector(".lib-chars-column-chooser"),columnButton=columnWrap==null?void 0:columnWrap.querySelector(".lib-chars-column-button"),columnMenu=columnWrap==null?void 0:columnWrap.querySelector(".lib-chars-column-menu"),table=prod.querySelector(".lib-chars-tbl");if(columnWrap&&columnButton&&columnMenu&&table){const hiddenColumns=_charTableHiddenColumns();columnMenu.querySelectorAll("input[data-column-key]").forEach(function(input){input.checked=hiddenColumns[input.dataset.columnKey]!==!1,input.addEventListener("change",function(){hiddenColumns[input.dataset.columnKey]=input.checked,localStorage.setItem("ttsvc_libchars_columns",JSON.stringify(hiddenColumns)),_applyCharTableColumns(table,hiddenColumns)})}),_applyCharTableColumns(table,hiddenColumns),columnButton.addEventListener("click",function(e){e.stopPropagation(),columnMenu.classList.toggle("is-open")}),columnMenu.addEventListener("click",function(e){e.stopPropagation()})}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"),conceptBtn=prod.querySelector(".lib-chars-bulk-concept-btn"),conceptCount=prod.querySelector(".lib-chars-bulk-count-concept"),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)})}),conceptBtn.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(conceptBtn,ids,"Generating concept art",function(rec){return _charAutoGenerateConceptArt(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),prod.querySelectorAll(".lib-chars-tbl-rating-star").forEach(function(star){star.addEventListener("click",async function(e){e.stopPropagation();const voiceId=star.dataset.voiceId,voice=(window._voices||[]).find(function(v){return v.id===voiceId});if(!voice||typeof saveMeta!="function")return;const value=Number(star.dataset.rating||0);voice.rating=value===Number(voice.rating||0)?0:value,prod.querySelectorAll(".lib-chars-tbl-rating-star").forEach(function(btn){btn.dataset.voiceId===voiceId&&btn.classList.toggle("is-on",Number(btn.dataset.rating)<=voice.rating)});try{await saveMeta(voiceId,{rating:voice.rating})}catch(err){toast("Could not save voice rating: "+(err.message||err),"error")}})}),prod.querySelectorAll(".lib-chars-tbl-voice-note").forEach(function(input){let timer=null;input.addEventListener("click",function(e){e.stopPropagation()}),input.addEventListener("keydown",function(e){e.stopPropagation()}),input.addEventListener("input",function(){clearTimeout(timer),timer=setTimeout(async function(){const voiceId=input.dataset.voiceId,voice=(window._voices||[]).find(function(v){return v.id===voiceId});if(!(!voice||typeof saveMeta!="function")){voice.note=input.value;try{await saveMeta(voiceId,{note:voice.note})}catch(err){toast("Could not save voice note: "+(err.message||err),"error")}}},600)})}),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 _libGeneratedVoiceFileName(rec){const voiceId=rec!=null&&rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"";if(!voiceId)return"";const voice=(window._voices||[]).find(v=>v&&v.id===voiceId);return voice!=null&&voice.path&&String(voice.path).split(/[\\/]/).pop().trim()||""}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):"",voiceMeta=voiceId?(window._voices||[]).find(function(v){return v.id===voiceId}):null,voiceMissing=!!voiceId&&!_voiceExists(voiceId),voiceRating=Math.max(0,Math.min(5,Number((voiceMeta==null?void 0:voiceMeta.rating)||0))),rawVoiceNote=_libStr(voiceMeta==null?void 0:voiceMeta.note),voiceNote=/^voice design\s*:/i.test(rawVoiceNote)?"":rawVoiceNote,voicePrompt=_libStr((voiceMeta==null?void 0:voiceMeta.voice_design_prompt)||sh.voice_design_prompt),stars=voiceId?'
'+[1,2,3,4,5].map(function(n){return''}).join("")+"
":'\u2014',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 "),profile=_charProfileCompleteness(sh),profileClass=profile.filled<=8?"is-sparse":profile.filled<=20?"is-partial":"is-strong";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')+''+profile.filled+" / "+profile.total+'
'+(voiceId?'"+(voiceMissing?' ':"")+escHtml(voiceId)+"":'Keine Stimme')+'
'+(voiceId?'':"")+''+(voiceId?'':"")+'
'+stars+''+(voicePrompt?escHtml(voicePrompt):'\u2014')+''+(voiceId?'":'\u2014')+'
'+tagList.map(function(t){return''+escHtml(t)+""}).join("")+'
'+(bookLabel?escHtml(bookLabel):'\u2014')+''}).join("");return'
'+_CHAR_TABLE_COLUMN_DEFS.map(function(def){return'"}).join("")+"
"+'
'+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("profile","Profil (gef\xFCllt)","Sort by filled profile fields; the second number is always 32")+th("voice","Stimme")+th("rating","Rating","Rate the assigned voice")+''+rows+"
Voice Design PromptNotesTagsBook / 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||{},window._libDetailOpts=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")+function(){const fileName=_libGeneratedVoiceFileName(rec);return fileName?'
Voice file: '+escHtml(fileName)+"
":""}()+_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?'':"")+''+(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(e){e.preventDefault(),e.stopPropagation(),Promise.resolve(goBack()).catch(function(err){console.error("[character detail] back navigation failed",err),toast("Character list could not be opened","error")})}),(_a2=pg.querySelector(".lcd-avatar-upload"))==null||_a2.addEventListener("click",function(){_openAvatarLightbox(rec,function(){_charDetailPage(rec,allChars,opts)})}),(_b2=pg.querySelector(".lcd-conceptart-gen"))==null||_b2.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}}),(_c2=pg.querySelector(".lcd-conceptart-img, .lcd-conceptart-empty"))==null||_c2.addEventListener("click",function(){_openConceptArtLightbox(rec,function(){_charDetailPage(rec,allChars,opts)})}),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)})}),(_d2=pg.querySelector(".lcd-pick-voice"))==null||_d2.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)})}),(_e2=pg.querySelector(".lcd-auto-voice"))==null||_e2.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)}),(_f2=pg.querySelector(".lcd-online-voice"))==null||_f2.addEventListener("click",function(){_charSearchOnline(rec)}),(_g2=pg.querySelector(".lcd-gen-voice"))==null||_g2.addEventListener("click",function(){_charDesignVoiceInline(rec)}),(_h2=pg.querySelector(".lcd-clone-voice"))==null||_h2.addEventListener("click",function(){_charCloneVoice(rec)}),(_i2=pg.querySelector(".lcd-preview-voice"))==null||_i2.addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),_libPreviewCharVoice(rec,e.currentTarget)}),(_j2=pg.querySelector(".lcd-generated-voice-play"))==null||_j2.addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),_libPreviewCharVoice(rec,e.currentTarget)}),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)+'':'
'+escHtml((rec.name||"?")[0].toUpperCase())+"
")+'
',document.body.appendChild(ov),ov.addEventListener("click",function(e){e.target===ov&&ov.remove()}),ov.querySelector("#alb-close").addEventListener("click",function(){ov.remove()}),ov.querySelector("#alb-upload-btn").addEventListener("click",function(){ov.querySelector("#alb-file-input").click()}),ov.querySelector("#alb-online-focus-btn").addEventListener("click",function(){ov.querySelector("#alb-url-input").focus(),ov.querySelector("#alb-url-input").scrollIntoView({behavior:"smooth",block:"center"})});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 _voiceAudibleSpeciesCue(value){const v=String(value||"").toLowerCase();return/\b(ork|orc|orcs)\b/.test(v)?"Orc: chest-led, gravel-edged resonance; clipped consonants, forceful breath, fully intelligible.":/\b(vampir|vampire)\b/.test(v)?"Vampire: cool velvety timbre, precise diction, restrained predatory calm.":/\b(elf|elv|elfe)\b/.test(v)?"Elf: clear airy resonance, precise musical diction, adult and grounded.":/\b(d[aä]mon|demon|teufel|devil)\b/.test(v)?"Demon: dark resonant timbre with a subtle otherworldly undertone; fully intelligible.":/\b(zwerg|dwarf)\b/.test(v)?"Dwarf: compact earthy resonance, sturdy cadence, deliberate articulation.":/\b(fee|fae|geist|spirit|untot|undead)\b/.test(v)?"Otherworldly: unusual natural resonance, measured breath, emotionally believable.":""}function _voiceAudibleRoleCue(sheet){const v=[sheet.profession,sheet.title,sheet.archetype,sheet.social_class,sheet.capabilities].filter(Boolean).join(" ").toLowerCase();return/krieger|soldat|ritter|wache|kämpfer|warrior|soldier|knight|guard|general|anführer|commander/.test(v)?"Warrior/commander: grounded projection, disciplined breath, concise command cadence.":/schmied|blacksmith|handwerk|artisan|craft/.test(v)?"Craftsperson: practical work-worn texture, measured pace, plain-spoken certainty.":/magier|zauber|hexe|hexer|wizard|mage|sorcer|druid/.test(v)?"Magic-user: measured phrasing, precise diction, contained intensity.":/priester|inquis|mönch|monk|priest|cleric|prophet/.test(v)?"Religious authority: formal diction, calm conviction, weighted pauses.":/könig|königin|baron|graf|adel|noble|lord|lady|herrscher|herrscherin/.test(v)?"High-status authority: polished diction, composed pace, effortless command.":/dieb|spion|assassin|schurke|rogue|thief/.test(v)?"Covert operator: quiet confidence, economical phrasing, alert timing.":/gelehrt|scholar|lehrer|teacher|arzt|heiler|healer/.test(v)?"Learned professional: articulate, patient pace, observant and assured.":""}function _voiceIdentityPrompt(rec,sourcePrompt,langName){const sh=rec.sheet||{},rawGender=String(sh.gender||_genderFromGenericName(rec.name)||"").toLowerCase(),gender=rawGender.startsWith("f")?"female":rawGender.startsWith("m")?"male":rawGender.startsWith("non")?"androgynous/nonbinary":"",hardGender=gender?"Adult "+gender+" voice, clearly "+(gender==="female"?"feminine and never masculine-coded":gender==="male"?"masculine and never feminine-coded":"androgynous")+".":"",age=String(sh.age_estimate||"").trim(),ageCue=age?"Age: "+age.slice(0,36)+".":"",lang=String(langName||"").trim(),languageCue=lang?lang.toLowerCase()==="english"?"Neutral international English.":"Native "+lang+" delivery, never American-accented.":"",species=_voiceAudibleSpeciesCue([sh.race_species,sh.archetype,sh.profession,sh.aliases].filter(Boolean).join(" ")),role=_voiceAudibleRoleCue(sh),prefix=[hardGender,ageCue,languageCue,species,role,"Distinct natural audiobook voice; no imitation."].filter(Boolean).join(" "),base=String(sourcePrompt||"").replace(/\s+/g," ").trim(),room=Math.max(0,480-prefix.length-(prefix&&base?1:0));return(prefix+(base?" "+base.slice(0,room):"")).slice(0,480)}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=_voiceIdentityPrompt(rec,savedPrompt||_buildVoicePrompt(rec,null,lang),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),savedPrompt=_libStr(sh.voice_design_prompt).trim(),instruct=_voiceIdentityPrompt(rec,savedPrompt||_buildVoicePrompt(rec,null,lang),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(),rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,close(),toast("New voice designed for "+rec.name,"success");const detailPage=document.querySelector(".lib-char-page"),detailOpts=window._libDetailOpts;if(detailPage&&detailOpts&&typeof _charDetailPage=="function"){const all=typeof clGetAll=="function"?await clGetAll().catch(function(){return[]}):[],updated=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(updated,all.filter(function(r){return r.book===updated.book}),detailOpts)}else typeof libraryRenderCharacters=="function"&&await 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=_voiceIdentityPrompt(rec,instructOverride||_libStr(sh.voice_design_prompt).trim()||_buildVoicePrompt(rec,await _getBookProfile(rec.book),langName),langName);if(!instruct.trim())throw new Error("No character description to design a voice from yet");instructOverride&&(sh.voice_design_prompt=instruct);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,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,image_kind:"portrait"};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,image_kind:"concept_art"};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}function _openConceptArtLightbox(rec,onSaved){var _a2;(_a2=document.getElementById("conceptart-lightbox"))==null||_a2.remove();const sh=rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim(),ov=document.createElement("div");ov.id="conceptart-lightbox",ov.className="audiobook-overlay",ov.innerHTML='
'+escHtml(rec.name)+' \u2014 Konzeptbild
'+(sh.concept_art_image?'Concept art \u2014 '+escHtml(rec.name)+'':'
No concept art yet
')+'
',document.body.appendChild(ov);const close=function(){ov.remove()},status2=function(msg,cls){const el=ov.querySelector("#calb-status");el.textContent=msg||"",el.className="llm-active-status"+(cls?" "+cls:"")},setPreview=function(src){ov.querySelector(".conceptart-editor-preview").innerHTML='Concept art \u2014 '+escHtml(rec.name)+''},commit=async function(dataUri){const target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;target.sheet=Object.assign({},target.sheet||{},{concept_art_image:dataUri}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),rec.sheet=target.sheet,setPreview(dataUri),toast("Concept art saved","success"),typeof onSaved=="function"&&onSaved()};ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#calb-close").addEventListener("click",close),ov.querySelector("#calb-upload").addEventListener("click",function(){ov.querySelector("#calb-file").click()}),ov.querySelector("#calb-online").addEventListener("click",function(){ov.querySelector("#calb-url").focus(),ov.querySelector("#calb-url").scrollIntoView({behavior:"smooth",block:"center"})}),ov.querySelector("#calb-file").addEventListener("change",function(){const file=this.files[0];if(!file)return;const fr=new FileReader;fr.onload=function(e){commit(e.target.result).catch(function(err){toast("Upload failed: "+err.message,"error")})},fr.readAsDataURL(file)}),ov.querySelector("#calb-url-btn").addEventListener("click",async function(){const url=ov.querySelector("#calb-url").value.trim();if(!url){toast("Enter an image URL","error");return}const btn=this;btn.disabled=!0,status2("Downloading\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 commit(d.image),status2("\u2713 Downloaded","ok")}catch(e){status2("Failed","err"),toast("Download failed: "+e.message,"error")}finally{btn.disabled=!1}}),ov.querySelector("#calb-regenerate").addEventListener("click",async function(){const nextPrompt=ov.querySelector("#calb-prompt").value.trim();if(!nextPrompt){toast("Generate or enter a concept-art prompt first","error");return}const btn=this,orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026',status2("Generating\u2026");try{sh.concept_art_prompt=nextPrompt;const provider=ov.querySelector("#calb-provider").value,img=await _charAutoGenerateConceptArt(rec,provider);await commit(img),status2("\u2713 Generated","ok")}catch(e){status2("Failed","err"),toast("Generation failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig}})}window._charSearchOnline=_charSearchOnline,window._charDesignVoice=_charDesignVoice,window._charAutoDesignVoice=_charAutoDesignVoice,window._charAutoGenerateImage=_charAutoGenerateImage,window._charAutoGenerateConceptArt=_charAutoGenerateConceptArt,window._openConceptArtLightbox=_openConceptArtLightbox,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,_stuCastFootHealthTimer=null;function _stuCastIsRunning(){return typeof _audiobook!="undefined"&&!!_audiobook.running}function _stuSyncCastFallbackControls(tabs,innerTabs,hasActionFooter){const running=_stuCastIsRunning();tabs.hidden=!1,tabs.style.display="flex",innerTabs.forEach(function(tab){const hideFallback=running||hasActionFooter;tab.hidden=hideFallback,tab.style.display=hideFallback?"none":""})}function _stuRelocateCastFoot(){_stuTryRelocateCastFoot(),!_stuCastFootObserver&&(_stuCastFootObserver=new MutationObserver(function(){_stuTryRelocateCastFoot()}),_stuCastFootObserver.observe(document.body,{childList:!0,subtree:!0}),[0,100,300,800,1500].forEach(ms=>setTimeout(_stuTryRelocateCastFoot,ms)),_stuCastFootHealthTimer=setInterval(function(){_stuIsActive&&_stuActive===2&&_stuTryRelocateCastFoot()},500))}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")||document.querySelector("#ab-cv-foot"),innerTabs=Array.from(tabs.querySelectorAll(":scope > .stu-inner-tab")),footerHasActions=freshFoot&&freshFoot.querySelector("#ab-cv-menu-identify, #ab-cv-menu-cast, #ab-cv-menu-viewcast");if(_stuSyncCastFallbackControls(tabs,innerTabs,!!footerHasActions),!freshFoot||!footerHasActions)return;if(freshFoot.parentElement===tabs){freshFoot.hidden=!1,freshFoot.style.display="flex";return}tabs.querySelectorAll("#ab-cv-foot").forEach(function(stale){stale.remove()}),tabs.hidden=!1,tabs.style.display="flex",freshFoot.style.borderTop="none",freshFoot.style.padding="0",freshFoot.style.justifyContent="flex-start",freshFoot.hidden=!1,freshFoot.style.display="flex",tabs.appendChild(freshFoot);const openReh=freshFoot.querySelector("#ab-cv-open-reh");openReh&&(openReh.hidden=!0)}function _stuRestoreCastFoot(){_stuCastFootObserver&&(_stuCastFootObserver.disconnect(),_stuCastFootObserver=null),_stuCastFootHealthTimer&&(clearInterval(_stuCastFootHealthTimer),_stuCastFootHealthTimer=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; +`),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;const _CHAR_PROFILE_COMPLETENESS_FIELDS=["gender","age_estimate","race_species","languages","nationality_background","social_class","profession","title","archetype","physical","clothing","alignment","arc_direction","arc_note","attribute_high","attribute_low","skills","capabilities","backstory","relationships","motivation","fears","mannerisms","communication_style","voice_pattern","secret","conflict_style","win_condition","reputation","religious_beliefs","notes","inventory"],_CHAR_TABLE_COLUMN_DEFS=[["select","Select"],["avatar","Avatar"],["name","Name"],["gender","Gender"],["age","Age"],["lines","Lines"],["language","Language"],["alignment","Good / Evil"],["profile","Profile"],["voice","Voice"],["rating","Rating"],["prompt","Voice Design Prompt"],["notes","Notes"],["tags","Tags"],["book","Book / Script"],["export","Export"]];function _charTableHiddenColumns(){try{const saved=JSON.parse(localStorage.getItem("ttsvc_libchars_columns")||"{}");return saved&&typeof saved=="object"?saved:{}}catch{return{}}}function _applyCharTableColumns(table,hidden){table&&_CHAR_TABLE_COLUMN_DEFS.forEach(function(def,index){var _a2;const isHidden=hidden[def[0]]===!1;table.querySelectorAll("tr").forEach(function(row){const cell=row.children[index];cell&&cell.classList.toggle("lib-chars-col-hidden",isHidden)});const col=(_a2=table.querySelector("colgroup"))==null?void 0:_a2.children[index];col&&col.classList.toggle("lib-chars-col-hidden",isHidden)})}function _charProfileCompleteness(sheet){const sh=sheet||{};return{filled:_CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function(key){return _libStr(sh[key]).trim().length>0}).length,total:_CHAR_PROFILE_COMPLETENESS_FIELDS.length}}async function libraryRenderCharacters(){var _a2;const container=document.getElementById("lib-chars-list");if(!container)return;if((!Array.isArray(window._voices)||!window._voices.length)&&typeof loadVoiceLibrary=="function")try{await loadVoiceLibrary()}catch(e){console.warn("[characters] voice library load failed",e)}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"],["rating","Stimmenbewertung"],["profile","Profil vollst\xE4ndig"]],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||"")},rating:function(a,b){const idOf=function(r){return r.voice?typeof r.voice=="object"?r.voice.id:r.voice:""},ratingOf=function(r){var _a3;return Number(((_a3=(window._voices||[]).find(function(v){return v.id===idOf(r)}))==null?void 0:_a3.rating)||0)};return ratingOf(b)-ratingOf(a)||(a.name||"").localeCompare(b.name||"")},profile:function(a,b){const filledA=_charProfileCompleteness(a.sheet).filled;return _charProfileCompleteness(b.sheet).filled-filledA||(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 columnWrap=prod.querySelector(".lib-chars-column-chooser"),columnButton=columnWrap==null?void 0:columnWrap.querySelector(".lib-chars-column-button"),columnMenu=columnWrap==null?void 0:columnWrap.querySelector(".lib-chars-column-menu"),table=prod.querySelector(".lib-chars-tbl");if(columnWrap&&columnButton&&columnMenu&&table){const hiddenColumns=_charTableHiddenColumns();columnMenu.querySelectorAll("input[data-column-key]").forEach(function(input){input.checked=hiddenColumns[input.dataset.columnKey]!==!1,input.addEventListener("change",function(){hiddenColumns[input.dataset.columnKey]=input.checked,localStorage.setItem("ttsvc_libchars_columns",JSON.stringify(hiddenColumns)),_applyCharTableColumns(table,hiddenColumns)})}),_applyCharTableColumns(table,hiddenColumns),columnButton.addEventListener("click",function(e){e.stopPropagation(),columnMenu.classList.toggle("is-open")}),columnMenu.addEventListener("click",function(e){e.stopPropagation()})}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"),conceptBtn=prod.querySelector(".lib-chars-bulk-concept-btn"),conceptCount=prod.querySelector(".lib-chars-bulk-count-concept"),consistentBtn=prod.querySelector(".lib-chars-bulk-consistent-btn"),consistentCount=prod.querySelector(".lib-chars-bulk-count-consistent"),visualStyleSel=prod.querySelector(".lib-chars-visual-style"),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)})}),conceptBtn.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(conceptBtn,ids,"Generating concept art",function(rec){return _charAutoGenerateConceptArt(rec,provider)})}),consistentBtn==null||consistentBtn.addEventListener("click",function(){const ids=[...prod.querySelectorAll(".lib-char-select-cb:checked")].map(function(cb){return cb.dataset.charId});ids.length&&runBulk(consistentBtn,ids,"Generating consistent images",function(rec){return _charAutoGenerateConsistentImages(rec,visualStyleSel?visualStyleSel.value:"cinematic")})});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),prod.querySelectorAll(".lib-chars-tbl-rating-star").forEach(function(star){star.addEventListener("click",async function(e){e.stopPropagation();const voiceId=star.dataset.voiceId,voice=(window._voices||[]).find(function(v){return v.id===voiceId});if(!voice||typeof saveMeta!="function")return;const value=Number(star.dataset.rating||0);voice.rating=value===Number(voice.rating||0)?0:value,prod.querySelectorAll(".lib-chars-tbl-rating-star").forEach(function(btn){btn.dataset.voiceId===voiceId&&btn.classList.toggle("is-on",Number(btn.dataset.rating)<=voice.rating)});try{await saveMeta(voiceId,{rating:voice.rating})}catch(err){toast("Could not save voice rating: "+(err.message||err),"error")}})}),prod.querySelectorAll(".lib-chars-tbl-voice-note").forEach(function(input){let timer=null;input.addEventListener("click",function(e){e.stopPropagation()}),input.addEventListener("keydown",function(e){e.stopPropagation()}),input.addEventListener("input",function(){clearTimeout(timer),timer=setTimeout(async function(){const voiceId=input.dataset.voiceId,voice=(window._voices||[]).find(function(v){return v.id===voiceId});if(!(!voice||typeof saveMeta!="function")){voice.note=input.value;try{await saveMeta(voiceId,{note:voice.note})}catch(err){toast("Could not save voice note: "+(err.message||err),"error")}}},600)})}),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);if(window._libResumeCharacterId){const resumeId=window._libResumeCharacterId;window._libResumeCharacterId=null;const resumeRec=byId.get(resumeId);resumeRec&&setTimeout(function(){_charDetailPage(resumeRec,all,{fromLibrary:!1})},0)}}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 _libGeneratedVoiceFileName(rec){const voiceId=rec!=null&&rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"";if(!voiceId)return"";const voice=(window._voices||[]).find(v=>v&&v.id===voiceId);return voice!=null&&voice.path&&String(voice.path).split(/[\\/]/).pop().trim()||""}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):"",voiceMeta=voiceId?(window._voices||[]).find(function(v){return v.id===voiceId}):null,voiceMissing=!!voiceId&&!_voiceExists(voiceId),voiceRating=Math.max(0,Math.min(5,Number((voiceMeta==null?void 0:voiceMeta.rating)||0))),rawVoiceNote=_libStr(voiceMeta==null?void 0:voiceMeta.note),voiceNote=/^voice design\s*:/i.test(rawVoiceNote)?"":rawVoiceNote,voicePrompt=_libStr((voiceMeta==null?void 0:voiceMeta.voice_design_prompt)||sh.voice_design_prompt),stars=voiceId?'
'+[1,2,3,4,5].map(function(n){return''}).join("")+"
":'\u2014',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 "),profile=_charProfileCompleteness(sh),profileClass=profile.filled<=8?"is-sparse":profile.filled<=20?"is-partial":"is-strong";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')+''+profile.filled+" / "+profile.total+'
'+(voiceId?'"+(voiceMissing?' ':"")+escHtml(voiceId)+"":'Keine Stimme')+'
'+(voiceId?'':"")+''+(voiceId?'':"")+'
'+stars+''+(voicePrompt?escHtml(voicePrompt):'\u2014')+''+(voiceId?'":'\u2014')+'
'+tagList.map(function(t){return''+escHtml(t)+""}).join("")+'
'+(bookLabel?escHtml(bookLabel):'\u2014')+''}).join("");return'
'+_CHAR_TABLE_COLUMN_DEFS.map(function(def){return'"}).join("")+"
"+'
'+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("profile","Profil (gef\xFCllt)","Sort by filled profile fields; the second number is always 32")+th("voice","Stimme")+th("rating","Rating","Rate the assigned voice")+''+rows+"
Voice Design PromptNotesTagsBook / 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()),isVoicePrompt=sheetKey==="voice_design_prompt",saveAction='';return'
"+escHtml(label)+(isVoicePrompt?' Edit the description to improve the voice':"")+(has||isVoicePrompt?"":' \u2014 not generated yet')+'
'+escHtml(value||"")+'
'+saveAction+'
"}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,_k2,_l2,_m2;typeof window.studioRememberCharacter=="function"&&window.studioRememberCharacter(rec.id),opts=opts||{},window._libDetailOpts=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||{},bookProfile=typeof _getBookProfile=="function"?await _getBookProfile(rec.book||""):{},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")+"
")+"
",bookContextHtml='
Book context
'+escHtml([bookProfile.genre,bookProfile.setting,bookProfile.era,bookProfile.language].filter(Boolean).join(" \xB7 ")||"Not set")+'
',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")+function(){const fileName=_libGeneratedVoiceFileName(rec);return fileName?'
Voice file: '+escHtml(fileName)+'
':'
'}()+_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("")+"
":"",sidebarByName=new Map;(allChars||[]).filter(function(c){return String(c.book||"").trim()===String(rec.book||"").trim()}).forEach(function(c){const key=String(c.name||"").trim().toLocaleLowerCase();if(!key)return;const old=sidebarByName.get(key);(!old||c.id===rec.id||_charLineCount(c)>_charLineCount(old))&&sidebarByName.set(key,c)}),sidebarByName.has(String(rec.name||"").trim().toLocaleLowerCase())||sidebarByName.set(String(rec.name||"").trim().toLocaleLowerCase(),rec);const sidebarHtml=[...sidebarByName.values()].sort(function(a,b){return _charLineCount(b)-_charLineCount(a)}).map(function(c){const h=_charHue(c.name),count=_charLineCount(c),gender2=_libStr(c.sheet&&c.sheet.gender).toLocaleLowerCase();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)+"":"")+'
'+bookContextHtml+conceptArtHtml+'
'+(voiceId?'':"")+''+(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);const sidebar=pg.querySelector(".lib-cpg-sidebar"),sidebarSearch=pg.querySelector(".lib-cpg-sidebar-search"),sidebarSort=pg.querySelector(".lib-cpg-sidebar-sort");function refreshCharacterSidebar(){const query=String((sidebarSearch==null?void 0:sidebarSearch.value)||"").trim().toLocaleLowerCase(),mode=(sidebarSort==null?void 0:sidebarSort.value)||"lines",items=Array.from((sidebar==null?void 0:sidebar.querySelectorAll(".lib-cpg-sidebar-item"))||[]);items.forEach(function(item){item.hidden=!!query&&!String(item.dataset.charName||"").toLocaleLowerCase().includes(query)}),items.sort(function(a,b){return mode==="name"?String(a.dataset.charName||"").localeCompare(String(b.dataset.charName||""),void 0,{sensitivity:"base"}):mode==="gender"?String(a.dataset.charGender||"").localeCompare(String(b.dataset.charGender||""),void 0,{sensitivity:"base"})||String(a.dataset.charName||"").localeCompare(String(b.dataset.charName||""),void 0,{sensitivity:"base"}):(parseInt(b.dataset.charLines||"0",10)||0)-(parseInt(a.dataset.charLines||"0",10)||0)||String(a.dataset.charName||"").localeCompare(String(b.dataset.charName||""),void 0,{sensitivity:"base"})}),items.forEach(function(item){sidebar.appendChild(item)})}sidebarSearch==null||sidebarSearch.addEventListener("input",refreshCharacterSidebar),sidebarSort==null||sidebarSort.addEventListener("change",refreshCharacterSidebar),pg.querySelector(".lib-cpg-back").addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),Promise.resolve(goBack()).catch(function(err){console.error("[character detail] back navigation failed",err),toast("Character list could not be opened","error")})}),(_a2=pg.querySelector(".lcd-edit-book-context"))==null||_a2.addEventListener("click",function(){_editBookProfile(rec.book||"")}),(_b2=pg.querySelector(".lcd-avatar-upload"))==null||_b2.addEventListener("click",function(){_openAvatarLightbox(rec,function(){_charDetailPage(rec,allChars,opts)})}),(_c2=pg.querySelector(".lcd-conceptart-gen"))==null||_c2.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}}),(_d2=pg.querySelector(".lcd-consistent-images-gen"))==null||_d2.addEventListener("click",async function(e){var _a3;e.stopPropagation();const btn=this;if(!_libStr(sh.image_prompt).trim()||!_libStr(sh.concept_art_prompt).trim()){toast("Generate both the Character Image Prompt and Character Reference Prompt first","error");return}const orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026';try{const style=((_a3=pg.querySelector(".lcd-visual-style"))==null?void 0:_a3.value)||"cinematic";await _charAutoGenerateConsistentImages(rec,style),toast("Matching profile and reference images generated","success"),_charDetailPage(rec,allChars,opts)}catch(err){toast("Consistent image generation failed: "+(err.message||err),"error"),btn.disabled=!1,btn.innerHTML=orig}}),(_e2=pg.querySelector(".lcd-visual-style"))==null||_e2.addEventListener("change",async function(){rec.sheet||(rec.sheet={}),rec.sheet.visual_style=this.value==="concept"?"concept":"cinematic",rec.updated=new Date,typeof clPut=="function"&&await clPut(rec)}),(_f2=pg.querySelector(".lcd-conceptart-img, .lcd-conceptart-empty"))==null||_f2.addEventListener("click",function(){_openConceptArtLightbox(rec,function(){_charDetailPage(rec,allChars,opts)})}),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)})}),(_g2=pg.querySelector(".lcd-pick-voice"))==null||_g2.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)})}),(_h2=pg.querySelector(".lcd-auto-voice"))==null||_h2.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)}),(_i2=pg.querySelector(".lcd-online-voice"))==null||_i2.addEventListener("click",function(){_charSearchOnline(rec)}),(_j2=pg.querySelector(".lcd-gen-voice"))==null||_j2.addEventListener("click",function(){_charDesignVoiceInline(rec)}),(_k2=pg.querySelector(".lcd-clone-voice"))==null||_k2.addEventListener("click",function(){_charCloneVoice(rec)}),(_l2=pg.querySelector(".lcd-preview-voice"))==null||_l2.addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),_libPreviewCharVoice(rec,e.currentTarget)}),(_m2=pg.querySelector(".lcd-generated-voice-play"))==null||_m2.addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),_libPreviewCharVoice(rec,e.currentTarget)}),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-save-prompt").forEach(function(btn){btn.addEventListener("click",async function(){var _a3,_b3,_c3;const text=((_b3=(_a3=btn.closest(".lcd-prompt-body"))==null?void 0:_a3.querySelector(".lcd-prompt-text"))==null?void 0:_b3.textContent.trim())||"";if(!text){toast("Prompt is empty","error");return}const sheetKey=btn.dataset.sheetKey||"voice_design_prompt",original=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Saving\u2026';try{if(rec.sheet||(rec.sheet={}),rec.sheet[sheetKey]=text,rec.sheet[sheetKey+"_user_edited"]=!0,rec.updated=new Date,typeof clPut=="function"&&await clPut(rec),sheetKey==="voice_design_prompt"){const key="vcf-design-presets",presets=JSON.parse(localStorage.getItem(key)||"{}"),base=String(rec.name||"Voice Design").trim()||"Voice Design";let name=base,n=2;for(;presets[name]&&presets[name].description!==text;)name=base+" "+n++;presets[name]={description:text,sample_text:"",language:_charLang(rec)||"Auto",gender:_libStr((_c3=rec.sheet)==null?void 0:_c3.gender)||"N"},localStorage.setItem(key,JSON.stringify(presets)),typeof syncDesignPresetsToServer=="function"&&syncDesignPresetsToServer()}toast("Prompt saved and character updated","success")}catch(err){toast("Could not save prompt: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}})}),pg.querySelectorAll(".lcd-regenerate-voice").forEach(function(btn){btn.addEventListener("click",async function(){var _a3,_b3;const prompt=((_a3=pg.querySelector('.lcd-prompt-box[data-sheet-key="voice_design_prompt"] .lcd-prompt-text'))==null?void 0:_a3.textContent.trim())||((_b3=pg.querySelector(".lcd-voice-prompt-box .lcd-prompt-text"))==null?void 0:_b3.textContent.trim())||"";if(!prompt){toast("Voice design prompt is empty","error");return}const generate=async function(){const original=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating Voice\u2026';try{rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=prompt,rec.updated=new Date,typeof clPut=="function"&&await clPut(rec),await _charAutoDesignVoice(rec,!0,prompt),_schedulePendingTtsRestart(),toast("New voice generated for "+rec.name,"success"),_charDetailPage(rec,allChars,opts)}catch(err){toast("Voice generation failed: "+(err.message||err),"error"),btn.disabled=!1,btn.innerHTML=original}};if(!rec.voice){await generate();return}const confirmRow=pg.querySelector(".lcd-voice-replace-confirm");if(!confirmRow)return;confirmRow.hidden=!1,confirmRow.scrollIntoView({behavior:"smooth",block:"nearest"});const cancel=confirmRow.querySelector(".lcd-voice-replace-cancel"),replace=confirmRow.querySelector(".lcd-voice-replace-ok");cancel.onclick=function(){confirmRow.hidden=!0},replace.onclick=async function(){confirmRow.hidden=!0,replace.disabled=!0;try{await generate()}finally{replace.disabled=!1}}})}),pg.querySelectorAll(".lcd-gen-prompt").forEach(function(btn){btn.addEventListener("click",async function(e){var _a3;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||"",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},sheet:sh2,visual_style:((_a3=pg.querySelector(".lcd-visual-style"))==null?void 0:_a3.value)||sh2.visual_style||"cinematic",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.sheet[key+"_user_edited"]=!1,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)+'':'
'+escHtml((rec.name||"?")[0].toUpperCase())+"
")+'
',document.body.appendChild(ov),ov.addEventListener("click",function(e){e.target===ov&&ov.remove()}),ov.querySelector("#alb-close").addEventListener("click",function(){ov.remove()}),ov.querySelector("#alb-upload-btn").addEventListener("click",function(){ov.querySelector("#alb-file-input").click()}),ov.querySelector("#alb-online-focus-btn").addEventListener("click",function(){ov.querySelector("#alb-url-input").focus(),ov.querySelector("#alb-url-input").scrollIntoView({behavior:"smooth",block:"center"})});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,confirmed:!0},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 _voiceAudibleSpeciesCue(value){const v=String(value||"").toLowerCase();return/\b(ork|orc|orcs)\b/.test(v)?"Orc: chest-led, gravel-edged resonance; clipped consonants, forceful breath, fully intelligible.":/\b(vampir|vampire)\b/.test(v)?"Vampire: cool velvety timbre, precise diction, restrained predatory calm.":/\b(elf|elv|elfe)\b/.test(v)?"Elf: clear airy resonance, precise musical diction, adult and grounded.":/\b(d[aä]mon|demon|teufel|devil)\b/.test(v)?"Demon: dark resonant timbre with a subtle otherworldly undertone; fully intelligible.":/\b(zwerg|dwarf)\b/.test(v)?"Dwarf: compact earthy resonance, sturdy cadence, deliberate articulation.":/\b(fee|fae|geist|spirit|untot|undead)\b/.test(v)?"Otherworldly: unusual natural resonance, measured breath, emotionally believable.":""}function _voiceAudibleRoleCue(sheet){const v=[sheet.profession,sheet.title,sheet.archetype,sheet.social_class,sheet.capabilities].filter(Boolean).join(" ").toLowerCase();return/krieger|soldat|ritter|wache|kämpfer|warrior|soldier|knight|guard|general|anführer|commander/.test(v)?"Warrior/commander: grounded projection, disciplined breath, concise command cadence.":/schmied|blacksmith|handwerk|artisan|craft/.test(v)?"Craftsperson: practical work-worn texture, measured pace, plain-spoken certainty.":/magier|zauber|hexe|hexer|wizard|mage|sorcer|druid/.test(v)?"Magic-user: measured phrasing, precise diction, contained intensity.":/priester|inquis|mönch|monk|priest|cleric|prophet/.test(v)?"Religious authority: formal diction, calm conviction, weighted pauses.":/könig|königin|baron|graf|adel|noble|lord|lady|herrscher|herrscherin/.test(v)?"High-status authority: polished diction, composed pace, effortless command.":/dieb|spion|assassin|schurke|rogue|thief/.test(v)?"Covert operator: quiet confidence, economical phrasing, alert timing.":/gelehrt|scholar|lehrer|teacher|arzt|heiler|healer/.test(v)?"Learned professional: articulate, patient pace, observant and assured.":""}function _voiceIdentityPrompt(rec,sourcePrompt,langName){const sh=rec.sheet||{},rawGender=String(sh.gender||_genderFromGenericName(rec.name)||"").toLowerCase(),gender=rawGender.startsWith("f")?"female":rawGender.startsWith("m")?"male":rawGender.startsWith("non")?"androgynous/nonbinary":"",hardGender=gender?"Adult "+gender+" voice, clearly "+(gender==="female"?"feminine and never masculine-coded":gender==="male"?"masculine and never feminine-coded":"androgynous")+".":"",age=String(sh.age_estimate||"").trim(),ageCue=age?"Age: "+age.slice(0,36)+".":"",lang=String(langName||"").trim(),languageCue=lang?lang.toLowerCase()==="english"?"Neutral international English.":"Native "+lang+" delivery, never American-accented.":"",species=_voiceAudibleSpeciesCue([sh.race_species,sh.archetype,sh.profession,sh.aliases].filter(Boolean).join(" ")),role=_voiceAudibleRoleCue(sh),prefix=[hardGender,ageCue,languageCue,species,role,"Distinct natural audiobook voice; no imitation."].filter(Boolean).join(" "),base=String(sourcePrompt||"").replace(/\s+/g," ").trim(),room=Math.max(0,480-prefix.length-(prefix&&base?1:0));return(prefix+(base?" "+base.slice(0,room):"")).slice(0,480)}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,afterSave){_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(result){ov.remove(),typeof afterSave=="function"&&afterSave(result||null)};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{const saved=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(saved)}catch(e){toast("Failed to save: "+(e.message||e),"error"),btn.disabled=!1}})})}window.openBookContextEditor=function(book){return new Promise(function(resolve){_editBookProfile(book,resolve)})};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=_voiceIdentityPrompt(rec,savedPrompt||_buildVoicePrompt(rec,null,lang),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,promptOverride){const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(promptOverride||sh.voice_design_prompt).trim(),instruct=savedPrompt||_voiceIdentityPrompt(rec,_buildVoicePrompt(rec,null,lang),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);let promptSaveTimer=null;const saveInlinePrompt=async function(text){if(text=String(text||"").trim(),!!text&&(rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,rec.updated=new Date,typeof clPut=="function")){const saved=await clPut(rec);saved&&saved.sheet&&(rec.sheet=saved.sheet)}},promptInput=ov.querySelector("#cdi-instruct");promptInput.addEventListener("input",function(){clearTimeout(promptSaveTimer),promptSaveTimer=setTimeout(function(){saveInlinePrompt(promptInput.value).catch(function(err){console.warn("[character prompt] autosave failed",err)})},600)}),ov.querySelector("#cdi-save").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;clearTimeout(promptSaveTimer),btn.disabled=!0,btn.innerHTML=' Saving\u2026';try{await saveInlinePrompt(promptInput.value),toast("Voice design prompt saved","success")}catch(err){toast("Prompt save failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),ov.querySelector("#cdi-rewrite").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Rewriting\u2026';try{const target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},sample=[sh.physical,sh.backstory,sh.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:rec.name,book:rec.book||"",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},sheet:sh,language,llm_url:target.url,model:target.model,fields:["voice_design_prompt"]})});if(!r.ok)throw new Error((await r.json().catch(function(){return{}})).detail||r.statusText);const d=await r.json();if(!d.voice_design_prompt)throw new Error("The rewrite returned an empty prompt");ov.querySelector("#cdi-instruct").value=d.voice_design_prompt,await saveInlinePrompt(d.voice_design_prompt),toast("Voice design prompt rewritten \u2014 review it, then generate the voice","success")}catch(err){toast("Prompt rewrite failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),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}clearTimeout(promptSaveTimer),btn.disabled=!0;const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-loading mdi-spin");try{await saveInlinePrompt(text),await _charAutoDesignVoice(rec,!0,text),_schedulePendingTtsRestart(),rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,close(),toast("New voice designed for "+rec.name,"success");const detailPage=document.querySelector(".lib-char-page"),detailOpts=window._libDetailOpts;if(detailPage&&detailOpts&&typeof _charDetailPage=="function"){const all=typeof clGetAll=="function"?await clGetAll().catch(function(){return[]}):[],updated=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(updated,all.filter(function(r){return r.book===updated.book}),detailOpts)}else typeof libraryRenderCharacters=="function"&&await 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?_libStr(instructOverride).trim():_voiceIdentityPrompt(rec,_libStr(sh.voice_design_prompt).trim()||_buildVoicePrompt(rec,await _getBookProfile(rec.book),langName),langName);if(!instruct.trim())throw new Error("No character description to design a voice from yet");instructOverride&&(sh.voice_design_prompt=instruct);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,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,image_kind:"portrait"};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 _charAutoGenerateConsistentImages(rec,visualStyle){const sh=rec.sheet||{},profilePrompt=_libStr(sh.image_prompt).trim(),referencePrompt=_libStr(sh.concept_art_prompt).trim();if(!profilePrompt)throw new Error("No Character Image Prompt \u2014 generate it first");if(!referencePrompt)throw new Error("No Character Reference Prompt \u2014 generate it first");const r=await fetch("/api/character-generate-consistent-images",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({profile_prompt:profilePrompt,reference_prompt:referencePrompt,name:rec.name||"",book:rec.book||"",sheet:sh,visual_style:visualStyle||"cinematic",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},identity_image:"",reuse_identity:!1})});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();if(!d.profile||!d.reference)throw new Error("ComfyUI returned an incomplete image pair");typeof clSetImage=="function"&&await clSetImage(rec.id,d.profile);const target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;return target.image=d.profile,target.sheet=Object.assign({},target.sheet||{},{concept_art_image:d.reference}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),rec.image=d.profile,rec.sheet=Object.assign({},sh,{concept_art_image:d.reference}),document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''+escHtml(rec.name)+''}),d.profile}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,image_kind:"concept_art"};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}function _openConceptArtLightbox(rec,onSaved){var _a2;(_a2=document.getElementById("conceptart-lightbox"))==null||_a2.remove();const sh=rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim(),ov=document.createElement("div");ov.id="conceptart-lightbox",ov.className="audiobook-overlay",ov.innerHTML='
'+escHtml(rec.name)+' \u2014 Konzeptbild
'+(sh.concept_art_image?'Concept art \u2014 '+escHtml(rec.name)+'':'
No concept art yet
')+'
',document.body.appendChild(ov);const close=function(){ov.remove()},status2=function(msg,cls){const el=ov.querySelector("#calb-status");el.textContent=msg||"",el.className="llm-active-status"+(cls?" "+cls:"")},setPreview=function(src){ov.querySelector(".conceptart-editor-preview").innerHTML='Concept art \u2014 '+escHtml(rec.name)+''},commit=async function(dataUri){const target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;target.sheet=Object.assign({},target.sheet||{},{concept_art_image:dataUri}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),rec.sheet=target.sheet,setPreview(dataUri),toast("Concept art saved","success"),typeof onSaved=="function"&&onSaved()};ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#calb-close").addEventListener("click",close),ov.querySelector("#calb-upload").addEventListener("click",function(){ov.querySelector("#calb-file").click()}),ov.querySelector("#calb-online").addEventListener("click",function(){ov.querySelector("#calb-url").focus(),ov.querySelector("#calb-url").scrollIntoView({behavior:"smooth",block:"center"})}),ov.querySelector("#calb-file").addEventListener("change",function(){const file=this.files[0];if(!file)return;const fr=new FileReader;fr.onload=function(e){commit(e.target.result).catch(function(err){toast("Upload failed: "+err.message,"error")})},fr.readAsDataURL(file)}),ov.querySelector("#calb-url-btn").addEventListener("click",async function(){const url=ov.querySelector("#calb-url").value.trim();if(!url){toast("Enter an image URL","error");return}const btn=this;btn.disabled=!0,status2("Downloading\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 commit(d.image),status2("\u2713 Downloaded","ok")}catch(e){status2("Failed","err"),toast("Download failed: "+e.message,"error")}finally{btn.disabled=!1}}),ov.querySelector("#calb-regenerate").addEventListener("click",async function(){const nextPrompt=ov.querySelector("#calb-prompt").value.trim();if(!nextPrompt){toast("Generate or enter a concept-art prompt first","error");return}const btn=this,orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Generating\u2026',status2("Generating\u2026");try{sh.concept_art_prompt=nextPrompt;const provider=ov.querySelector("#calb-provider").value,img=await _charAutoGenerateConceptArt(rec,provider);await commit(img),status2("\u2713 Generated","ok")}catch(e){status2("Failed","err"),toast("Generation failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig}})}window._charSearchOnline=_charSearchOnline,window._charDesignVoice=_charDesignVoice,window._charAutoDesignVoice=_charAutoDesignVoice,window._charAutoGenerateImage=_charAutoGenerateImage,window._charAutoGenerateConceptArt=_charAutoGenerateConceptArt,window._openConceptArtLightbox=_openConceptArtLightbox,window.libraryRenderCharacters=libraryRenderCharacters;const STUDIO_RESUME_KEY="ttsvc_studio_resume";let _stuActive=1;function _stuReadResume(){try{return JSON.parse(localStorage.getItem(STUDIO_RESUME_KEY)||"{}")||{}}catch{return{}}}function _stuSaveResume(phase){var _a2,_b2,_c2;try{const previous=_stuReadResume();localStorage.setItem(STUDIO_RESUME_KEY,JSON.stringify({phase,bookId:((_a2=window._audiobook)==null?void 0:_a2.bookId)||((_b2=window.readerState)==null?void 0:_b2.savedId)||previous.bookId||"",bookTitle:((_c2=window.readerState)==null?void 0:_c2.title)||previous.bookTitle||"",characterId:previous.characterId||"",savedAt:Date.now()}))}catch{}}window.studioRememberBook=function(bookId,title){try{const previous=_stuReadResume();localStorage.setItem(STUDIO_RESUME_KEY,JSON.stringify({phase:_stuActive||previous.phase||1,bookId:bookId||previous.bookId||"",bookTitle:title||previous.bookTitle||"",characterId:previous.characterId||"",savedAt:Date.now()}))}catch{}},window.studioRememberCharacter=function(characterId){var _a2,_b2;try{const previous=_stuReadResume();localStorage.setItem(STUDIO_RESUME_KEY,JSON.stringify({phase:previous.phase||_stuActive||3,bookId:previous.bookId||((_a2=window._audiobook)==null?void 0:_a2.bookId)||"",bookTitle:previous.bookTitle||((_b2=window.readerState)==null?void 0:_b2.title)||"",characterId:characterId||"",savedAt:Date.now()}))}catch{}};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)})}),(_uc=document.getElementById("stu-book-context-btn"))==null||_uc.addEventListener("click",async function(){var _a2;const bookTitle=((_a2=window.readerState)==null?void 0:_a2.title)||"";!bookTitle||typeof window.openBookContextEditor!="function"||await window.openBookContextEditor(bookTitle)}),(_vc=document.getElementById("stu-change-book-btn"))==null||_vc.addEventListener("click",function(){showStudioPhase(1)});let _stuCastFootObserver=null,_stuCastFootHealthTimer=null;function _stuCastIsRunning(){return typeof _audiobook!="undefined"&&!!_audiobook.running}function _stuSyncCastFallbackControls(tabs,innerTabs,hasActionFooter){const running=_stuCastIsRunning();tabs.hidden=!1,tabs.style.display="flex",innerTabs.forEach(function(tab){const hideFallback=running||hasActionFooter;tab.hidden=hideFallback,tab.style.display=hideFallback?"none":""})}function _stuRelocateCastFoot(){_stuTryRelocateCastFoot(),!_stuCastFootObserver&&(_stuCastFootObserver=new MutationObserver(function(){_stuTryRelocateCastFoot()}),_stuCastFootObserver.observe(document.body,{childList:!0,subtree:!0}),[0,100,300,800,1500].forEach(ms=>setTimeout(_stuTryRelocateCastFoot,ms)),_stuCastFootHealthTimer=setInterval(function(){_stuIsActive&&_stuActive===2&&_stuTryRelocateCastFoot()},500))}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")||document.querySelector("#ab-cv-foot"),innerTabs=Array.from(tabs.querySelectorAll(":scope > .stu-inner-tab")),footerHasActions=freshFoot&&freshFoot.querySelector("#ab-cv-menu-identify, #ab-cv-menu-cast, #ab-cv-menu-viewcast");if(_stuSyncCastFallbackControls(tabs,innerTabs,!!footerHasActions),!freshFoot||!footerHasActions)return;if(freshFoot.parentElement===tabs){freshFoot.hidden=!1,freshFoot.style.display="flex";return}tabs.querySelectorAll("#ab-cv-foot").forEach(function(stale){stale.remove()}),tabs.hidden=!1,tabs.style.display="flex",freshFoot.style.borderTop="none",freshFoot.style.padding="0",freshFoot.style.justifyContent="flex-start",freshFoot.hidden=!1,freshFoot.style.display="flex",tabs.appendChild(freshFoot);const openReh=freshFoot.querySelector("#ab-cv-open-reh");openReh&&(openReh.hidden=!0)}function _stuRestoreCastFoot(){_stuCastFootObserver&&(_stuCastFootObserver.disconnect(),_stuCastFootObserver=null),_stuCastFootHealthTimer&&(clearInterval(_stuCastFootHealthTimer),_stuCastFootHealthTimer=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,_stuSaveResume(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))})}),(_wc=document.getElementById("stu-phase-prev"))==null||_wc.addEventListener("click",function(){_stuActive>1&&showStudioPhase(_stuActive-1)}),(_xc=document.getElementById("stu-phase-next"))==null||_xc.addEventListener("click",function(){_stuActive<4&&showStudioPhase(_stuActive+1)});function studioOnShow(){var _a2,_b2,_c2;_stuInstallNavGuardOnce(),_stuIsActive=!0;const resume=_stuReadResume();resume.bookId&&window._audiobook&&(window._audiobook.bookId=resume.bookId),resume.phase>=1&&resume.phase<=4&&(_stuActive=resume.phase,resume.characterId&&(window._libResumeCharacterId=resume.characterId));const resumeBookId=resume.bookId,hasCurrentBook=!!((_b2=(_a2=window.readerState)==null?void 0:_a2.sentences)!=null&&_b2.length&&((_c2=window.readerState)!=null&&_c2.savedId));if(resumeBookId&&!hasCurrentBook&&typeof window.readerOpenLibraryDoc=="function"){window.readerOpenLibraryDoc(resumeBookId).then(function(){showStudioPhase(_stuActive)});return}showStudioPhase(_stuActive)}window.studioOnShow=studioOnShow; diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 7ef0b88..a8e8303 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1616,7 +1616,6 @@ function _abScrollIntoView(el, opts) { function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) { const panel = document.getElementById('reader-audiobook-panel'); if (!panel) return; - if (typeof window.navReaderView === 'function') window.navReaderView('cast'); else if (typeof window.showReaderView === 'function') window.showReaderView('cast'); else { @@ -1707,7 +1706,7 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
`; - + const AB_DEFAULT_PROMPT = `Du bist ein erfahrener Drehbuchautor und Hörbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente für Erzähler und Dialoge (wörtliche Rede) zu unterteilen. WICHTIGE DEFINITION VON DIALOG: @@ -4176,6 +4175,14 @@ ABSOLUTE REGELN: }); }; const runIdentifyAll = async () => { + const bookTitle = window.readerState?.title || ''; + if (bookTitle && typeof window.openBookContextEditor === 'function') { + const existingContext = typeof _getBookProfile === 'function' ? await _getBookProfile(bookTitle) : {}; + if (!existingContext.confirmed) { + const profile = await window.openBookContextEditor(bookTitle); + if (!profile) return; + } + } const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true }); if (!ok) return; applyPromptAndRun(audiobookCast); @@ -5712,6 +5719,23 @@ function audiobookLineVoice(l) { return { voice: rehState.narratorVoice, instruct: '' }; } +// Long narrator paragraphs must be split before they reach the TTS service. +// The service has to restart its decoder/model context for a very long +// request, and its own emergency cut can land mid-sentence. Keep the split +// sentence-aware and add only a short, intentional breath between generated +// pieces; normal paragraph gaps are added later when chapters are merged. +async function _abSynthesizeLongText(voice, text, instruct, backend) { + const pieces = (typeof splitTextIntoChunks === 'function') + ? splitTextIntoChunks(text, 900) + : [text]; + if (pieces.length <= 1) return fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend); + const blobs = []; + for (const piece of pieces) { + blobs.push(await fetchTtsPreviewBlob(voice, piece, 'wav', instruct, backend)); + } + return mergeWavBlobs(blobs, 120); +} + async function audiobookExport() { if (_audiobook.running) return; if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; } @@ -5794,7 +5818,7 @@ async function audiobookExport() { let lastErr; for (let attempt = 1; attempt <= 3; attempt++) { try { - blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend)); + blob = await _abSynthesizeLongText(voice, text, instruct, _ttsBackendForVoice(voice, rehState.backend)); lastErr = null; break; } catch (e) { diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 381d150..21a6f44 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -219,6 +219,8 @@ async function libraryRenderCharacters() { + '' + '' + '' + + '' + + '' + '' + '' + '
' @@ -315,6 +317,9 @@ async function libraryRenderCharacters() { const imageCount = prod.querySelector('.lib-chars-bulk-count-image'); const conceptBtn = prod.querySelector('.lib-chars-bulk-concept-btn'); const conceptCount = prod.querySelector('.lib-chars-bulk-count-concept'); + const consistentBtn = prod.querySelector('.lib-chars-bulk-consistent-btn'); + const consistentCount = prod.querySelector('.lib-chars-bulk-count-consistent'); + const visualStyleSel = prod.querySelector('.lib-chars-visual-style'); const imageProviderSel = prod.querySelector('.lib-chars-image-provider'); if (imageProviderSel) imageProviderSel.value = (typeof _appSettings !== 'undefined' && _appSettings.image_gen_provider) || ''; const deleteBtn = prod.querySelector('.lib-chars-bulk-delete-btn'); @@ -337,6 +342,8 @@ async function libraryRenderCharacters() { imageBtn.disabled = n === 0; conceptCount.textContent = n; conceptBtn.disabled = n === 0; + if (consistentCount) consistentCount.textContent = n; + if (consistentBtn) consistentBtn.disabled = n === 0; deleteCount.textContent = n; deleteBtn.disabled = n === 0; syncTblSelectAllCb(); @@ -426,6 +433,13 @@ async function libraryRenderCharacters() { const provider = imageProviderSel ? imageProviderSel.value : ''; runBulk(conceptBtn, ids, 'Generating concept art', function (rec) { return _charAutoGenerateConceptArt(rec, provider); }); }); + consistentBtn?.addEventListener('click', function () { + const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; }); + if (!ids.length) return; + runBulk(consistentBtn, ids, 'Generating consistent images', function (rec) { + return _charAutoGenerateConsistentImages(rec, visualStyleSel ? visualStyleSel.value : 'cinematic'); + }); + }); const fixLangBtn = prod.querySelector('.lib-chars-fix-lang-btn'); fixLangBtn?.addEventListener('click', function () { // No selection needed — scans every character in this production for @@ -532,6 +546,14 @@ async function libraryRenderCharacters() { } else if (savedScrollTop != null) { mainEl.scrollTop = savedScrollTop; } + + // Restore the exact profile that was open when Studio was refreshed. + if (window._libResumeCharacterId) { + const resumeId = window._libResumeCharacterId; + window._libResumeCharacterId = null; + const resumeRec = byId.get(resumeId); + if (resumeRec) setTimeout(function () { _charDetailPage(resumeRec, all, { fromLibrary: false }); }, 0); + } } function _charHue(name) { @@ -1073,13 +1095,16 @@ function _lcdSectionFull(icon, label, fields) { // convention the rest of the detail view uses, so edits autosave for free. function _lcdPromptBox(label, value, sheetKey) { const has = !!(value && String(value).trim()); - return '
' - + '' + escHtml(label) + (has ? '' : ' — not generated yet') + '' + const isVoicePrompt = sheetKey === 'voice_design_prompt'; + const saveAction = ''; + return '
' + + '' + escHtml(label) + (isVoicePrompt ? ' Edit the description to improve the voice' : '') + (has || isVoicePrompt ? '' : ' — not generated yet') + '' + '
' + '
' + escHtml(value || '') + '
' + '
' + + saveAction + '' - + '' + + '' + '
' + '
' + '
'; @@ -1143,6 +1168,7 @@ function _charLineCount(c) { } async function _charDetailPage(rec, allChars, opts) { + if (typeof window.studioRememberCharacter === 'function') window.studioRememberCharacter(rec.id); opts = opts || {}; window._libDetailOpts = opts; // Defaults to the Library grid's own list container/back-navigation, but @@ -1156,6 +1182,7 @@ async function _charDetailPage(rec, allChars, opts) { window._libDetailRec = rec; const sh = rec.sheet || {}; + const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book || '') : {}; const hue = _charHue(rec.name); const hue2 = (hue + 40) % 360; const tier = String(sh.tier || '').toLowerCase(); @@ -1192,6 +1219,13 @@ async function _charDetailPage(rec, allChars, opts) { const conceptArtHtml = '
' + '
' + '' + + '' + + '' + '' @@ -1204,6 +1238,14 @@ async function _charDetailPage(rec, allChars, opts) { + '
') + '
'; + const bookContextHtml = '
' + + '
Book context
' + + '
' + + escHtml([bookProfile.genre, bookProfile.setting, bookProfile.era, bookProfile.language].filter(Boolean).join(' · ') || 'Not set') + + '
' + + '' + + '
'; + const alignHtml = pct != null ? ( '
' + '' @@ -1229,8 +1271,16 @@ async function _charDetailPage(rec, allChars, opts) { ? '
' + '' + ' Voice file: ' + escHtml(fileName) + '' + + '' + '
' - : ''; + + '' + : '
'; }()) + _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt') + _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_prompt') @@ -1291,13 +1341,30 @@ async function _charDetailPage(rec, allChars, opts) { // Prefer the actual live count from the currently-loaded script (exact), // falling back to the LLM's own line_count estimate from casting time // when no script is loaded here. - const sidebarChars = (allChars || []).slice().sort(function (a, b) { + // The shared cast store can contain several incremental records with the + // same display name (for example Marcian was discovered again in later + // passages/books). The detail navigator is book-local, so showing every + // raw record here made the same person appear several times. Keep the + // strongest record per name for this book, while leaving the underlying + // records untouched for now. + const sidebarByName = new Map(); + (allChars || []).filter(function (c) { + return String(c.book || '').trim() === String(rec.book || '').trim(); + }).forEach(function (c) { + const key = String(c.name || '').trim().toLocaleLowerCase(); + if (!key) return; + const old = sidebarByName.get(key); + if (!old || c.id === rec.id || _charLineCount(c) > _charLineCount(old)) sidebarByName.set(key, c); + }); + if (!sidebarByName.has(String(rec.name || '').trim().toLocaleLowerCase())) sidebarByName.set(String(rec.name || '').trim().toLocaleLowerCase(), rec); + const sidebarChars = [...sidebarByName.values()].sort(function (a, b) { return _charLineCount(b) - _charLineCount(a); }); const sidebarHtml = sidebarChars.map(function (c) { const h = _charHue(c.name); const count = _charLineCount(c); - return '
' + const gender = _libStr(c.sheet && c.sheet.gender).toLocaleLowerCase(); + return '
' + '' + escHtml((c.name || '?')[0].toUpperCase()) + '' + '' + escHtml(c.name) + '' + (count ? '' + count + '' : '') @@ -1333,6 +1400,7 @@ async function _charDetailPage(rec, allChars, opts) { + '
' + '
' + '
' + + bookContextHtml + conceptArtHtml + '
' + '' @@ -1394,11 +1462,41 @@ async function _charDetailPage(rec, allChars, opts) { + '
' + '
' + '
Charaktere · ' + escHtml(rec.book || '') + '
' + + '
' + + '' + + '' + + '
' + sidebarHtml + '
'; container.appendChild(pg); + const sidebar = pg.querySelector('.lib-cpg-sidebar'); + const sidebarSearch = pg.querySelector('.lib-cpg-sidebar-search'); + const sidebarSort = pg.querySelector('.lib-cpg-sidebar-sort'); + function refreshCharacterSidebar() { + const query = String(sidebarSearch?.value || '').trim().toLocaleLowerCase(); + const mode = sidebarSort?.value || 'lines'; + const items = Array.from(sidebar?.querySelectorAll('.lib-cpg-sidebar-item') || []); + items.forEach(function (item) { + item.hidden = !!query && !String(item.dataset.charName || '').toLocaleLowerCase().includes(query); + }); + items.sort(function (a, b) { + if (mode === 'name') return String(a.dataset.charName || '').localeCompare(String(b.dataset.charName || ''), undefined, { sensitivity: 'base' }); + if (mode === 'gender') { + const genderOrder = String(a.dataset.charGender || '').localeCompare(String(b.dataset.charGender || ''), undefined, { sensitivity: 'base' }); + return genderOrder || String(a.dataset.charName || '').localeCompare(String(b.dataset.charName || ''), undefined, { sensitivity: 'base' }); + } + return (parseInt(b.dataset.charLines || '0', 10) || 0) - (parseInt(a.dataset.charLines || '0', 10) || 0) + || String(a.dataset.charName || '').localeCompare(String(b.dataset.charName || ''), undefined, { sensitivity: 'base' }); + }); + items.forEach(function (item) { sidebar.appendChild(item); }); + } + sidebarSearch?.addEventListener('input', refreshCharacterSidebar); + sidebarSort?.addEventListener('change', refreshCharacterSidebar); + pg.querySelector('.lib-cpg-back').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); @@ -1407,6 +1505,9 @@ async function _charDetailPage(rec, allChars, opts) { toast('Character list could not be opened', 'error'); }); }); + pg.querySelector('.lcd-edit-book-context')?.addEventListener('click', function () { + _editBookProfile(rec.book || ''); + }); // Both the initial letter and an existing portrait open the same clear // image workflow; clicking no longer launches an unexplained file picker. @@ -1435,6 +1536,35 @@ async function _charDetailPage(rec, allChars, opts) { } }); + pg.querySelector('.lcd-consistent-images-gen')?.addEventListener('click', async function (e) { + e.stopPropagation(); + const btn = this; + if (!_libStr(sh.image_prompt).trim() || !_libStr(sh.concept_art_prompt).trim()) { + toast('Generate both the Character Image Prompt and Character Reference Prompt first', 'error'); + return; + } + const orig = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Generating…'; + try { + const style = pg.querySelector('.lcd-visual-style')?.value || 'cinematic'; + await _charAutoGenerateConsistentImages(rec, style); + toast('Matching profile and reference images generated', 'success'); + _charDetailPage(rec, allChars, opts); + } catch (err) { + toast('Consistent image generation failed: ' + (err.message || err), 'error'); + btn.disabled = false; + btn.innerHTML = orig; + } + }); + + pg.querySelector('.lcd-visual-style')?.addEventListener('change', async function () { + if (!rec.sheet) rec.sheet = {}; + rec.sheet.visual_style = this.value === 'concept' ? 'concept' : 'cinematic'; + rec.updated = new Date(); + if (typeof clPut === 'function') await clPut(rec); + }); + pg.querySelector('.lcd-conceptart-img, .lcd-conceptart-empty')?.addEventListener('click', function () { _openConceptArtLightbox(rec, function () { _charDetailPage(rec, allChars, opts); @@ -1494,6 +1624,90 @@ async function _charDetailPage(rec, allChars, opts) { toast('Prompt copied', 'success'); }); }); + pg.querySelectorAll('.lcd-save-prompt').forEach(function (btn) { + btn.addEventListener('click', async function () { + const text = btn.closest('.lcd-prompt-body')?.querySelector('.lcd-prompt-text')?.textContent.trim() || ''; + if (!text) { toast('Prompt is empty', 'error'); return; } + const sheetKey = btn.dataset.sheetKey || 'voice_design_prompt'; + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Saving…'; + try { + if (!rec.sheet) rec.sheet = {}; + rec.sheet[sheetKey] = text; + rec.sheet[sheetKey + '_user_edited'] = true; + rec.updated = new Date(); + if (typeof clPut === 'function') await clPut(rec); + // Voice prompts are also available as reusable design presets; the + // other three prompt types belong only to this character record. + if (sheetKey === 'voice_design_prompt') { + const key = 'vcf-design-presets'; + const presets = JSON.parse(localStorage.getItem(key) || '{}'); + const base = String(rec.name || 'Voice Design').trim() || 'Voice Design'; + let name = base; + let n = 2; + while (presets[name] && presets[name].description !== text) name = base + ' ' + n++; + presets[name] = { description: text, sample_text: '', language: _charLang(rec) || 'Auto', gender: _libStr(rec.sheet?.gender) || 'N' }; + localStorage.setItem(key, JSON.stringify(presets)); + if (typeof syncDesignPresetsToServer === 'function') syncDesignPresetsToServer(); + } + toast('Prompt saved and character updated', 'success'); + } catch (err) { toast('Could not save prompt: ' + (err.message || err), 'error'); } + finally { btn.disabled = false; btn.innerHTML = original; } + }); + }); + pg.querySelectorAll('.lcd-regenerate-voice').forEach(function (btn) { + btn.addEventListener('click', async function () { + const prompt = pg.querySelector('.lcd-prompt-box[data-sheet-key="voice_design_prompt"] .lcd-prompt-text')?.textContent.trim() + || pg.querySelector('.lcd-voice-prompt-box .lcd-prompt-text')?.textContent.trim() || ''; + if (!prompt) { + toast('Voice design prompt is empty', 'error'); + return; + } + const generate = async function () { + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Generating Voice…'; + try { + // The visible editor is authoritative: persist it first, then pass + // exactly that same text to generation. No intermediate editor + // dialog and no hidden prompt rewrite. + if (!rec.sheet) rec.sheet = {}; + rec.sheet.voice_design_prompt = prompt; + rec.updated = new Date(); + if (typeof clPut === 'function') await clPut(rec); + await _charAutoDesignVoice(rec, true, prompt); + _schedulePendingTtsRestart(); + toast('New voice generated for ' + rec.name, 'success'); + _charDetailPage(rec, allChars, opts); + } catch (err) { + toast('Voice generation failed: ' + (err.message || err), 'error'); + btn.disabled = false; + btn.innerHTML = original; + } + }; + if (!rec.voice) { + await generate(); + return; + } + const confirmRow = pg.querySelector('.lcd-voice-replace-confirm'); + if (!confirmRow) return; + confirmRow.hidden = false; + confirmRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + const cancel = confirmRow.querySelector('.lcd-voice-replace-cancel'); + const replace = confirmRow.querySelector('.lcd-voice-replace-ok'); + cancel.onclick = function () { confirmRow.hidden = true; }; + replace.onclick = async function () { + confirmRow.hidden = true; + replace.disabled = true; + try { + await generate(); + } finally { + replace.disabled = false; + } + }; + }); + }); // One Generate button per prompt box — the server accepts a `fields` filter // so clicking just one no longer burns tokens (re)generating all four. pg.querySelectorAll('.lcd-gen-prompt').forEach(function (btn) { @@ -1510,13 +1724,14 @@ async function _charDetailPage(rec, allChars, opts) { const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' }; const 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] }), + body: JSON.stringify({ name: rec.name, book: rec.book || '', book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}, sheet: sh2, visual_style: pg.querySelector('.lcd-visual-style')?.value || sh2.visual_style || 'cinematic', 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 — try again'); if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = d[key]; + rec.sheet[key + '_user_edited'] = false; rec.updated = new Date(); if (typeof clPut === 'function') await clPut(rec); toast('Prompt generated', 'success'); @@ -2215,7 +2430,7 @@ async function _saveBookProfile(book, profile) { const key = String(book || '').trim(); const r = await fetch('/api/book-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(Object.assign({ book: key }, profile)), + body: JSON.stringify(Object.assign({ book: key, confirmed: true }, profile)), }); if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); } const d = await r.json(); @@ -2385,7 +2600,7 @@ function _charSearchOnline(rec) { // Small modal to set/edit a book's genre/setting/era/language once, instead // of every character's prompt guessing its own — see _getBookProfile. -function _editBookProfile(book) { +function _editBookProfile(book, afterSave) { _getBookProfile(book).then(function (profile) { const ov = document.createElement('div'); ov.className = 'audiobook-overlay'; @@ -2404,25 +2619,28 @@ function _editBookProfile(book) { + '
' + '
'; document.body.appendChild(ov); - const close = function () { ov.remove(); }; + const close = function (result) { ov.remove(); if (typeof afterSave === 'function') afterSave(result || null); }; ov.querySelector('#bctx-cancel').addEventListener('click', close); ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); ov.querySelector('#bctx-save').addEventListener('click', async function () { const btn = this; btn.disabled = true; try { - await _saveBookProfile(book, { + const saved = 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(); + close(saved); } catch (e) { toast('Failed to save: ' + (e.message || e), 'error'); btn.disabled = false; } }); }); } +window.openBookContextEditor = function (book) { + return new Promise(function (resolve) { _editBookProfile(book, resolve); }); +}; // Confirmation before silently reusing a series voice, for this DELIBERATE, // single-character "design a voice" action — confirmed live as a genuinely @@ -2531,19 +2749,27 @@ async function _charDesignVoice(rec) { // a single Generate button that calls _charAutoDesignVoice with the edited // text as an override (skipping the cross-book reuse suggestion, since // editing the prompt already signals the user wants a specific new voice). -function _charDesignVoiceInline(rec) { +function _charDesignVoiceInline(rec, promptOverride) { const sh = rec.sheet || {}; const lang = _charLang(rec); - const savedPrompt = _libStr(sh.voice_design_prompt).trim(); - const instruct = _voiceIdentityPrompt(rec, savedPrompt || _buildVoicePrompt(rec, null, lang), lang); + // Prefer text explicitly passed from the open editor so regeneration never + // reopens an older saved prompt while an autosave is still pending. + const savedPrompt = _libStr(promptOverride || sh.voice_design_prompt).trim(); + // Saved/manual prompts are already complete and must be shown verbatim. + // Only synthesize the identity prefix when there is no saved prompt yet. + const instruct = savedPrompt + ? savedPrompt + : _voiceIdentityPrompt(rec, _buildVoicePrompt(rec, null, lang), lang); const ov = document.createElement('div'); ov.className = 'audiobook-overlay'; - ov.innerHTML = '
' + ov.innerHTML = '
' + '
Voice design prompt — ' + escHtml(rec.name) + '
' + '

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

' + '' - + '
' + + '
' + '' + + '' + + '' + '' + '
' + '
'; @@ -2551,14 +2777,79 @@ function _charDesignVoiceInline(rec) { const close = function () { ov.remove(); }; ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); ov.querySelector('#cdi-cancel').addEventListener('click', close); + let promptSaveTimer = null; + const saveInlinePrompt = async function (text) { + text = String(text || '').trim(); + if (!text) return; + if (!rec.sheet) rec.sheet = {}; + rec.sheet.voice_design_prompt = text; + rec.updated = new Date(); + if (typeof clPut === 'function') { + const saved = await clPut(rec); + if (saved && saved.sheet) rec.sheet = saved.sheet; + } + }; + const promptInput = ov.querySelector('#cdi-instruct'); + promptInput.addEventListener('input', function () { + clearTimeout(promptSaveTimer); + promptSaveTimer = setTimeout(function () { + saveInlinePrompt(promptInput.value).catch(function (err) { + console.warn('[character prompt] autosave failed', err); + }); + }, 600); + }); + ov.querySelector('#cdi-save').addEventListener('click', async function (e) { + const btn = e.currentTarget; + const original = btn.innerHTML; + clearTimeout(promptSaveTimer); + btn.disabled = true; + btn.innerHTML = ' Saving…'; + try { + await saveInlinePrompt(promptInput.value); + toast('Voice design prompt saved', 'success'); + } catch (err) { + toast('Prompt save failed: ' + (err.message || err), 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = original; + } + }); + ov.querySelector('#cdi-rewrite').addEventListener('click', async function (e) { + const btn = e.currentTarget; + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Rewriting…'; + try { + const target = typeof statusLlmTarget === 'function' ? statusLlmTarget() : { url: '', model: '' }; + const sample = [sh.physical, sh.backstory, sh.motivation].filter(Boolean).join(' '); + const language = typeof detectLang === 'function' && sample ? (detectLang(sample) || '') : ''; + const r = await fetch('/api/character-generate-prompts', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: rec.name, book: rec.book || '', book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}, sheet: sh, language, llm_url: target.url, model: target.model, fields: ['voice_design_prompt'] }), + }); + if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText); + const d = await r.json(); + if (!d.voice_design_prompt) throw new Error('The rewrite returned an empty prompt'); + ov.querySelector('#cdi-instruct').value = d.voice_design_prompt; + await saveInlinePrompt(d.voice_design_prompt); + toast('Voice design prompt rewritten — review it, then generate the voice', 'success'); + } catch (err) { + toast('Prompt rewrite failed: ' + (err.message || err), 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = original; + } + }); ov.querySelector('#cdi-generate').addEventListener('click', async function (e) { const btn = e.currentTarget; const text = ov.querySelector('#cdi-instruct').value.trim(); if (!text) { toast('Prompt is empty', 'error'); return; } + clearTimeout(promptSaveTimer); btn.disabled = true; const icon = btn.querySelector('.mdi'); if (icon) icon.className = 'mdi mdi-loading mdi-spin'; try { + await saveInlinePrompt(text); await _charAutoDesignVoice(rec, true, text); _schedulePendingTtsRestart(); if (!rec.sheet) rec.sheet = {}; @@ -2763,7 +3054,12 @@ async function _charAutoDesignVoice(rec, force, instructOverride) { } const sh = rec.sheet || {}; const langName = (await _resolveBookLang(rec)) || 'English'; - const instruct = _voiceIdentityPrompt(rec, instructOverride || _libStr(sh.voice_design_prompt).trim() || _buildVoicePrompt(rec, await _getBookProfile(rec.book), langName), langName); + // An explicit edited prompt is authoritative. Do not decorate/rewrite it, + // otherwise the editor, confirmation dialog, and generated voice silently + // use three different prompts. + const instruct = instructOverride + ? _libStr(instructOverride).trim() + : _voiceIdentityPrompt(rec, _libStr(sh.voice_design_prompt).trim() || _buildVoicePrompt(rec, await _getBookProfile(rec.book), langName), langName); if (!instruct.trim()) throw new Error('No character description to design a voice from yet'); if (instructOverride) sh.voice_design_prompt = instruct; const langCode = (typeof DESIGN_LANG_CODE !== 'undefined' && DESIGN_LANG_CODE[langName]) || 'EN'; @@ -2946,6 +3242,48 @@ async function _charAutoGenerateImage(rec, provider) { return d.image; } +// Generate both visual assets through the shared-identity ComfyUI workflow. +// The backend creates a clean identity anchor from structured book/character +// facts. A legacy portrait is deliberately not reused because its hats, +// clothing, props, or scenery would be inherited by the new image pair. +async function _charAutoGenerateConsistentImages(rec, visualStyle) { + const sh = rec.sheet || {}; + const profilePrompt = _libStr(sh.image_prompt).trim(); + const referencePrompt = _libStr(sh.concept_art_prompt).trim(); + if (!profilePrompt) throw new Error('No Character Image Prompt — generate it first'); + if (!referencePrompt) throw new Error('No Character Reference Prompt — generate it first'); + const r = await fetch('/api/character-generate-consistent-images', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profile_prompt: profilePrompt, + reference_prompt: referencePrompt, + name: rec.name || '', + book: rec.book || '', + sheet: sh, + visual_style: visualStyle || 'cinematic', + book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}, + identity_image: '', + reuse_identity: false, + }), + }); + if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair'); + if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile); + const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null; + const target = current || rec; + target.image = d.profile; + target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.reference }); + target.updated = new Date(); + if (typeof clPut === 'function') await clPut(target); + rec.image = d.profile; + rec.sheet = Object.assign({}, sh, { concept_art_image: d.reference }); + document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) { + av.innerHTML = '' + escHtml(rec.name) + ''; + }); + return d.profile; +} + // Generate an actual concept-art REFERENCE SHEET image from the character's // own concept_art_prompt — this field previously had no image action at // all (only "Copy"/"Regenerate" for the text itself), so having a real, diff --git a/static/js/reader.js b/static/js/reader.js index 8cf2ef6..ebc6ca8 100644 --- a/static/js/reader.js +++ b/static/js/reader.js @@ -1699,6 +1699,7 @@ async function readerOpenLibraryDoc(id) { if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending')); readerState.savedId = id; readerState.sourceUploaded = true; // already on the server + if (typeof window.studioRememberBook === 'function') window.studioRememberBook(id, rec.title); readerState.savedAudioIdx = new Set(); // mark which units already have audio on the server (fetched lazily on play) if (rec.sentenceCount === readerState.sentences.length) { @@ -1729,6 +1730,10 @@ async function readerOpenLibraryDoc(id) { else showReaderView('main'); } +// Studio uses this explicit entry point to restore the selected book after a +// browser refresh, when the in-memory reader state has been cleared. +window.readerOpenLibraryDoc = readerOpenLibraryDoc; + function readerSetSelectValue(id, value) { const sel = $(id); if (!sel) return; if (![...sel.options].some(o => o.value === value)) { diff --git a/static/js/studio.js b/static/js/studio.js index d365ce3..fa90ca3 100644 --- a/static/js/studio.js +++ b/static/js/studio.js @@ -11,8 +11,53 @@ // the same element ids would collide; physically relocating the existing // node (same id, same listeners) does not. +const STUDIO_RESUME_KEY = 'ttsvc_studio_resume'; let _stuActive = 1; +function _stuReadResume() { + try { return JSON.parse(localStorage.getItem(STUDIO_RESUME_KEY) || '{}') || {}; } + catch (_) { return {}; } +} + +function _stuSaveResume(phase) { + try { + const previous = _stuReadResume(); + localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({ + phase: phase, + bookId: window._audiobook?.bookId || window.readerState?.savedId || previous.bookId || '', + bookTitle: window.readerState?.title || previous.bookTitle || '', + characterId: previous.characterId || '', + savedAt: Date.now(), + })); + } catch (_) {} +} + +window.studioRememberBook = function (bookId, title) { + try { + const previous = _stuReadResume(); + localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({ + phase: _stuActive || previous.phase || 1, + bookId: bookId || previous.bookId || '', + bookTitle: title || previous.bookTitle || '', + characterId: previous.characterId || '', + savedAt: Date.now(), + })); + } catch (_) {} +}; + +window.studioRememberCharacter = function (characterId) { + try { + const previous = _stuReadResume(); + localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({ + phase: previous.phase || _stuActive || 3, + bookId: previous.bookId || window._audiobook?.bookId || '', + bookTitle: previous.bookTitle || window.readerState?.title || '', + characterId: characterId || '', + savedAt: Date.now(), + })); + } catch (_) {} +}; + // id -> { parent, next } — where a borrowed node came from, so it can go home. const _stuHomes = new Map(); @@ -316,6 +361,21 @@ document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(functio tab.addEventListener('click', function () { _stuShowCastView(tab.dataset.stuCastView); }); }); +// Keep book context editing in the compact Studio toolbar. The context is +// deliberately available before identification, without consuming a full +// banner row inside the casting panel. +document.getElementById('stu-book-context-btn')?.addEventListener('click', async function () { + const bookTitle = window.readerState?.title || ''; + if (!bookTitle || typeof window.openBookContextEditor !== 'function') return; + await window.openBookContextEditor(bookTitle); +}); + +document.getElementById('stu-change-book-btn')?.addEventListener('click', function () { + // The Source phase contains the existing uploaded-book cards. Keep the + // current production intact until the user explicitly chooses another. + showStudioPhase(1); +}); + // The borrowed panel's own footer (#ab-cv-foot: "Identify Characters ⌄" / // "Cast Characters ⌄" / "Cast ⌄") is a richer, real action bar (Scan/Verify, // Cast all/Continue uncasted/Cast selected/recast, view/export) that Studio's @@ -449,6 +509,7 @@ function _stuRestoreCastFoot() { function showStudioPhase(n) { _stuActive = n; + _stuSaveResume(n); for (let i = 1; i <= 4; i++) { const el = document.getElementById('stu-phase-' + i); if (el) el.hidden = (i !== n); @@ -488,6 +549,23 @@ function studioOnShow() { // .is-active class has actually been applied by nav.js (which happens // AFTER this hook runs). _stuIsActive = true; + const resume = _stuReadResume(); + if (resume.bookId && window._audiobook) window._audiobook.bookId = resume.bookId; + if (resume.phase >= 1 && resume.phase <= 4) { + _stuActive = resume.phase; + if (resume.characterId) window._libResumeCharacterId = resume.characterId; + } + // A hard refresh clears the in-memory reader document. Reopen the book + // remembered by Studio before entering Characters, otherwise the cast view + // incorrectly reports “Import a document first”. + const resumeBookId = resume.bookId; + const hasCurrentBook = !!(window.readerState?.sentences?.length && window.readerState?.savedId); + if (resumeBookId && !hasCurrentBook && typeof window.readerOpenLibraryDoc === 'function') { + window.readerOpenLibraryDoc(resumeBookId).then(function () { + showStudioPhase(_stuActive); + }); + return; + } showStudioPhase(_stuActive); } window.studioOnShow = studioOnShow; diff --git a/static/samples/ab/stats.json b/static/samples/ab/stats.json new file mode 100644 index 0000000..ff720d5 --- /dev/null +++ b/static/samples/ab/stats.json @@ -0,0 +1 @@ +{"cv_happy_run0": 158, "cv_happy_run1": 269, "cv_happy_run2": 178, "cv_sad_run0": 125, "cv_sad_run1": 116, "cv_sad_run2": 119, "vd_happy_run0": 163, "vd_happy_run1": 238, "vd_happy_run2": 178, "vd_sad_run0": 190, "vd_sad_run1": 130, "vd_sad_run2": 214} \ No newline at end of file diff --git a/static/samples/canon/stats.json b/static/samples/canon/stats.json new file mode 100644 index 0000000..7e486fb --- /dev/null +++ b/static/samples/canon/stats.json @@ -0,0 +1 @@ +{"none": [177, 3.99, 0.108], "[whisper]": [185, 4.37, 0.1021], "[low volume]": [188, 4.04, 0.0982], "[low voice]": [188, 4.04, 0.0982], "[loud]": [180, 3.9, 0.1007], "[volume up]": [183, 4.18, 0.1063], "[screaming]": [195, 3.85, 0.1127], "[shouting]": [186, 3.81, 0.1067], "[sad]": [182, 4.32, 0.0956], "[angry]": [187, 4.04, 0.0974], "[delight]": [181, 4.13, 0.1041], "[shocked]": [182, 4.04, 0.1121]} \ No newline at end of file diff --git a/static/samples/clone/stats.json b/static/samples/clone/stats.json new file mode 100644 index 0000000..e420fb0 --- /dev/null +++ b/static/samples/clone/stats.json @@ -0,0 +1 @@ +[["whisper", 3.92, 0.062159258872270584, 241.72849223335226], ["sad", 4.08, 0.06933707743883133, 231.93486615774808], ["neutral", 3.92, 0.066058449447155, 222.10213617948165], ["scared", 3.92, 0.0758277103304863, 249.98229014031475], ["happy", 3.76, 0.06555065512657166, 233.54039635323997], ["angry", 3.92, 0.07043411582708359, 231.01072213430118]] \ No newline at end of file diff --git a/static/samples/clone/stats_only.json b/static/samples/clone/stats_only.json new file mode 100644 index 0000000..3b71ab9 --- /dev/null +++ b/static/samples/clone/stats_only.json @@ -0,0 +1 @@ +[["whisper", 4.32, 0.07170327752828598, 205.90466589691226], ["sad", 4.16, 0.057622577995061874, 170.46017057067849], ["neutral", 3.84, 0.0707193985581398, 224.89668431811384], ["scared", 4.16, 0.07150360941886902, 238.6658619958772], ["happy", 3.84, 0.07040099054574966, 233.53034816554657], ["angry", 3.92, 0.07094117999076843, 233.24904356308747]] \ No newline at end of file diff --git a/static/samples/cv/stats.json b/static/samples/cv/stats.json new file mode 100644 index 0000000..9a85fed --- /dev/null +++ b/static/samples/cv/stats.json @@ -0,0 +1 @@ +[["whisper", 5.76, 0.0365188904106617, 120.9247432976325], ["sad", 5.92, 0.04318904131650925, 187.38621022379402], ["neutral", 5.12, 0.02466031163930893, 134.47394115948856], ["happy", 4.88, 0.06555867195129395, 187.04688959379465], ["scared", 6.16, 0.05431271716952324, 163.42342261804015], ["angry", 4.08, 0.05320056155323982, 206.1807382039959]] \ No newline at end of file diff --git a/static/samples/en5/stats.json b/static/samples/en5/stats.json new file mode 100644 index 0000000..da879c4 --- /dev/null +++ b/static/samples/en5/stats.json @@ -0,0 +1 @@ +{"simple_neutral": [187, 4.23, 0.0956, "I told you already, and now it is too late for excuses."], "simple_happy": [186, 3.95, 0.0806, "[happy] I told you already, and now it is too late for excuses."], "simple_sad": [186, 4.18, 0.0838, "[sad] I told you already, and now it is too late for excuses."], "simple_angry": [186, 4.32, 0.1009, "[angry] I told you already, and now it is too late for excuses."], "simple_scared": [197, 3.95, 0.1027, "[scared] I told you already, and now it is too late for excuses."], "rich_neutral": [187, 4.23, 0.0956, "I told you already, and now it is too late for excuses."], "rich_happy": [185, 4.04, 0.0898, "[bright and delighted, barely containing a grin] I told you already, and now it is too late for excuses."], "rich_sad": [188, 4.27, 0.0816, "[voice rough from crying, trying to sound normal] I told you already, and now it is too late for excuses."], "rich_angry": [196, 5.25, 0.0796, "[furious, teeth clenched, spitting the words out] I told you already, and now it is too late for excuses."], "rich_scared": [183, 3.95, 0.0946, "[breathless with fear, voice trembling] I told you already, and now it is too late for excuses."]} \ No newline at end of file diff --git a/static/samples/fishmap/stats.json b/static/samples/fishmap/stats.json new file mode 100644 index 0000000..9d71680 --- /dev/null +++ b/static/samples/fishmap/stats.json @@ -0,0 +1 @@ +{"neutral": [160, 3.9, 0.047], "sad": [91, 4.46, 0.0205], "sobbing": [177, 4.6, 0.052], "whispering": [101, 3.95, 0.0211], "calm": [102, 3.81, 0.0244], "resigned": [115, 4.27, 0.0353], "disappointed": [105, 3.99, 0.0231], "tender": [168, 3.44, 0.0413], "curious": [104, 3.99, 0.0274], "confident": [194, 4.32, 0.0591], "sarcastic": [200, 4.04, 0.064], "happy": [203, 3.9, 0.0642], "excited": [230, 4.18, 0.0644], "angry": [226, 4.5, 0.0649], "scared": [223, 4.23, 0.0639], "shouting": [234, 4.04, 0.0649]} \ No newline at end of file diff --git a/static/samples/fixed/stats.json b/static/samples/fixed/stats.json new file mode 100644 index 0000000..5c8e9c1 --- /dev/null +++ b/static/samples/fixed/stats.json @@ -0,0 +1 @@ +[["fish_angry.wav", 4.504671201814059, 0.06494688987731934, 225.75760285080554], ["fish_happy.wav", 3.900952380952381, 0.06421244889497757, 203.42632819142833], ["fish_neutral.wav", 3.900952380952381, 0.0469849593937397, 160.12848521981064], ["fish_sad.wav", 4.458231292517007, 0.02046436071395874, 91.45606740704154], ["fish_scared.wav", 4.2260317460317465, 0.06387874484062195, 223.45062208675228], ["fish_whisper.wav", 3.947392290249433, 0.021135646849870682, 100.54120279187988], ["qwen_angry.wav", 4.48, 0.07321401685476303, 268.64705517416706], ["qwen_happy.wav", 3.84, 0.06605295836925507, 225.1418578552891], ["qwen_neutral.wav", 3.92, 0.03878803923726082, 179.30881848496767], ["qwen_sad.wav", 5.76, 0.04751121252775192, 141.99477795203393], ["qwen_scared.wav", 3.2, 0.0778232142329216, 202.59172414436782], ["qwen_whisper.wav", 4.48, 0.048452772200107574, 128.1539575097085]] \ No newline at end of file diff --git a/static/samples/index.html b/static/samples/index.html new file mode 100644 index 0000000..3778b06 --- /dev/null +++ b/static/samples/index.html @@ -0,0 +1,283 @@ +Emotion samples - book03 + +

Emotion fix - sample comparison

+

Same lines, same voices, run through both backends after the fix. Fish-Speech gets a translated English [tag] baked into the text; Qwen3 Voice Design gets the native German instruct sentence, unchanged.

+ +

1. Book-realistic emotions (as the LLM actually assigns them, e.g. "befehlend", "resigniert")

+

MARCIAN

EmotionFish-SpeechQwen3 Voice Design
befehlend
kalt und zynisch
fragend
entschlossen
ernst

LYSANDRA

EmotionFish-SpeechQwen3 Voice Design
resigniert
besorgt
abweisend
verächtlich
schroff

DARRAG

EmotionFish-SpeechQwen3 Voice Design
befehlend
entschlossen
dringend
drohend
überrascht
+ +

2. Basic emotions (happy / sad / angry / scared / excited)

+

MARCIAN

EmotionFish-SpeechQwen3 Voice Design
happy (glücklich)
sad (traurig)
angry (wütend)
scared (ängstlich)
excited (aufgeregt)

LYSANDRA

EmotionFish-SpeechQwen3 Voice Design
happy (glücklich)
sad (traurig)
angry (wütend)
scared (ängstlich)
excited (aufgeregt)

DARRAG

EmotionFish-SpeechQwen3 Voice Design
happy (glücklich)
sad (traurig)
angry (wütend)
scared (ängstlich)
excited (aufgeregt)
+ +

3. Controlled test — SAME exact line, only the tag/instruct changes

+

This isolates the emotion variable cleanly (sections 1 & 2 above used different lines per emotion). Measured acoustics (pitch/loudness) are shown next to each — Fish-Speech shows a large, consistent shift (sad/whisper much lower & quieter, angry/happy higher & louder); Qwen3 shows a strong shift for "happy" but almost none for "angry".

+

Fish-Speech

+ + + + + + +
VersionAudioPitch (Hz)Loudness (RMS)
Neutral (no tag)1600.047
[angry]2260.065
[happy]2030.064
[sad]920.021
[whisper]840.020
+

Qwen3 Voice Design

+ + + + +
VersionAudioPitch (Hz)Loudness (RMS)
Neutral1580.057
Angry1580.065
Happy3100.072
+ +

4. English text/voice test (same character-role voice, English content)

+

Testing whether emotion differentiation is stronger in English than German. Pitch numbers below are measured (not felt) — Fish-Speech barely moves at all in English (72-80Hz across every emotion); Qwen3 Voice Design shows a genuinely clean, coherent gradient: sad < angry < shocked < happy. This is the clearest sign yet that Qwen3 Voice Design's emotional differentiation is much stronger in English than German — Fish-Speech stays weak regardless of language.

+

Fish-Speech (English)

+ + + + + + + +
EmotionAudioPitch (Hz)Loudness
Neutral760.080
Whispering750.066
Sad800.073
Angry730.088
Happy720.082
Shocked800.077
+

Qwen3 Voice Design (English)

+ + + + + + + +
EmotionAudioPitch (Hz)Loudness
Neutral1090.066
Whispering(breathy, pitch untrackable)0.040
Sad2740.049
Angry2970.090
Shocked3480.093
Happy3980.091
+ +

5. German text + English instruction (Qwen3 Voice Design, Marcian's cloned voice)

+

Same German dialogue line, but the instruct sentence is written in English instead of German, to see if that captures some of section 4's improvement while keeping the actual book text in German. Angry stood out clearly (340Hz vs ~200Hz baseline); sad/happy/shocked stayed muddled together.

+ + + + + + +
EmotionAudioPitch (Hz)Loudness
Neutral2230.062
Sad2010.072
Angry3400.097
Happy2220.074
Shocked1750.068
+ +

6. ⚠ RE-TEST on the FIXED build (this is the one that matters)

+

Everything in sections 1–5 above was generated while a JavaScript bug was silently breaking the app. +A typeof check on a const in its temporal dead zone was throwing during startup, which stopped +the whole bundle from initializing past that point. Those earlier samples were measuring a half-working app, so please +disregard them. Below is the same controlled test (identical German line, identical voice, only the emotion varying) +re-run after the fix — plus emotion instructions are now written in English, which Qwen3 follows far more reliably.

+

Qwen3 Voice Design — German text, English instruct

+

Now a clean, correctly-ordered gradient, and note the durations vary sensibly too: sad drags (5.76s), scared rushes (3.20s). That's real prosody, not just volume.

+
EmotionAudioPitch (Hz)LoudnessDuration
Whisper1280.0484.48s
Sad1420.0485.76s
Neutral1790.0393.92s
Scared2030.0783.20s
Happy2250.0663.84s
Angry2690.0734.48s
+

Fish-Speech — inline [tag]

+

Still essentially two registers (quiet/low: whisper+sad — loud/high: everything else). Scared/happy/angry remain hard to tell apart.

+
EmotionAudioPitch (Hz)LoudnessDuration
Whisper1010.0213.95s
Sad910.0204.46s
Neutral1600.0473.90s
Scared2230.0644.23s
Happy2030.0643.90s
Angry2260.0654.50s
+ +

7. ★ THE ACTUAL FIX — voice_clone backend (read this one)

+

Why everything above was misleading: Voice Design re-rolls the voice on every single call. +I generated the identical prompt 4 times and got 174, 232, 174 and 264 Hz — an 89 Hz spread with nothing +changed. That is the same size as the "emotion differences" I was reporting, so those numbers were mostly random noise. +It also explains what you heard: the male→female jumps, and "sad sounds happy". Voice Design designs a new +voice from the prompt; it is not a stable speaker, so it is the wrong tool for an audiobook.

+

voice_clone is deterministic — the same input produced bit-identical audio twice over. +So on this backend, any difference you hear is genuinely the emotion, not a re-roll. Character identity holds +(measured timbre drift ≤ 0.011).

+

A. Emotion instruct alone — recommended

+
EmotionAudioPitchDurationLoudness
Whisper2064.32s0.072
Sad1704.16s0.058
Neutral2253.84s0.071
Scared2394.16s0.072
Happy2343.84s0.070
Angry2333.92s0.071
+

B. Same, but with the accent clause appended — emotion collapses

+

Two style directives in one instruct compete and the trailing one wins: the range squashes to +222–250 Hz and sad all but disappears. I've now dropped the accent clause whenever a line carries an +emotion — a cloned voice already gets its accent from the reference WAV.

+
EmotionAudioPitchDurationLoudness
Whisper2423.92s0.062
Sad2324.08s0.069
Neutral2223.92s0.066
Scared2503.92s0.076
Happy2343.76s0.066
Angry2313.92s0.070
+ +

8. Different characters — you were right about the shouting

+

Marcian's reference clip is the culprit for the shouting: it is only 3.1 seconds long and sits at +253 Hz, which is shouting range for a male voice, with the narrowest dynamic range of any voice checked. +Cloning reproduces that faithfully, so every Marcian line shouts no matter what emotion is requested.

+

But swapping to calm voices exposes the deeper problem. On these two the emotional pitch range is +only 19–22 Hz — barely audible. Marcian's wider 69 Hz spread was mostly just his shouty baseline having room +to fall, not better emotional control. So voice_clone keeps identity rock-steady but delivers only weak emotion.

+

DE_M_Darrag — calm 110 Hz reference

+
EmotionAudioPitchDuration
Whisper1124.56s
Sad1094.32s
Neutral1114.72s
Happy1104.40s
Angry1314.48s
+

Ernst Meinecke — professional narrator, 21s reference

+
EmotionAudioPitchDuration
Whisper1455.68s
Sad1354.88s
Neutral1545.12s
Happy1496.08s
Angry1515.28s
+

Where this leaves us: +Fish-Speech → two registers only, everything shouts. Voice Design → strong changes but re-rolls the voice every +call, so identity drifts and results are random. voice_clone → perfectly stable identity but only ~20 Hz of emotion. +None of the three running backends does both. CustomVoice (port 8022) is down — per the Qwen3-TTS +docs that is the variant built specifically for timbre control plus emotional style, and it is the one option not yet tested.

+ +

9. You were right — it's the model, not the settings

+

I checked what each container actually loads, and your hypothesis was exactly correct:

+ + + + + + +
BackendModel loadedInstruction / emotion control?
voice_clone (8020)Qwen3-TTS-12Hz-1.7B-BaseNo
streaming (8023)same Base modelNo
VoiceDesign (8021)...-VoiceDesignYes
CustomVoice (8022)...-CustomVoiceYes
+

So my earlier "voice_clone responds to emotion" claim was wrong — that was a Base model being mildly +perturbed by instruction text it does not understand, which is why the range was only 19–22 Hz. I have also +confirmed on HuggingFace that no "Instruct" variant of Qwen3-TTS exists — the family is exactly these three.

+

CustomVoice, started and tested (speaker Ryan, German text)

+

This is the strongest real emotion control measured so far: 85 Hz range, with speaker identity +holding (timbre drift ≤0.008) and sensible pacing (angry fast 4.08s, scared slow 6.16s). Repeat runs confirm the effect is +genuine, not noise — sad lands at 111–136 Hz and angry at 159–210 Hz, ranges that never overlap.

+
EmotionAudioPitchDurationLoudness
Whisper1215.76s0.037
Sad1875.92s0.043
Neutral1345.12s0.025
Happy1874.88s0.066
Scared1636.16s0.054
Angry2064.08s0.053
+

The catch: CustomVoice exposes only +9 fixed speakers (Ryan, Aiden, Vivian, Serena, Uncle_Fu, Dylan, Eric…) and its API has no reference-audio +field at all — it cannot clone. So it cannot voice the book's 57 cloned characters. Qwen3-TTS splits cloning and emotion +across two models that cannot be combined: Base clones but ignores instructions, CustomVoice obeys instructions but cannot clone, +VoiceDesign obeys instructions but invents a new voice on every call.

+ +

10. VoiceDesign vs CustomVoice — the consistency problem

+

Your plan (narrator on voice_clone, characters on VoiceDesign) is sound in structure — +the narrator on voice_clone is exactly right: identity is rock-steady and he needs no emotion. +But before committing 50 hours to VoiceDesign for the characters, listen to this.

+

Each block below is the same character, same line, same emotion, called three times. +Nothing changes between runs. VoiceDesign has no seed parameter, so it cannot be pinned.

+

VoiceDesign — "happy" ×3 (identical requests)

+
#AudioPitch
Run 1163 Hz
Run 2238 Hz
Run 3178 Hz
spread across identical calls75 Hz
+

VoiceDesign — "sad" ×3 (identical requests)

+
#AudioPitch
Run 1190 Hz
Run 2130 Hz
Run 3214 Hz
spread across identical calls84 Hz
+

CustomVoice — "sad" ×3 (identical requests)

+
#AudioPitch
Run 1125 Hz
Run 2116 Hz
Run 3119 Hz
spread across identical calls9 Hz
+

CustomVoice — "happy" ×3 (identical requests)

+
#AudioPitch
Run 1158 Hz
Run 2269 Hz
Run 3178 Hz
spread across identical calls111 Hz
+

+Averaged over 3 runs each, VoiceDesign's emotions do not separate reliably: neutral 136 Hz, +sad 158, happy 231, angry 237 — but "happy" alone ranged 122–311 Hz across identical calls. The variation +within one emotion is larger than the difference between emotions, and sad landed above neutral +(backwards). Over ~2000 lines that means a character whose pitch wanders line to line.

+CustomVoice's "sad" by contrast is tight (116–125 Hz, 9 Hz spread) — genuinely reliable. Its weakness is +coverage: 9 fixed speakers, no cloning.

+ +

11. Fish-Speech — I was wrong, and it may be the best option

+

Correction: Fish-Speech is perfectly deterministic. The same request twice produced +byte-identical audio (same MD5, 0 Hz spread) because the app pins a per-voice seed. That changes the picture completely:

+ + + + + +
Enginesad→angry separationRun-to-run noiseVerdict
Fish-Speech135 Hz0 Hzsignal, no noise
CustomVoice51 Hz26–51 Hzsignal ≈ noise
VoiceDesign101 Hzup to 189 Hznoise > signal
+

And it is three registers, not two — I under-sold it earlier. Because output is deterministic, +this map is exact rather than a sample. Emotion tags reliably land in one of three intensity levels:

+

Subdued — quiet, low

TagAudioPitchDurLoudness
sad91 Hz4.46s0.0205
whispering101 Hz3.95s0.0211
calm102 Hz3.81s0.0244
disappointed105 Hz3.99s0.0231
curious104 Hz3.99s0.0274
resigned115 Hz4.27s0.0353

Neutral / warm — mid

TagAudioPitchDurLoudness
neutral160 Hz3.9s0.047
tender168 Hz3.44s0.0413
sobbing177 Hz4.6s0.052

Energized — loud, high

TagAudioPitchDurLoudness
confident194 Hz4.32s0.0591
sarcastic200 Hz4.04s0.064
happy203 Hz3.9s0.0642
scared223 Hz4.23s0.0639
angry226 Hz4.5s0.0649
excited230 Hz4.18s0.0644
shouting234 Hz4.04s0.0649
+

+What this means for the book: Fish clones all 57 characters, holds every identity perfectly stable, and +gives three dependable intensity levels — subdued for sad/whispered/resigned lines, neutral for ordinary narration, +energized for angry/excited/frightened ones. What it will not do is distinguish angry from happy: both sit in the +energized band (226 vs 203 Hz). The emotional contour of a scene comes through; the specific colour does not.

+Determinism has a practical bonus too: a re-run reproduces identical audio, so an interrupted export resumes cleanly +instead of drifting.

+ +

12. English voice + English bracket tags (as requested) — and an important correction

+

Voice: EN_F_Melina (best dynamic range of the English references, 10.4s clip). +English text, English tags in square brackets before the line, per the S2 word-level-control article. +I tested both plain single-word tags and the article's richer free-form style (e.g. its own example +[voice rough from crying, trying to sound normal], and pairing a physical reaction with an emotion).

+

Simple single-word tags

+
EmotionTagAudioPitchDur
neutral(no tag)187 Hz4.23s
happy[happy]186 Hz3.95s
sad[sad]186 Hz4.18s
angry[angry]186 Hz4.32s
scared[scared]197 Hz3.95s
+

Rich descriptive tags (article's recommended style)

+
EmotionTagAudioPitchDur
neutral(no tag)187 Hz4.23s
happy[bright and delighted, barely containing a grin]185 Hz4.04s
sad[voice rough from crying, trying to sound normal]188 Hz4.27s
angry[furious, teeth clenched, spitting the words out]196 Hz5.25s
scared[breathless with fear, voice trembling]183 Hz3.95s
+

+Correction to section 11 — I over-claimed. Both tag styles above land within +183–197 Hz, i.e. essentially no emotional differentiation, and the richer tags were no better +than the simple ones. Checking further, every other voice behaves the same way:

+ + + + + + + + +
Voicesadangryrange
DE_M_Marcian91 Hz226 Hz135 Hz
DE_M_Darrag86948 Hz
DE_F_Lysandra18617610 Hz
Ernst Meinecke (narrator)1091112 Hz
EN_M_Alan_Rickman72808 Hz
EN_M_Albus_Dumbledore1891845 Hz

+Marcian is the single outlier — and section 11's whole "three registers" map was built on him alone. +His reference clip is an anomalous 3.1-second shout at 253 Hz, so a [sad] tag has enormous room to pull him +down. Normal, well-recorded references barely move at all. I also tested whether reference length was the cause by +truncating two voices to 3 seconds — it made no difference, so it is the unusual character of that particular clip, +not its duration.

+ +

13. ★ How Fish-Speech S2 emotion ACTUALLY works

+

I investigated the mechanism instead of guessing. The finding explains every confusing result above:

+

+Fish-Speech emotion is driven by the REFERENCE CLIP, not by the text tags.
+When you supply reference audio, S2 does in-context prosody imitation — it copies the reference's delivery, and that +overrides bracket tags almost entirely. Tags only work when there is no reference competing with them.

+

Evidence A — the same tags, with and without a reference

+ + + + +
TagNo referenceWith reference (cloning)
[sad]loudness 0.051 (−40%), 21% slower0.096 (−11%)
[angry]0.094, dynamics 0.324, darker timbre0.097, dynamics 0.245
[whispering]0.054 (−36%)0.091 (−16%)
+

Evidence B — emotion transfers through the reference with NO tag at all

+

Identical plain sentence, no bracket tags anywhere. Only the reference clip changed:

+ + + + + +
Reference usedPitchLoudnessBrightness
calm / neutral183 Hz0.0752699
scared reference106 Hz0.022 (5× quieter)2380
angry reference121 Hz0.108 (loud)1778 (dark)
happy reference210 Hz0.1062177
+

The 5 samples you asked for — tags with no competing reference

+

These finally differentiate: 10× loudness spread, pitch 110–222 Hz, and genuine +intonation differences (happy pstd 45 vs scared pstd 5). Caveat: with no reference the model also picks its own voice, +so identity is not controlled here (speaker distance 0.027 vs 0.011 for a true clone).

+
EmotionTag usedAudioPitchDurLoudness
neutral(no tag)182 Hz3.58s0.084
happy[cheerful and bright, smiling as she speaks]222 Hz4.27s0.0997
sad[voice rough from crying, trying to sound normal]155 Hz3.2s0.2098
angry[furious, teeth clenched, spitting the words out]129 Hz3.72s0.1037
scared[breathless with fear, voice trembling]110 Hz4.13s0.0208
+

+What this means — and the way forward. The trade-off is inherent: a reference gives you the character's +identity but suppresses tag-based emotion; no reference gives you emotion but an uncontrolled voice. But Evidence B points +at a real solution: give each character several reference clips, one per emotional register (neutral, +subdued, intense) and pick the matching one per line. That uses the mechanism instead of fighting it — delivering +cloned identity and emotion, both fully deterministic.

+ +

14. ★ THE PROOF — same text, same tags, cloning on vs off

+

Your five-line example, generated twice through Fish Audio S2 Pro. Identical text, identical tags. +The only difference is whether a cloned reference voice was supplied.

+

A. WITH a cloned voice (DE_F_Einfaches_Maedchen) — what you heard

+ +

Pitch across all five emotions: 241–268 Hz — a 27 Hz spread. Every line lands in the +same place, which is exactly what you reported: "no difference between the lines".

+

B. WITHOUT a reference voice — same text, same tags

+ + + + + + +
LinePitchLoudness
[happy]293 Hz0.194 (loudest)
[uncertain]237 Hz0.148
[sad]184 Hz0.125
[hopeful]207 Hz0.084
[determined]170 Hz0.061 (quietest)
+

123 Hz spread and 3.2× loudness range — four to five times the differentiation, in a +sensible order (happy highest and loudest, determined lowest and quietest). Because this is one single generation, the +voice stays consistent throughout; the earlier "5 different voices" problem only happened when each emotion was generated +as its own separate call.

+

+This is the trade-off in one page: Fish gives you the cloned character voice, or the emotion tags — never both. +The reference clip is prepended as an in-context example and the model imitates its delivery, which overrides the tags.

+ +

15. Controlling the voice WITHOUT a reference clip

+

If no reference clip is used, the voice can still be steered — by describing it in a tag. +S2-Pro accepts free-form descriptions, so gender, age and character are controllable directly:

+
Description tagAudioPitch
(no tag)182 Hz
[deep male voice]91 Hz
[young woman's voice]191 Hz
[elderly man, frail and raspy]93 Hz
[small child, high voice]202 Hz
[gruff old warrior, deep and gravelly]72 Hz
+

That is a 72–202 Hz range — clearly distinguishable men, women and children, all without any reference clip.

+

Reproducible: same description + same seed = identical audio

+

Three runs of the same description and seed produced byte-identical files (matching MD5). +So a character can be pinned as "description + seed" and will sound the same every time — this fixes the +"5 different voices" problem from section 13.

+

The catch: emotion tags also shift the voice

+

Same character description throughout, only the emotion tag changes. Emotion clearly works +(174 Hz range, 6.4× loudness) — but the character drifts as well. "Identity drift" below compares timbre +against the neutral take; a true cloned voice stays at 0.001–0.011, and roughly 0.03+ starts to read as a different person.

+
TagAudioPitchLoudnessIdentity drift
(no emotion tag)72 Hz0.033—
[happy]174 Hz0.1130.033
[sad]—0.2090.212
[angry]123 Hz0.2010.044
[whisper]81 Hz0.1000.069
+

+Please judge this by ear — do these five still sound like the same person, or like different speakers? +If they hold together well enough, this is a workable path for the audiobook: define each character as a description plus a +fixed seed, and emotion tags will actually function. If they do not, then cloned voices with neutral delivery remains the +honest option.

+ \ No newline at end of file diff --git a/static/samples/manifest.json b/static/samples/manifest.json new file mode 100644 index 0000000..52527f3 --- /dev/null +++ b/static/samples/manifest.json @@ -0,0 +1,272 @@ +[ + { + "char": "MARCIAN", + "emotion": "befehlend", + "backend": "fishspeech", + "file": "marcian_1_befehlend_fish.mp3", + "bytes": 38470, + "secs": 17.4, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "befehlend", + "backend": "voice_design", + "file": "marcian_1_befehlend_qwen3design.mp3", + "bytes": 20013, + "secs": 4.0, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "kalt und zynisch", + "backend": "fishspeech", + "file": "marcian_2_kalt_und_zynisch_fish.mp3", + "bytes": 23214, + "secs": 10.9, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "kalt und zynisch", + "backend": "voice_design", + "file": "marcian_2_kalt_und_zynisch_qwen3design.mp3", + "bytes": 17709, + "secs": 3.6, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "fragend", + "backend": "fishspeech", + "file": "marcian_3_fragend_fish.mp3", + "bytes": 39097, + "secs": 18.3, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "fragend", + "backend": "voice_design", + "file": "marcian_3_fragend_qwen3design.mp3", + "bytes": 19629, + "secs": 4.5, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "entschlossen", + "backend": "fishspeech", + "file": "marcian_4_entschlossen_fish.mp3", + "bytes": 57069, + "secs": 25.7, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "entschlossen", + "backend": "voice_design", + "file": "marcian_4_entschlossen_qwen3design.mp3", + "bytes": 26349, + "secs": 4.9, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "ernst", + "backend": "fishspeech", + "file": "marcian_5_ernst_fish.mp3", + "bytes": 32200, + "secs": 13.3, + "ok": true + }, + { + "char": "MARCIAN", + "emotion": "ernst", + "backend": "voice_design", + "file": "marcian_5_ernst_qwen3design.mp3", + "bytes": 15501, + "secs": 2.9, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "resigniert", + "backend": "fishspeech", + "file": "lysandra_1_resigniert_fish.mp3", + "bytes": 39515, + "secs": 15.6, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "resigniert", + "backend": "voice_design", + "file": "lysandra_1_resigniert_qwen3design.mp3", + "bytes": 25389, + "secs": 4.6, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "besorgt", + "backend": "fishspeech", + "file": "lysandra_2_besorgt_fish.mp3", + "bytes": 20289, + "secs": 8.1, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "besorgt", + "backend": "voice_design", + "file": "lysandra_2_besorgt_qwen3design.mp3", + "bytes": 14829, + "secs": 2.5, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "abweisend", + "backend": "fishspeech", + "file": "lysandra_3_abweisend_fish.mp3", + "bytes": 19871, + "secs": 7.9, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "abweisend", + "backend": "voice_design", + "file": "lysandra_3_abweisend_qwen3design.mp3", + "bytes": 10413, + "secs": 1.9, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "verächtlich", + "backend": "fishspeech", + "file": "lysandra_4_verachtlich_fish.mp3", + "bytes": 48919, + "secs": 18.6, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "verächtlich", + "backend": "voice_design", + "file": "lysandra_4_verachtlich_qwen3design.mp3", + "bytes": 20973, + "secs": 3.7, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "schroff", + "backend": "fishspeech", + "file": "lysandra_5_schroff_fish.mp3", + "bytes": 35544, + "secs": 13.9, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion": "schroff", + "backend": "voice_design", + "file": "lysandra_5_schroff_qwen3design.mp3", + "bytes": 17709, + "secs": 3.1, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "befehlend", + "backend": "fishspeech", + "file": "darrag_1_befehlend_fish.mp3", + "bytes": 47038, + "secs": 18.4, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "befehlend", + "backend": "voice_design", + "file": "darrag_1_befehlend_qwen3design.mp3", + "bytes": 22893, + "secs": 4.0, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "entschlossen", + "backend": "fishspeech", + "file": "darrag_2_entschlossen_fish.mp3", + "bytes": 19453, + "secs": 7.9, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "entschlossen", + "backend": "voice_design", + "file": "darrag_2_entschlossen_qwen3design.mp3", + "bytes": 13293, + "secs": 2.3, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "dringend", + "backend": "fishspeech", + "file": "darrag_3_dringend_fish.mp3", + "bytes": 16945, + "secs": 7.0, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "dringend", + "backend": "voice_design", + "file": "darrag_3_dringend_qwen3design.mp3", + "bytes": 9069, + "secs": 1.7, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "drohend", + "backend": "fishspeech", + "file": "darrag_4_drohend_fish.mp3", + "bytes": 26140, + "secs": 10.3, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "drohend", + "backend": "voice_design", + "file": "darrag_4_drohend_qwen3design.mp3", + "bytes": 13869, + "secs": 2.5, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "überrascht", + "backend": "fishspeech", + "file": "darrag_5_uberrascht_fish.mp3", + "bytes": 18408, + "secs": 7.6, + "ok": true + }, + { + "char": "DARRAG", + "emotion": "überrascht", + "backend": "voice_design", + "file": "darrag_5_uberrascht_qwen3design.mp3", + "bytes": 7533, + "secs": 1.4, + "ok": true + } +] \ No newline at end of file diff --git a/static/samples/manifest_basic.json b/static/samples/manifest_basic.json new file mode 100644 index 0000000..bb0505b --- /dev/null +++ b/static/samples/manifest_basic.json @@ -0,0 +1,302 @@ +[ + { + "char": "MARCIAN", + "emotion_de": "glücklich", + "emotion_en": "happy", + "backend": "fishspeech", + "file": "basic_marcian_1_happy_fish.mp3", + "bytes": 40351, + "secs": 15.2, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "glücklich", + "emotion_en": "happy", + "backend": "voice_design", + "file": "basic_marcian_1_happy_qwen3design.mp3", + "bytes": 19341, + "secs": 3.2, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "traurig", + "emotion_en": "sad", + "backend": "fishspeech", + "file": "basic_marcian_2_sad_fish.mp3", + "bytes": 23841, + "secs": 8.8, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "traurig", + "emotion_en": "sad", + "backend": "voice_design", + "file": "basic_marcian_2_sad_qwen3design.mp3", + "bytes": 13293, + "secs": 2.3, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "wütend", + "emotion_en": "angry", + "backend": "fishspeech", + "file": "basic_marcian_3_angry_fish.mp3", + "bytes": 37634, + "secs": 13.8, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "wütend", + "emotion_en": "angry", + "backend": "voice_design", + "file": "basic_marcian_3_angry_qwen3design.mp3", + "bytes": 20589, + "secs": 3.5, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "ängstlich", + "emotion_en": "scared", + "backend": "fishspeech", + "file": "basic_marcian_4_scared_fish.mp3", + "bytes": 25095, + "secs": 9.3, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "ängstlich", + "emotion_en": "scared", + "backend": "voice_design", + "file": "basic_marcian_4_scared_qwen3design.mp3", + "bytes": 11373, + "secs": 1.9, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "aufgeregt", + "emotion_en": "excited", + "backend": "fishspeech", + "file": "basic_marcian_5_excited_fish.mp3", + "bytes": 10884, + "secs": 4.4, + "ok": true + }, + { + "char": "MARCIAN", + "emotion_de": "aufgeregt", + "emotion_en": "excited", + "backend": "voice_design", + "file": "basic_marcian_5_excited_qwen3design.mp3", + "bytes": 6861, + "secs": 1.3, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "glücklich", + "emotion_en": "happy", + "backend": "fishspeech", + "file": "basic_lysandra_1_happy_fish.mp3", + "bytes": 31364, + "secs": 12.6, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "glücklich", + "emotion_en": "happy", + "backend": "voice_design", + "file": "basic_lysandra_1_happy_qwen3design.mp3", + "bytes": 16173, + "secs": 2.8, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "traurig", + "emotion_en": "sad", + "backend": "fishspeech", + "file": "basic_lysandra_2_sad_fish.mp3", + "bytes": 12347, + "secs": 5.5, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "traurig", + "emotion_en": "sad", + "backend": "voice_design", + "file": "basic_lysandra_2_sad_qwen3design.mp3", + "bytes": 6573, + "secs": 1.1, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "wütend", + "emotion_en": "angry", + "backend": "fishspeech", + "file": "basic_lysandra_3_angry_fish.mp3", + "bytes": 20915, + "secs": 8.1, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "wütend", + "emotion_en": "angry", + "backend": "voice_design", + "file": "basic_lysandra_3_angry_qwen3design.mp3", + "bytes": 10029, + "secs": 1.7, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "ängstlich", + "emotion_en": "scared", + "backend": "fishspeech", + "file": "basic_lysandra_4_scared_fish.mp3", + "bytes": 23841, + "secs": 8.9, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "ängstlich", + "emotion_en": "scared", + "backend": "voice_design", + "file": "basic_lysandra_4_scared_qwen3design.mp3", + "bytes": 12909, + "secs": 2.2, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "aufgeregt", + "emotion_en": "excited", + "backend": "fishspeech", + "file": "basic_lysandra_5_excited_fish.mp3", + "bytes": 46620, + "secs": 16.6, + "ok": true + }, + { + "char": "LYSANDRA", + "emotion_de": "aufgeregt", + "emotion_en": "excited", + "backend": "voice_design", + "file": "basic_lysandra_5_excited_qwen3design.mp3", + "bytes": 24813, + "secs": 4.0, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "glücklich", + "emotion_en": "happy", + "backend": "fishspeech", + "file": "basic_darrag_1_happy_fish.mp3", + "bytes": 48083, + "secs": 17.2, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "glücklich", + "emotion_en": "happy", + "backend": "voice_design", + "file": "basic_darrag_1_happy_qwen3design.mp3", + "bytes": 21933, + "secs": 9.2, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "traurig", + "emotion_en": "sad", + "backend": "fishspeech", + "file": "basic_darrag_2_sad_fish.mp3", + "bytes": 18826, + "secs": 6.9, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "traurig", + "emotion_en": "sad", + "backend": "voice_design", + "file": "basic_darrag_2_sad_qwen3design.mp3", + "bytes": 10413, + "secs": 1.7, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "wütend", + "emotion_en": "angry", + "backend": "fishspeech", + "file": "basic_darrag_3_angry_fish.mp3", + "bytes": 14228, + "secs": 5.4, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "wütend", + "emotion_en": "angry", + "backend": "voice_design", + "file": "basic_darrag_3_angry_qwen3design.mp3", + "bytes": 7821, + "secs": 1.3, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "ängstlich", + "emotion_en": "scared", + "backend": "fishspeech", + "file": "basic_darrag_4_scared_fish.mp3", + "bytes": 24259, + "secs": 8.7, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "ängstlich", + "emotion_en": "scared", + "backend": "voice_design", + "file": "basic_darrag_4_scared_qwen3design.mp3", + "bytes": 10989, + "secs": 1.8, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "aufgeregt", + "emotion_en": "excited", + "backend": "fishspeech", + "file": "basic_darrag_5_excited_fish.mp3", + "bytes": 17990, + "secs": 6.7, + "ok": true + }, + { + "char": "DARRAG", + "emotion_de": "aufgeregt", + "emotion_en": "excited", + "backend": "voice_design", + "file": "basic_darrag_5_excited_qwen3design.mp3", + "bytes": 8781, + "secs": 1.5, + "ok": true + } +] \ No newline at end of file diff --git a/static/samples/voicectl/stats.json b/static/samples/voicectl/stats.json new file mode 100644 index 0000000..45aed37 --- /dev/null +++ b/static/samples/voicectl/stats.json @@ -0,0 +1 @@ +{"no_tag": 182, "deep_male": 91, "young_woman": 191, "elderly_man": 93, "little_girl": 202, "gruff_old_warrior": 72} \ No newline at end of file diff --git a/static/samples/works/stats.json b/static/samples/works/stats.json new file mode 100644 index 0000000..36b0c64 --- /dev/null +++ b/static/samples/works/stats.json @@ -0,0 +1 @@ +{"neutral": {"pitch": 182, "pstd": 13, "dur": 3.58, "rms": 0.084, "dyn": 0.189, "bright": 4028, "tag": ""}, "happy": {"pitch": 222, "pstd": 45, "dur": 4.27, "rms": 0.0997, "dyn": 0.237, "bright": 2998, "tag": "[cheerful and bright, smiling as she speaks]"}, "sad": {"pitch": 155, "pstd": 26, "dur": 3.2, "rms": 0.2098, "dyn": 0.483, "bright": 3302, "tag": "[voice rough from crying, trying to sound normal]"}, "angry": {"pitch": 129, "pstd": 22, "dur": 3.72, "rms": 0.1037, "dyn": 0.315, "bright": 2972, "tag": "[furious, teeth clenched, spitting the words out]"}, "scared": {"pitch": 110, "pstd": 5, "dur": 4.13, "rms": 0.0208, "dyn": 0.085, "bright": 3658, "tag": "[breathless with fear, voice trembling]"}} \ No newline at end of file diff --git a/static/sections/s-caststudio.html b/static/sections/s-caststudio.html index 87f2090..defc9f7 100644 --- a/static/sections/s-caststudio.html +++ b/static/sections/s-caststudio.html @@ -46,6 +46,8 @@