Add unified Studio casting workflow and fix voice/casting pipeline bugs

Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-26 02:03:56 +02:00
parent f6e5449eb6
commit ea50267c30
42 changed files with 10099 additions and 1221 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
1.14.24
1.17.95

View File

@ -39,6 +39,19 @@ _SETTINGS_KEYS = {
"client_voice_bindings",
"llm_url", "llm_model", "llm_api_key",
"engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards",
# Character portrait generation (cloud APIs + local ComfyUI)
"image_gen_provider", "image_gen_model",
"comfyui_url", "comfyui_workflow", "comfyui_prompt_node_id",
"comfyui_prompt_field", "comfyui_output_node_id",
# Inbound API key — gates non-browser callers (external scripts, MCP
# clients) hitting this app's own /api/* and /mcp routes. Distinct from
# every *outbound* key above, which are credentials this app sends to
# OTHER services. Off by default (external_api_key_required=False) —
# this is a live, actively-used app and the same-origin detection this
# gate relies on has never been exercised against real browser traffic,
# so enforcing it unconditionally risked locking out the working UI on
# an untested edge case. Turn it on deliberately once confirmed safe.
"external_api_key", "external_api_key_required",
# Browser-persistent UI state
"refine_llm_url", "conv_llm_url", "seed_finder_text",
"seed_finder_dir", "pt_dir", "audiobook_prompt",
@ -51,7 +64,17 @@ _TTS_STABILITY_BY_BACKEND_DEFAULT = {
"voice_clone": dict(_TTS_STABILITY_DEFAULT),
"streaming": dict(_TTS_STABILITY_DEFAULT),
"customvoice": dict(_TTS_STABILITY_DEFAULT),
"voice_design": dict(_TTS_STABILITY_DEFAULT),
# NOT voice_design: the stability block's fixed seed=0 + temperature=0.1
# exists so repeated reads of the SAME cloned voice sound consistent
# across takes — exactly backwards for voice design, where every call is
# supposed to produce a DIFFERENT voice from a different character
# prompt. Pinning the model's random draw meant the prompt text was the
# only source of variation, and low temperature flattened even that —
# confirmed live: auto-designed voices for different characters all
# sounded near-identical. Leaving this empty lets the backend use its
# own natural randomization per call, same as the other creative-voice
# backends below.
"voice_design": {},
"nvidia_magpie": {},
"nvidia_zeroshot": {},
"nvidia_flow": {},
@ -286,6 +309,15 @@ def _load_settings() -> dict:
"engine_container_names": {},
"engine_api_keys": {},
"custom_engine_cards": [],
"image_gen_provider": "",
"image_gen_model": "",
"comfyui_url": "http://host.docker.internal:8188",
"comfyui_workflow": "",
"comfyui_prompt_node_id": "",
"comfyui_prompt_field": "text",
"comfyui_output_node_id": "",
"external_api_key": "",
"external_api_key_required": False,
"refine_llm_url": "",
"conv_llm_url": "",
"seed_finder_text": "",
@ -316,6 +348,21 @@ def _load_settings() -> dict:
return dict(result)
def _ensure_external_api_key() -> str:
"""Returns the app's inbound API key, generating + persisting one on
first use. Called lazily by the auth middleware rather than at startup,
so a fresh install doesn't need a migration step."""
import secrets
settings = _load_settings()
key = (settings.get("external_api_key") or "").strip()
if key:
return key
key = secrets.token_urlsafe(32)
settings["external_api_key"] = key
_save_settings(settings)
return key
def _save_settings(s: dict) -> None:
global _settings_cache, _settings_cache_mtime, _settings_cache_db_updated
CONFIG_DIR.mkdir(parents=True, exist_ok=True)

View File

@ -134,6 +134,10 @@ def _row_to_reh(row: sqlite3.Row) -> dict:
return d
def _reh_title_key(title: Any) -> str:
return " ".join(str(title or "").split()).casefold()
# ── Characters ────────────────────────────────────────────────────────────────
def char_get_all() -> list[dict]:
@ -149,6 +153,15 @@ def char_get(char_id: str) -> dict | None:
def char_put(rec: dict) -> dict:
image = rec.get("image")
if isinstance(image, str) and image.startswith("/api/characters/"):
# /api/characters (the bulk list) hands out a lightweight image URL
# instead of the real base64 blob (see routes/characters.py). A record
# round-tripped from that list and saved back here would otherwise
# silently overwrite the real stored portrait with this placeholder
# string — keep whatever is already on the row instead.
existing = char_get(rec.get("id", ""))
rec = {**rec, "image": existing.get("image") if existing else None}
with _open() as conn:
conn.execute("""
INSERT INTO characters (id, book, name, tags, voice, image, sheet, analysis, created, updated)
@ -196,11 +209,63 @@ def reh_get(reh_id: int) -> dict | None:
return _row_to_reh(row) if row else None
def reh_find_by_title(title: str) -> dict | None:
key = _reh_title_key(title)
if not key:
return None
with _open() as conn:
rows = conn.execute(
"SELECT * FROM rehearsals ORDER BY updated DESC, id DESC"
).fetchall()
for row in rows:
rec = _row_to_reh(row)
if _reh_title_key(rec.get("title")) == key:
return rec
return None
def reh_compact_titles() -> int:
"""Remove older duplicate rehearsals that share the same title.
We keep the most recently updated row for each normalized title and delete
the rest. This prevents the library from accumulating repeated copies when
auto-save/import paths reuse the same book title.
"""
with _open() as conn:
rows = conn.execute(
"SELECT id, title, updated FROM rehearsals ORDER BY updated DESC, id DESC"
).fetchall()
seen: set[str] = set()
delete_ids: list[int] = []
for row in rows:
key = _reh_title_key(row["title"])
if not key:
continue
if key in seen:
delete_ids.append(int(row["id"]))
else:
seen.add(key)
for reh_id in delete_ids:
conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,))
if delete_ids:
conn.commit()
return len(delete_ids)
def reh_put(rec: dict) -> dict:
"""Insert (no id) or full replace (id present). Returns record with id."""
reh_id = rec.get("id")
title = str(rec.get("title", "") or "")
if not reh_id and title.strip():
existing = reh_find_by_title(title)
if existing:
reh_id = existing["id"]
# Keep the original created timestamp when a title-based save updates
# an existing rehearsal rather than creating a new library copy.
rec = dict(rec)
rec["created"] = existing.get("created") or rec.get("created")
row = {
"title": rec.get("title", ""),
"title": title,
"script": rec.get("script", ""),
"cast": _j(rec.get("cast", {})),
"emotions": _j(rec.get("emotions", {})),

View File

@ -634,27 +634,42 @@ def _voice_design_dialogue_request_audio(
# ── Benchmark request ─────────────────────────────────────────────────────────
def _tts_benchmark_request(text: str, voice: str, settings: dict, label: str) -> dict:
endpoint, payload, tts_hdrs = _tts_request_config(text, voice, settings, "wav")
def _tts_benchmark_request(text: str, voice: str, settings: dict, label: str, is_designed: bool = False) -> dict:
start = time.perf_counter()
first_audio_at = None
raw = bytearray()
media_type = "audio/wav"
with _post_tts_with_fallback(endpoint, payload, tts_hdrs, stream=True, timeout=180) as resp:
resp.raise_for_status()
media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav"
for chunk in resp.iter_content(chunk_size=512):
if not chunk:
continue
raw.extend(chunk)
if first_audio_at is None:
if payload.get("response_format") == "wav" or "wav" in media_type.lower():
offset = _wav_data_offset(bytes(raw))
if offset is not None and len(raw) > offset:
if is_designed:
# A designed voice has no reference WAV to clone from — it can only
# ever be synthesized through the voice_design engine (same dispatch
# as /api/tts-preview's backend=='voice_design' branch), never the
# generic voice_clone-style request this function otherwise builds.
# Previously every voice benchmarked through the one fixed tts_url
# regardless of origin, so a designed voice's benchmark only ever
# "worked" by coincidence when that unrelated clone engine happened
# to also be reachable — confirmed live: with it down, EVERY voice
# in an all-designed batch failed the benchmark even though the
# voice_design engine itself was reachable the whole time.
audio_bytes, media_type = _voice_design_voice_request_audio(voice, text, settings)
raw.extend(audio_bytes)
first_audio_at = time.perf_counter()
else:
endpoint, payload, tts_hdrs = _tts_request_config(text, voice, settings, "wav")
with _post_tts_with_fallback(endpoint, payload, tts_hdrs, stream=True, timeout=180) as resp:
resp.raise_for_status()
media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav"
for chunk in resp.iter_content(chunk_size=512):
if not chunk:
continue
raw.extend(chunk)
if first_audio_at is None:
if "wav" in media_type.lower():
offset = _wav_data_offset(bytes(raw))
if offset is not None and len(raw) > offset:
first_audio_at = time.perf_counter()
else:
first_audio_at = time.perf_counter()
else:
first_audio_at = time.perf_counter()
total = time.perf_counter() - start
if not raw:

View File

@ -137,6 +137,30 @@ def _backup_path(audio: Path) -> Path:
return audio.with_name(f".{audio.stem}.original{audio.suffix}.bak")
def _picture_backup_path(picture: Path) -> Path:
return picture.with_name(f".{picture.stem}.original{picture.suffix}.bak")
def _backup_existing_picture(wav: Path) -> None:
# A voice's picture had no equivalent to _backup_original_voice's
# copy-before-overwrite protection — confirmed live as real, unrecoverable
# data loss: a shared library voice cloned from a real person's own
# reference photo got silently overwritten (the previous file just
# unlink()'d, nothing copied first) the moment an unrelated feature
# elsewhere pushed a different picture onto it. Best-effort and silent by
# design, same as the audio backup — this must never block/break the
# actual upload it's protecting.
existing = _picture_path(wav)
if not existing:
return
backup = _picture_backup_path(existing)
if not backup.exists():
try:
shutil.copy2(str(existing), str(backup))
except OSError:
pass
def _legacy_backup_path(audio: Path) -> Path:
return audio.with_name(f"{audio.stem}.original{audio.suffix}")
@ -375,10 +399,13 @@ def _benchmark_summary(runs: list[dict]) -> dict:
def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str]]) -> dict:
from core.tts_helpers import _tts_benchmark_request
has_ref, _transcript = _read_reference_text(audio)
meta = _load_meta(audio)
is_designed = meta.get("origin") == "designed" or not has_ref
runs: list[dict] = []
for label, text in sentences:
try:
runs.append(_tts_benchmark_request(text, audio.stem, settings, label))
runs.append(_tts_benchmark_request(text, audio.stem, settings, label, is_designed=is_designed))
except Exception as e:
runs.append({"ok": False, "label": label, "text": text, "error": str(e)})

View File

@ -124,6 +124,17 @@ def rebuild_voice_index(settings: dict) -> list[dict]:
rows.append(_row_for_audio(settings, audio))
except Exception as exc:
logger.warning("Could not index voice %s: %s", audio, exc)
# This scan can take a noticeable while over 100+ voices, and runs in a
# background thread while the app keeps serving requests — including
# DELETE /api/voice/{id}, which removes its index row immediately.
# A delete landing mid-scan (before this point) leaves that voice's row
# sitting in `rows` from when it still existed on disk; replacing the
# WHOLE table with that stale snapshot would resurrect it right after the
# delete already removed it. Confirmed live as deleted voices reappearing.
# Re-check existence right here, as close to the write as possible, to
# shrink that window down from "however long the scan took" to next to
# nothing.
rows = [row for row in rows if Path(row["path"]).exists()]
voice_index_replace_all(rows)
return [row["entry"] for row in rows]

View File

@ -9,16 +9,55 @@ Mirrors the IndexedDB API in characters-library.js so the JS swap is mechanical:
"""
from __future__ import annotations
import base64
import re
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from core.database import char_get_all, char_get, char_put, char_delete
router = APIRouter()
_DATA_URL_RE = re.compile(r"^data:(image/[\w.+-]+);base64,(.+)$", re.DOTALL)
@router.get("/api/characters")
async def characters_list():
return {"characters": char_get_all()}
# Swap each character's raw base64 portrait for a lightweight URL —
# the list endpoint is fetched in bulk (e.g. rendering a full cast grid),
# and re-sending every character's full image blob inline made that
# payload/DOM balloon to tens of megabytes for a book with dozens of
# portraits, blocking rendering with no visual feedback. Single-record
# fetches (characters_get below) still return the real base64.
chars = char_get_all()
for c in chars:
if c.get("image"):
c["image"] = f"/api/characters/{c['id']}/image"
return {"characters": chars}
@router.get("/api/characters/{char_id:path}/image")
async def characters_image(char_id: str):
# Character portraits are stored inline as base64 data: URLs (clUpsert/
# clSetImage write straight into the `image` column) — fine for a single
# avatar per card, but Script Rehearser's Stage view renders one avatar
# PER DIALOGUE LINE, and a character can speak hundreds of lines. Inlining
# the raw data URL into every line's HTML re-embeds the same multi-KB/MB
# blob hundreds of times, ballooning the page to hundreds of megabytes and
# silently failing to render at all (confirmed live on a 1968-line book).
# Serving it as a real URL means the browser fetches/caches it once.
rec = char_get(char_id)
if rec is None or not rec.get("image"):
raise HTTPException(404, "No image")
m = _DATA_URL_RE.match(rec["image"])
if not m:
raise HTTPException(404, "Invalid image data")
try:
raw = base64.b64decode(m.group(2))
except Exception:
raise HTTPException(404, "Invalid image data")
return Response(content=raw, media_type=m.group(1))
@router.get("/api/characters/{char_id:path}")

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import base64
import contextlib
import copy
import io
import json
import re
@ -12,6 +13,7 @@ import time
import uuid
import wave
from pathlib import Path
from urllib.parse import quote
import requests
from typing import Optional
@ -600,8 +602,9 @@ def _charsheets_prepare(data: dict) -> dict:
f"The source text is in {language}.\n"
f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n"
f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, "
f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, "
f"conflict_style, win_condition, archetype, aliases, first_name, last_name, full_name, title.\n"
f"capabilities, backstory, relationships, motivation, fears, mannerisms, communication_style, voice_pattern, "
f"secret, conflict_style, win_condition, archetype, aliases, first_name, last_name, title, age_estimate, "
f"race_species, languages, nationality_background, social_class, profession, reputation, religious_beliefs, notes.\n"
f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n"
f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n"
f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n"
@ -615,7 +618,20 @@ def _charsheets_prepare(data: dict) -> dict:
"- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name "
"and put only the proven alternate form in aliases/title/full_name.\n"
"- Do NOT invent brand-new profiles in this pass. Only refine the already-casted roster; ignore places, institutions, and other non-person entities.\n"
"- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n"
"- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n"
"- Before deciding a passage is about someone else: check whether the person could BE one of the targets "
"under an alias, title, role, or nickname instead of their literal listed name — check both this passage's "
"own context (action beats, who's being addressed, profession mentioned) AND the 'existing sheets so far' "
"below, which may already record that alias for a target (e.g. a target's existing sheet says "
"'aliases: der Schmied' — a passage about 'der Schmied' with no other name given IS that target, attribute "
"it there, do not skip it just because the passage never says the target's literal name).\n"
"- Only if, after that check, the passage is clearly about a DIFFERENT person who is NOT one of the listed "
"targets and NOT an alias/role already recorded for one of them (a scene that doesn't actually involve any "
"target), output NO sheet for this passage at all — an empty 'sheets' array is correct and expected. Never "
"force unrelated content about someone else into a target's profile just because a target happens to be "
"listed and you can't create a new one — but do not use this as an excuse to skip a passage that genuinely "
"is about a target under an alias; skipping a real match is just as wrong as gluing a fact onto the wrong "
"character.\n\n"
) if target_mode and known else ""
# A user-editable prompt (client-side "Prompt" panel, mirroring the
@ -642,14 +658,19 @@ def _charsheets_prepare(data: dict) -> dict:
"dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.\n"
"For each character output these fields:\n"
"- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n"
"- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n"
"- first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty.\n"
"- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain.\n"
"- first_name, last_name: split the character identity when known. Leave unknown parts empty.\n"
"- title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').\n"
"- profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').\n"
"- age_estimate: estimated age or age range, if inferable\n"
"- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant\n"
"- languages: spoken languages / dialects / tongues, comma-separated if multiple\n"
"- nationality_background: homeland, culture, origin, or social background if known\n"
"- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.)\n"
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
"- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.\n"
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
"- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known.\n"
"- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look\n"
"- alignment: strict moral code + the one line they will never cross\n"
"- moral_alignment_score: integer 0100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous\n"
"- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'\n"
@ -662,28 +683,34 @@ def _charsheets_prepare(data: dict) -> dict:
"- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)\n"
"- fears: their deepest fears, phobias or dread\n"
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
"- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.\n"
"- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n"
"- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.\n"
"- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. "
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though the prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
+ "Do NOT mention plot spoilers.\n"
"- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n"
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
"- secret: dark secret or fatal flaw\n"
"- conflict_style: fight, flight, or manipulate — how they act when cornered\n"
"- win_condition: the specific event that would make them feel they have won\n"
"- reputation: how other characters or society see them\n"
"- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it\n"
"- notes: brief catch-all notes for useful details that do not fit elsewhere\n"
"- tier: 'main' or 'supporting'\n"
"- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, "
"a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). "
"Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.\n"
"IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). "
"Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. "
"Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.\n"
"Examples: 'Marcus', 'the Executioner', and 'Bloodfang' may all refer to ONE profile if context shows they are the same person. "
"Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title and keep one canonical name.\n"
"Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. "
"Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n"
"Respond with STRICT JSON only:\n"
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","archetype":"","gender":"","physical":"","clothing":"",'
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
'"inventory":[],"secret":"","conflict_style":"","win_condition":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
'"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"",'
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
)
lang_reminder = (
@ -773,9 +800,19 @@ def _charsheets_parse(raw: str) -> dict:
arc = str(s.get("arc_direction") or "neutral").strip()
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
arc = "neutral"
gender = str(s.get("gender") or "").strip().lower()
if gender not in ("male", "female", "nonbinary"):
gender = ""
# The model answers in whatever language the source text is in (this
# app is used heavily with German books), so a strict English-only
# whitelist silently discarded valid answers like "weiblich"/"männlich"
# instead of normalizing them — every non-German-speaking character's
# gender was quietly wiped to blank.
gender_raw = str(s.get("gender") or "").strip().lower()
_GENDER_NORMALIZE = {
"male": "male", "m": "male", "man": "male", "mann": "male", "männlich": "male",
"female": "female", "f": "female", "woman": "female", "frau": "female", "weiblich": "female",
"nonbinary": "nonbinary", "non-binary": "nonbinary", "n": "nonbinary",
"nichtbinär": "nonbinary", "nicht-binär": "nonbinary", "divers": "nonbinary",
}
gender = _GENDER_NORMALIZE.get(gender_raw, "")
s.update({
"name": name, "aliases": aliases, "inventory": inv[:3],
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
@ -999,6 +1036,117 @@ async def character_deep_analysis(request: Request):
return {"name": name, "analysis": analysis}
def _silly_tavern_prompt_instruction(sheet: dict) -> str:
"""Full SillyTavern character-card template (card + scenario + first
message), adapted from a user-supplied prompt originally written for
building a card from scratch via internet research (fandom/wikipedia).
Here the character's profile is already fully known from the book, so
the internet-research instructions are dropped and replaced with
'use ONLY the profile provided' (already stated in the shared preamble
this gets appended to) everything else (exact field structure, the
generic-but-deliberately-open Scenario block, the First Message rules)
is kept close to the original since it's a well-tested format."""
gender = str((sheet or {}).get("gender") or "").strip().lower()
is_female = gender.startswith("f")
is_male = gender.startswith("m")
if is_female:
appearance = (
'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/'
'long (arms-length)/short (chin-length)], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
'body: [PICK FROM:slim/curvy, PICK FROM:perfect figure/sensual/abs, PICK FROM:light skin/tanned skin/'
'brown skin/green skin/blue skin/red skin], breasts: [SIZE, CUP, PICK FROM:big areolas/medium-sized '
'areolas/small areolas, PICK FROM:cherry-tan nipples/cherry-pink nipples/honey-tan nipples/'
'golden-brown nipples/dark-brown nipples], armpit hair: PICK FROM:shaved/natural, '
'pubic hair: PICK FROM:shaved/natural, fingernails: PICK FROM:natural/painted (color), '
'toenails: PICK FROM:natural/painted (color)'
)
outfits = (
'{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
'DESCRIBE SHOES (COLOR), lingerie: [lace bra (COLOR), lace thong (COLOR)]}\n'
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
'DESCRIBE SHOES (COLOR), lingerie: [lace bra (color), lace thong (color)]}\n'
'{{"Sleeping Outfit"}}:{nightgown (COLOR), thong (COLOR), soft slippers (white)}\n'
'{{"Running Outfit"}}:{sports bra (COLOR), leggings (COLOR), sports shoes (white), lingerie: thong (COLOR)}\n'
'{{"Exercise Outfit"}}:{sports bra (COLOR), leggings (COLOR), bare feet, lingerie: lace thong (COLOR)}\n'
'{{"Swimsuit"}}:{PICK FROM: bikini/one-piece (COLOR), DESCRIBE SHOES (COLOR)}'
)
elif is_male:
appearance = (
'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/'
'long (arms-length)/short (chin-length)], facial hair: PICK FROM:beard/goatie/beard & moustache/'
'moustache/clean-shaven, eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
'body: [PICK FROM:slim/muscular/bulky/fat, PICK FROM:light skin/tanned skin/brown skin/green skin/'
'blue skin/red skin], penis: [SIZE, LENGTH cm, PICK FROM:big balls/medium-sized balls/small balls, '
'PICK FROM:circumcised/uncircumcised], armpit hair: PICK FROM:shaved/natural, '
'pubic hair: PICK FROM:shaved/natural'
)
outfits = (
'{{"Main Outfit"}}:{DESCRIBE TOP (color), DESCRIBE BOTTOM (color), DESCRIBE SHOES (COLOR), '
'lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
'DESCRIBE SHOES (COLOR), lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Sleeping Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, soft slippers (white)}\n'
'{{"Running Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, sports shoes (white), lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Exercise Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, bare feet, lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Swimsuit"}}:{DESCRIBE BOTTOM, DESCRIBE SHOES (COLOR)}'
)
else:
# Gender unknown/non-binary/narrator role — keep the same card
# shape but skip the anatomy-specific appearance fields entirely
# rather than guessing a binary that doesn't fit.
appearance = (
'hair: [COLOR, STYLE, LENGTH], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
'body: [BUILD, SKIN TONE], distinguishing features: DESCRIBE'
)
outfits = (
'{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n'
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n'
'{{"Sleeping Outfit"}}:{DESCRIBE SLEEPWEAR, soft slippers (white)}'
)
return (
"Produce exactly this field:\n"
"- silly_tavern_prompt: a complete SillyTavern character card for this character, in the EXACT format "
"below — a card body, then a Scenario block, then a First Message. Fill every field from the character "
"profile above; only invent a value when the profile truly has nothing for it, and mark anything invented "
"with a trailing '*'. Do not add bullet points, extra spaces, or commentary — follow the formatting "
"exactly. Do not replace '{{char}}' with the character's actual name — keep it literal. Keep every '{', "
"'}', '[', ']', '(', ')' character exactly as shown.\n\n"
"{{char}}:\n"
"{\n"
'{{"Personal Information"}}:{name: NAME, surname: SURNAME, race: PICK FROM PROFILE OR INFER, '
"nationality: NATIONALITY, gender: GENDER, age: AGE, profession: PROFESSION, "
"residence: [PLACE, TYPE OF DWELLING], marital status: MARITAL STATUS}\n\n"
f'{{{{"Appearance"}}}}:{{{appearance}}}\n\n'
'{{"Personality"}}:{A DETAILED, SPECIFIC DESCRIPTION OF THIS CHARACTER\'S OWN PERSONALITY, SPEECH PATTERN '
"AND QUIRKS FROM THE PROFILE ABOVE — NOT A GENERIC PERSONALITY TYPE. BE SPECIFIC TO THIS CHARACTER.}\n\n"
'{{"Likes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Dislikes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Goals"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Skills"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Weapons"}}:{LIST ONLY IF THIS CHARACTER PLAUSIBLY CARRIES ONE PER THE PROFILE — OMIT THIS FIELD '
"ENTIRELY OTHERWISE}\n\n"
f"{outfits}\n"
"}\n\n"
"Then, in the SAME string, add a scenario block as clear instructions/definitions for the LLM, not "
"narration — {{char}}'s relationship with {{user}}, everyday routine, current mood, current plans. Keep "
"it open-ended (many different stories could start from it) rather than building one specific scene. "
"Use this exact structure:\n\n"
'{{"Scenario"}}:{"{{char}} is living everyday life","{{char}} and {{user}} keep crossing each other\'s '
'paths as {{char}} and {{user}} relationship develops","everyday routine":["mornings":"{{char}} GENERATE",'
'"days":"{{char}} GENERATE","evenings":"{{char}} GENERATE"],"current mood":"{{char}} GENERATE"]}\n\n'
"Then add a section literally titled 'First Message:' on its own line, followed by the message itself: "
"maximum 3 paragraphs, balancing narration with {{char}} dialogue, true to the profile's personality and "
"the scenario above. Never decide what {{user}} does or says. Avoid describing eyes. Use direct speech "
"with no markdown for dialogue, and *asterisks* for narration.\n\n"
"The finished silly_tavern_prompt string MUST contain all three parts, in this order: the {{char}} card "
"block, the {{\"Scenario\"}} block, and the 'First Message:' section — never stop after the card or the "
"Scenario alone.\n\n"
'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think'
)
@router.post("/api/character-generate-prompts")
async def character_generate_prompts(request: Request):
"""Turn an already-extracted character sheet into four ready-to-use external
@ -1053,45 +1201,89 @@ async def character_generate_prompts(request: Request):
"voice_design_prompt": (
preamble
+ "Produce exactly this field:\n"
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, "
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (25-60 words, one paragraph, "
"no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not "
"mention plot events — describe only how the voice should SOUND.\n\n"
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. "
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though this prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
+
"Generic category labels alone ('young female voice, energetic tone, clear pitch') describe a whole "
"demographic, not a person, and different characters who happen to share an age/gender end up "
"sounding like the same person — confirmed live as a real problem with several young-female "
"characters in one book. Every one of these fields needs a SPECIFIC, CONCRETE choice, not the safe "
"generic default: pick a distinctive timbre (breathy/husky/bright/nasal/silvery/gravelly/reedy, not "
"just 'clear'), a specific pace/rhythm quirk (clipped consonants, unhurried drawl, rapid-fire, "
"deliberate pauses before key words), and a specific emotional baseline drawn from THIS character's "
"own personality/backstory (guarded warmth, brittle confidence, weary sarcasm — not just 'friendly' "
"or 'energetic'). Two characters with the same age and gender in your profile should still end up "
"with visibly different prompts once you've done this. Do not mention plot events — describe only "
"how the voice should SOUND.\n\n"
'Respond with STRICT JSON only: {"voice_design_prompt":""}/no-think'
),
"image_prompt": (
preamble
+ "Produce exactly this field:\n"
"- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. "
"Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, "
"clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style "
"(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
"are fine.\n\n"
"- image_prompt: a detailed English image-generation prompt for a full CHARACTER REFERENCE SHEET "
"(a single composite image, like a game/animation production turnaround), not just one portrait. "
"FIRST, from the book title and profile, work out the story's genre, setting, and era (e.g. "
"'medieval European-inspired high fantasy', 'grimdark low fantasy', 'space opera sci-fi', "
"'contemporary urban fantasy') — an image model has no idea what a book title implies and will "
"default to generic modern/real-world imagery unless told explicitly, which is exactly wrong for "
"an occupation like 'Admiral' or 'General' in a fantasy world (it will draw a 20th-century military "
"uniform instead of that world's actual equivalent). State that genre/setting/era explicitly, as "
"its own descriptor near the START of the prompt, and make clear the world has no real-world 20th "
"or 21st century technology, uniforms, or clothing unless the story is actually confirmed "
"contemporary/near-future — every other visual choice (armor, dress, rank insignia, weapons) must "
"fit THAT world, not the real one. Then ask the image model for: (1) a full-body front-view "
"illustration as the anchor, (2) a turnaround panel with side and back views, (3) a small "
"expression sheet with 3-5 headshots showing this character's typical emotional range "
"(calm/determined/etc. — pick expressions that fit their personality), (4) a color palette swatch "
"panel for hair/eyes/outfit, (5) callouts for their signature props, tools, or clothing details "
"with short labels. Include age impression, build, hair/eyes/skin if known, and clothing "
"appropriate to the established setting. Specify a clean production-design/concept-art layout "
"with a plain neutral background, and explicitly note this is an ORIGINAL character, not based on "
"any copyrighted character. One dense paragraph, comma-separated descriptors are fine.\n\n"
'Respond with STRICT JSON only: {"image_prompt":""}/no-think'
),
"silly_tavern_prompt": (
preamble
+ "Produce exactly this field:\n"
"- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their "
"own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), "
"'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character "
"greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character "
"lines showing their manner of speech). Keep each section a few lines at most.\n\n"
'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think'
),
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
"concept_art_prompt": (
preamble
+ "Produce exactly this field:\n"
"- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — "
"a turnaround/reference sheet with multiple views and expressions: front view, side or back view, "
"2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, "
"in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
"white background').\n\n"
"- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC "
"reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
"(not a list). This mirrors a well-tested prompt-engineering pattern for these sheets; follow it "
"precisely rather than writing free-form:\n"
" Task: name the sheet type, e.g. 'Generate a character/NPC design sheet.'\n"
" Subject: 'an original adult [role/archetype from the profile]' plus its 3-6 most visually "
"distinctive, ALREADY-ESTABLISHED traits (skin/hair, signature garment layers, and — only if the "
"profile actually gives this character a prop, weapon, or tool — its exact count, e.g. 'exactly one "
"quiver' or 'exactly two throwing knives'; omit props entirely if the profile has none).\n"
" Context: one clause on what the sheet is for and the story's genre/setting/era — infer this from "
"the book/profile the same way you would for a portrait (a fantasy Admiral is NOT a real-world 20th-"
"century Admiral) and state it explicitly, since an image model defaults to generic modern imagery "
"otherwise.\n"
" Style: a concept-art style matching that genre/setting (painterly game concept art / detailed "
"semi-realistic concept art / hand-painted concept art — pick what fits), grounded materials, clear "
"shape language.\n"
" Composition: full-body front, side, and back views across the top; below them, ONE clean row of "
"isolated callouts for this character's established props/costume components ONLY (skip this row "
"entirely if the profile establishes no distinct props/costume pieces worth separating out) — state "
"the exact number of callouts and name each one.\n"
" Lighting: neutral studio/museum-style light, no cinematic color cast.\n"
" Constraints: face/silhouette/garment/prop consistency across every view; the exact prop count "
"restated; no duplicate gear, no extra limbs, no readable text/logos/watermark, no real-world/"
"franchise references, this is an ORIGINAL character not based on any copyrighted one.\n"
" Output: one production-ready 3:2 reference sheet.\n\n"
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
),
}
requested = [f for f in (data.get("fields") or []) if f in all_groups]
field_groups = [((f,), all_groups[f]) for f in (requested or all_groups.keys())]
# The SillyTavern card is a full structured card + scenario + first
# message now, not a few short labelled lines — needs a much bigger
# budget than the other three (single-paragraph) prompt fields or it
# reliably truncates mid-card.
_field_max_tokens = {"silly_tavern_prompt": 3000}
def _call_group(fields: tuple, system: str) -> dict:
payload: dict = {
@ -1100,7 +1292,7 @@ async def character_generate_prompts(request: Request):
{"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."},
],
"temperature": 0.7,
"max_tokens": 1536,
"max_tokens": _field_max_tokens.get(fields[0], 1536),
}
if model:
payload["model"] = model
@ -1148,6 +1340,417 @@ async def character_generate_prompts(request: Request):
return out
@router.post("/api/audiobook-consistency-check")
async def audiobook_consistency_check(request: Request):
"""Check whether every line already attributed to ONE character across the
whole book actually sounds like them using the character's OWN other
lines as the reference, not a chunk-local judgement. Unlike the casting/
verification passes (which re-read the source text chunk by chunk), this
works purely over already-attributed lines gathered from anywhere in the
book, so it can catch a character who briefly "borrows" someone else's
voice in a way no single passage-sized chunk would ever expose.
Body: {character, lines: [{index, text}], known_characters: [...], llm_url, model}
Returns: {outliers: [{index, reason, suggested_speaker}]}
"""
data = await request.json()
character: str = (data.get("character") or "").strip()
lines: list = data.get("lines") or []
known: list = data.get("known_characters") or []
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not character:
raise HTTPException(400, "Character name required")
if not lines:
raise HTTPException(400, "No lines to check")
numbered = "\n".join(
f"[{l.get('index')}] {str(l.get('text') or '').strip()}"
for l in lines if str(l.get("text") or "").strip()
)
known_note = (
f"\n\nOther characters already recognized in this book (only suggest one of these, or 'Unknown'"
f"never invent a new name): {', '.join(str(n) for n in known)}"
) if known else ""
system = (
f"You are a dramaturge auditing dialogue attribution in a novel already cast for audiobook production. "
f"Below are ALL the lines currently attributed to ONE character, '{character}', gathered from across the "
f"whole book (not necessarily consecutive). Your job: read them as a whole and judge whether each line "
f"actually sounds like the SAME person speaking — same tone, vocabulary, register, and personality — or "
f"whether one or more lines sound like they were misattributed from someone else (a different tone, "
f"formality, vocabulary, or a statement that contradicts what the rest of {character}'s lines establish "
f"about them).\n\n"
f"Be conservative: most lines are correctly attributed. Only flag a line if it genuinely reads like a "
f"different voice compared to the REST of {character}'s own lines here — not just because it's short, "
f"blunt, or otherwise unremarkable.{known_note}\n\n"
'Respond with STRICT JSON only: {"outliers":[{"index":0,"quote":"","reason":"","suggested_speaker":""}]}\n'
"index: the EXACT number shown in [square brackets] right before the flagged line below — copy that "
"number verbatim, do NOT count lines yourself or renumber them (the brackets are the book's real line "
"numbers, not a 0/1/2/3 sequence). quote: the first few words of the flagged line, verbatim, so the "
"index can be double-checked. reason: one short sentence explaining why this line doesn't fit. "
"suggested_speaker: your best guess at who actually said it (exact name from the list above), or "
"'Unknown' if you can't tell. Omit any line that isn't an outlier — do not list every line.\n/no-think"
)
user = f"{character}'s lines (index — text):\n{numbered}"
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.3,
"max_tokens": 2048,
}
if model:
payload["model"] = model
def _call() -> dict:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or _reasoning_text(msg) or "").strip()
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
for cand in (content, _extract_json_block(content)):
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and isinstance(parsed.get("outliers"), list):
return parsed
except Exception:
continue
return {"outliers": []}
try:
result = await asyncio.to_thread(_call)
except Exception as e:
raise HTTPException(502, f"Consistency check failed: {e}")
clean = []
for o in result.get("outliers", []):
if not isinstance(o, dict):
continue
try:
idx = int(o.get("index"))
except (TypeError, ValueError):
continue
clean.append({
"index": idx,
"quote": str(o.get("quote") or "").strip()[:120],
"reason": str(o.get("reason") or "").strip()[:200],
"suggested_speaker": str(o.get("suggested_speaker") or "").strip(),
})
return {"outliers": clean}
def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
prompt_field: str, output_node_id: str, prompt: str) -> str:
"""Submits a pre-exported ComfyUI API-format workflow with the given
prompt text injected into one designated node/field, waits for it to
finish, and returns the resulting image as a data: URI.
The workflow itself is opaque to us the user exports it from ComfyUI's
own UI (Workflow > Export (API Format)), since converting the editor's
graph format ourselves risks silently mis-wiring virtual-routing addons
(e.g. rgthree Get/Set nodes) that don't show up as real graph edges.
"""
if not comfyui_url:
raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)")
if not workflow_json:
raise HTTPException(400, "No ComfyUI workflow configured — paste an API-format workflow export in Settings > Engines > Image Generation")
if not prompt_node_id or not output_node_id:
raise HTTPException(400, "ComfyUI prompt/output node IDs not set (Settings > Engines > Image Generation)")
try:
workflow = json.loads(workflow_json)
except Exception as e:
raise HTTPException(400, f"Saved ComfyUI workflow isn't valid JSON: {e}")
if prompt_node_id not in workflow:
raise HTTPException(400, f"Prompt node id '{prompt_node_id}' not found in the saved workflow")
if output_node_id not in workflow:
raise HTTPException(400, f"Output node id '{output_node_id}' not found in the saved workflow")
graph = copy.deepcopy(workflow)
graph[prompt_node_id].setdefault("inputs", {})[prompt_field or "text"] = prompt
client_id = str(uuid.uuid4())
base = comfyui_url.rstrip("/")
try:
resp = requests.post(f"{base}/prompt", json={"prompt": graph, "client_id": client_id}, timeout=30)
if not resp.ok:
detail = resp.text[:500]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"ComfyUI rejected the workflow: {detail}")
prompt_id = resp.json().get("prompt_id")
if not prompt_id:
raise HTTPException(502, "ComfyUI didn't return a prompt_id")
except HTTPException:
raise
except requests.exceptions.ConnectionError:
raise HTTPException(502, f"Cannot reach ComfyUI at {base} — is the container running?")
except Exception as e:
raise HTTPException(502, f"ComfyUI submission failed: {e}")
# Poll history — generation on a real workflow (multi-sampler, upscale,
# etc.) can take minutes, so this waits longer than a typical API call.
deadline = time.time() + 300
history = None
while time.time() < deadline:
try:
hr = requests.get(f"{base}/history/{prompt_id}", timeout=10)
if hr.ok:
data = hr.json()
if prompt_id in data:
history = data[prompt_id]
status = history.get("status", {})
if status.get("completed") is True or status.get("status_str") == "success":
break
if status.get("status_str") == "error":
msgs = status.get("messages", [])
raise HTTPException(502, f"ComfyUI generation failed: {msgs[-1] if msgs else 'unknown error'}")
except HTTPException:
raise
except Exception:
pass
time.sleep(2)
if not history:
raise HTTPException(504, "ComfyUI generation timed out after 5 minutes")
outputs = history.get("outputs", {}).get(output_node_id, {})
images = outputs.get("images") or []
if not images:
raise HTTPException(502, f"Output node '{output_node_id}' produced no images — check it's the right SaveImage/PreviewImage node id")
img = images[0]
try:
vr = requests.get(f"{base}/view", params={
"filename": img.get("filename", ""), "subfolder": img.get("subfolder", ""), "type": img.get("type", "output"),
}, timeout=30)
vr.raise_for_status()
except Exception as e:
raise HTTPException(502, f"Fetching the generated image from ComfyUI failed: {e}")
b64 = base64.b64encode(vr.content).decode("ascii")
mime = vr.headers.get("Content-Type", "image/png")
return f"data:{mime};base64,{b64}"
@router.post("/api/character-image-from-url")
async def character_image_from_url(request: Request):
"""Download an image from a user-supplied URL server-side and return it
as a data: URI, so pasting a link works the same as an upload fetching
an arbitrary third-party image directly from the browser would usually
fail on CORS, since most image hosts don't send permissive headers.
Body: {url}
Returns: {image: "data:image/...;base64,...."}
"""
data = await request.json()
url: str = (data.get("url") or "").strip()
if not url:
raise HTTPException(400, "Image URL required")
try:
resp = requests.get(url, timeout=30, headers={"User-Agent": "TTS-Voice-Creator/image-fetch"}, stream=True)
resp.raise_for_status()
ctype = resp.headers.get("Content-Type", "")
if not ctype.startswith("image/"):
raise HTTPException(400, f"That URL didn't return an image (got {ctype or 'unknown content type'})")
content = resp.raw.read(15 * 1024 * 1024, decode_content=True)
if not content:
raise HTTPException(502, "Empty response from that URL")
b64 = base64.b64encode(content).decode("ascii")
return {"image": f"data:{ctype};base64,{b64}"}
except HTTPException:
raise
except requests.exceptions.RequestException as e:
raise HTTPException(502, f"Couldn't fetch that URL: {e}")
@router.post("/api/character-generate-image")
async def character_generate_image(request: Request):
"""Generate a character profile picture from a text prompt via a cloud
image-gen provider (OpenAI, Google, OpenRouter) or a local ComfyUI
workflow.
Body: {prompt, provider?, model?} provider/model default to the
Settings > Engines > Image Generation choice if not passed explicitly.
Returns: {image: "data:image/png;base64,...."}
"""
data = await request.json()
prompt: str = (data.get("prompt") or "").strip()
if not prompt:
raise HTTPException(400, "Image prompt required")
_settings = _load_settings()
keys: dict = _settings.get("engine_api_keys") or {}
provider: str = (data.get("provider") or _settings.get("image_gen_provider") or "").strip().lower()
model: str = (data.get("model") or _settings.get("image_gen_model") or "").strip()
if not provider:
raise HTTPException(400, "No image generation provider configured — set one in Settings > Engines > Image Generation")
# Every branch below does blocking requests.post/get (plus, for ComfyUI, a
# polling loop with time.sleep for up to 5 minutes on a real multi-stage
# workflow) with no asyncio.to_thread wrapper — unlike every other
# blocking call in this file. On a single-worker uvicorn process (see
# server.py) that blocked the ENTIRE event loop: every other user's
# request (TTS, page loads, /api/characters) would hang unresponsive for
# as long as one image generation took, which for a bulk "auto-generate
# images" run across a whole cast could be tens of minutes of site-wide
# freeze with no error, just silence. Runs in a worker thread instead.
return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings)
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict) -> dict:
if provider == "openai":
api_key = (keys.get("openai_image") or "").strip()
if not api_key:
raise HTTPException(400, "OpenAI API key not set (Settings > Engines > Image Generation)")
try:
resp = requests.post(
"https://api.openai.com/v1/images/generations",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": model or "gpt-image-1", "prompt": prompt, "size": "1024x1024", "n": 1},
timeout=120,
)
if not resp.ok:
detail = resp.text[:400]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"OpenAI image generation failed: {detail}")
b64 = resp.json()["data"][0]["b64_json"]
return {"image": f"data:image/png;base64,{b64}"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"OpenAI image generation failed: {e}")
if provider == "google":
api_key = (keys.get("google_image") or "").strip()
if not api_key:
raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)")
gmodel = model or "gemini-2.5-flash-image"
try:
resp = requests.post(
f"https://generativelanguage.googleapis.com/v1beta/models/{gmodel}:generateContent",
params={"key": api_key},
json={"contents": [{"parts": [{"text": prompt}]}]},
timeout=120,
)
if not resp.ok:
detail = resp.text[:400]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"Google image generation failed: {detail}")
parts = (resp.json().get("candidates") or [{}])[0].get("content", {}).get("parts", [])
inline = next((p.get("inlineData") for p in parts if p.get("inlineData")), None)
if not inline:
raise HTTPException(502, "Google returned no image data — try again or rephrase the prompt")
mime = inline.get("mimeType", "image/png")
return {"image": f"data:{mime};base64,{inline['data']}"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"Google image generation failed: {e}")
if provider == "openrouter":
# Reuses the same key as the OpenRouter LLM card (Settings > Engines >
# Language Models) — OpenRouter serves image-output models through
# the same chat/completions endpoint and account as text models,
# unlike OpenAI/Google where images are a separate API surface with
# their own key.
api_key = (keys.get("openrouter") or "").strip()
if not api_key:
raise HTTPException(400, "OpenRouter API key not set (Settings > Engines > Language Models > OpenRouter)")
omodel = model or "google/gemini-2.5-flash-image-preview:free"
try:
resp = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": omodel, "messages": [{"role": "user", "content": prompt}], "modalities": ["image", "text"]},
timeout=120,
)
if not resp.ok:
detail = resp.text[:400]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"OpenRouter image generation failed: {detail}")
msg = (resp.json().get("choices") or [{}])[0].get("message", {})
# OpenRouter returns generated images in message.images (not the
# OpenAI Images API shape) — each entry is
# {"type": "image_url", "image_url": {"url": "data:...;base64,..."}}.
images = msg.get("images") or []
url = next((im.get("image_url", {}).get("url") for im in images if im.get("image_url", {}).get("url")), None)
if not url:
# Some models inline the image as a data URI in the text content instead.
content = msg.get("content")
text = content if isinstance(content, str) else " ".join(
p.get("text", "") for p in (content or []) if isinstance(p, dict)
)
m = re.search(r"data:image/\w+;base64,[A-Za-z0-9+/=]+", text or "")
url = m.group(0) if m else None
if not url:
raise HTTPException(502, "OpenRouter returned no image data — this model may not support image output, try another")
return {"image": url}
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"OpenRouter image generation failed: {e}")
if provider == "comfyui":
image = _comfyui_run(
(_settings.get("comfyui_url") or "").strip(),
_settings.get("comfyui_workflow") or "",
(_settings.get("comfyui_prompt_node_id") or "").strip(),
(_settings.get("comfyui_prompt_field") or "text").strip(),
(_settings.get("comfyui_output_node_id") or "").strip(),
prompt,
)
return {"image": image}
if provider == "pollinations":
# Pollinations.ai — free, no API key, no account. A GET request that
# returns the image directly. Third-party public service: no SLA, no
# control over the model behind it, prompts leave the server — fine
# as a free stopgap, not a permanent guarantee.
pmodel = model or "flux"
last_err = ""
# The free queue (50 concurrent slots server-wide, shared across all
# of Pollinations' users) fills up under load and rejects with a
# transient error rather than queueing — a short retry clears most
# of these without the user having to click "Generate" again.
for attempt in range(3):
try:
resp = requests.get(
f"https://image.pollinations.ai/prompt/{quote(prompt)}",
params={"model": pmodel, "width": 1024, "height": 1024, "nologo": "true"},
timeout=120,
)
if resp.ok and resp.content:
b64 = base64.b64encode(resp.content).decode("ascii")
mime = resp.headers.get("Content-Type", "image/jpeg")
return {"image": f"data:{mime};base64,{b64}"}
last_err = resp.text[:300]
except Exception as e:
last_err = str(e)
if attempt < 2:
time.sleep(4)
raise HTTPException(502, f"Pollinations.ai request failed after retries: {last_err}")
raise HTTPException(400, f"Unknown image generation provider: {provider}")
def _attribution_prepare(data: dict) -> dict:
"""Resolve settings and build the chat payload for one attribution request.
Shared by the blocking endpoint and the streaming (watch-the-LLM-think)
@ -1191,8 +1794,13 @@ def _attribution_prepare(data: dict) -> dict:
"kein Zitatende — die Rede desselben Charakters geht danach unverändert weiter, bis das tatsächliche "
"schließende Anführungszeichen erscheint.\n"
"9. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die "
"Stimme oder das (beginnende) Sprechen einer bestimmten Person (z.B. \"Marcians Stimme wirkte nicht mehr so "
"fest\", \"X setzte zum Sprechen an\"), gehört diese Zeile dieser Person — nicht 'Unknown'."
"Stimme oder das (beginnende) Sprechen einer bestimmten Person — auch in indirekter/idiomatischer Form, "
"nicht nur mit einem wörtlichen Sprechverb (z.B. \"Marcians Stimme wirkte nicht mehr so fest\", \"X setzte "
"zum Sprechen an\", \"X fand als erster seine Stimme wieder\", \"ihre ersten Worte waren\", \"X brach das "
"Schweigen\"), gehört die folgende Zeile dieser Person — nicht 'Unknown'.\n"
"10. Selbstvorstellung: Nennt eine Zitat-Zeile selbst den Namen der sprechenden Person als Vorstellung "
"(z.B. \"Man nennt mich Andra\", \"Ich bin X\", \"Mein Name ist X\", \"Ich heiße X\"), ist diese genannte "
"Person die Sprecherin dieser Zeile — nicht 'Unknown', auch ohne separaten Sprecher-Tag."
)
if not base_prompt.strip():
base_prompt = (
@ -1238,9 +1846,14 @@ def _attribution_prepare(data: dict) -> dict:
"- MID-QUOTE DASH RULE: a \" - \" (em-dash/hyphen used as a pause) in the MIDDLE of a quotation does NOT "
"end it — the SAME speaker's line continues unchanged after the dash, until the actual closing quotation "
"mark appears. Do not split it into narration or a new speaker at the dash.\n"
"- VOICE-ANNOUNCEMENT RULE: if narration explicitly mentions a specific character's voice or that they "
"are about to speak, shortly before an unattributed line (e.g. \"Marcian's voice sounded...\", "
"\"X began to say\"), attribute that line to that character instead of 'Unknown'."
"- VOICE-ANNOUNCEMENT RULE: if narration mentions a specific character's voice or that they are about to "
"speak, shortly before an unattributed line, attribute that line to that character instead of 'Unknown' "
"— this includes indirect/idiomatic phrasing, not just a literal speech verb (e.g. \"Marcian's voice "
"sounded...\", \"X began to say\", \"X found his voice first\", \"her first words were\", \"X broke the "
"silence\").\n"
"- SELF-INTRODUCTION RULE: if a quoted line itself names the speaker as an introduction (e.g. \"They "
"call me Andra\", \"I am X\", \"My name is X\"), that named person is the speaker of THIS line — never "
"'Unknown', even with no separate speaker tag."
)
else:
if lang_hint:
@ -1790,6 +2403,47 @@ _MCP_TOOLS = [
"description": "List all available voice profiles with their language, persona, and enabled state.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_books",
"description": "List all Read Aloud books/documents (title, id, character count metadata).",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_characters",
"description": "List character library records, optionally filtered to one book/production.",
"inputSchema": {
"type": "object",
"properties": {"book": {"type": "string", "description": "Book/production title to filter by (optional — omit for every character across every book)"}},
},
},
{
"name": "get_character",
"description": "Get one character's full record (sheet, voice, image, tags) by id.",
"inputSchema": {
"type": "object",
"properties": {"id": {"type": "string", "description": "Character record id"}},
"required": ["id"],
},
},
{
"name": "update_character",
"description": "Update fields on a character's sheet (e.g. backstory, archetype, gender) or top-level record fields (name, voice, tags). Merges with the existing record — only send the fields you want to change.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Character record id"},
"sheet": {"type": "object", "description": "Partial sheet fields to merge in (e.g. {\"backstory\": \"...\", \"archetype\": \"...\"})"},
"voice": {"type": "string", "description": "Voice id to assign (shortcut for updating just the voice)"},
"name": {"type": "string", "description": "Rename the character (rare — usually leave unset)"},
},
"required": ["id"],
},
},
{
"name": "list_rehearsals",
"description": "List all saved Script Rehearser sessions (title, id, character/line counts).",
"inputSchema": {"type": "object", "properties": {}},
},
]
@ -1879,6 +2533,58 @@ async def _mcp_tool_list_profiles() -> dict:
return {"content": [{"type": "text", "text": json.dumps(profiles)}]}
async def _mcp_tool_list_books() -> dict:
from routes.reader import reader_list_docs
data = await reader_list_docs()
return {"content": [{"type": "text", "text": json.dumps(data.get("docs", []))}]}
async def _mcp_tool_list_characters(args: dict) -> dict:
from core.database import char_get_all
book = str(args.get("book") or "").strip().lower()
recs = char_get_all()
if book:
recs = [r for r in recs if str(r.get("book") or "").strip().lower() == book
or book in [t.strip().lower() for t in str(r.get("tags") or "").split(",")]]
return {"content": [{"type": "text", "text": json.dumps(recs)}]}
async def _mcp_tool_get_character(args: dict) -> dict:
from core.database import char_get
char_id = str(args.get("id") or "").strip()
if not char_id:
raise ValueError("id is required")
rec = char_get(char_id)
if rec is None:
raise ValueError(f"Character '{char_id}' not found")
return {"content": [{"type": "text", "text": json.dumps(rec)}]}
async def _mcp_tool_update_character(args: dict) -> dict:
from core.database import char_get, char_put
char_id = str(args.get("id") or "").strip()
if not char_id:
raise ValueError("id is required")
rec = char_get(char_id)
if rec is None:
raise ValueError(f"Character '{char_id}' not found")
if "sheet" in args and isinstance(args["sheet"], dict):
rec["sheet"] = {**(rec.get("sheet") or {}), **args["sheet"]}
if "voice" in args:
rec["voice"] = args["voice"]
if "name" in args:
rec["name"] = args["name"]
rec["id"] = char_id
updated = char_put(rec)
return {"content": [{"type": "text", "text": json.dumps(updated)}]}
async def _mcp_tool_list_rehearsals() -> dict:
from core.database import reh_get_all, reh_compact_titles
reh_compact_titles()
return {"content": [{"type": "text", "text": json.dumps(reh_get_all())}]}
def _mcp_error_response(code: int, message: str, rpc_id) -> Response:
import logging
body = {"jsonrpc": "2.0", "error": {"code": code, "message": message}, "id": rpc_id}
@ -1921,6 +2627,16 @@ async def mcp_jsonrpc(request: Request):
result = await _mcp_tool_list_captures()
elif tool_name == "list_profiles":
result = await _mcp_tool_list_profiles()
elif tool_name == "list_books":
result = await _mcp_tool_list_books()
elif tool_name == "list_characters":
result = await _mcp_tool_list_characters(tool_args)
elif tool_name == "get_character":
result = await _mcp_tool_get_character(tool_args)
elif tool_name == "update_character":
result = await _mcp_tool_update_character(tool_args)
elif tool_name == "list_rehearsals":
result = await _mcp_tool_list_rehearsals()
else:
return _mcp_error_response(-32601, f"Unknown tool: {tool_name}", rpc_id)
else:

View File

@ -85,7 +85,7 @@ async def start_local_container(name: str):
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
raise HTTPException(404, f"Unknown container: {name}")
try:
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/start")
code, _ = await asyncio.to_thread(_docker_post, f"/containers/{quote(name, safe='')}/start")
except Exception as e:
raise HTTPException(502, f"Docker start failed: {e}")
if code not in (204, 304):
@ -98,7 +98,7 @@ async def stop_local_container(name: str):
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
raise HTTPException(404, f"Unknown container: {name}")
try:
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/stop?t=10")
code, _ = await asyncio.to_thread(_docker_post, f"/containers/{quote(name, safe='')}/stop?t=10")
except Exception as e:
raise HTTPException(502, f"Docker stop failed: {e}")
if code not in (204, 304):
@ -111,7 +111,7 @@ async def restart_local_container(name: str):
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
raise HTTPException(404, f"Unknown container: {name}")
try:
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/restart?t=10")
code, _ = await asyncio.to_thread(_docker_post, f"/containers/{quote(name, safe='')}/restart?t=10")
except Exception as e:
raise HTTPException(502, f"Docker restart failed: {e}")
if code not in (204, 304):
@ -128,14 +128,24 @@ async def probe_url(url: str, type: str = "", api_key: str = ""):
base = base[: -len(_suffix)]
break
key_to_use = api_key if api_key else "sk-dummy-key"
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"}
# Anthropic doesn't speak the OpenAI-style Bearer auth every other card
# here uses — it needs x-api-key + an anthropic-version header, so the
# generic Bearer probe below would 401 even with a perfectly valid key.
if type == "anthropic":
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "x-api-key": key_to_use, "anthropic-version": "2023-06-01"}
else:
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"}
if type == "llm":
if type == "anthropic":
checks = [("/v1/models", "data")]
elif type == "llm":
checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)]
elif type == "stt":
checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)]
elif type == "tts":
checks = [("/health", None), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)]
elif type == "comfyui":
checks = [("/system_stats", "system")]
else:
checks = [("", None)]

View File

@ -26,7 +26,7 @@ from core.voice import (
_backup_original_voice, _backup_candidates, _backup_audio_suffix,
_remove_audio_variants, _remove_voice_package, _voice_package_paths,
_move_voice_package, _read_reference_text, _voice_health,
_is_internal_voice_file, _benchmark_voice,
_is_internal_voice_file, _benchmark_voice, _backup_existing_picture,
)
from core.voice_index import (
indexed_voices,
@ -40,6 +40,44 @@ from core.voice_index import (
router = APIRouter()
# ── Book/production profile (genre, setting, era, language) ──────────────────
# A one-time-per-book note the user fills in ("German, fantasy like Lord of
# the Rings, medieval times") so every LLM prompt this book generates —
# voice design, character portraits — carries real setting context instead
# of guessing generic defaults per character. Confirmed live as a recurring
# problem before this existed: fantasy-book characters designed with 1920s
# general portraits, and per-character language detection defaulting to
# English for sparse/minor characters with no descriptive text of their own.
def _book_profile_key(book: str) -> str:
return "book_profile::" + book.strip().lower()
@router.get("/api/book-profile")
async def get_book_profile(book: str):
from core.database import state_get
if not book.strip():
raise HTTPException(400, "book is required")
return state_get(_book_profile_key(book), {}) or {}
@router.post("/api/book-profile")
async def save_book_profile(request: Request):
from core.database import state_put
data = await request.json()
book = str(data.get("book", "")).strip()
if not book:
raise HTTPException(400, "book is required")
profile = {
"genre": str(data.get("genre", "")).strip()[:200],
"setting": str(data.get("setting", "")).strip()[:200],
"era": str(data.get("era", "")).strip()[:200],
"language": str(data.get("language", "")).strip()[:60],
}
state_put(_book_profile_key(book), profile)
return {"ok": True, "profile": profile}
# ── Upload ────────────────────────────────────────────────────────────────────
@router.post("/api/upload")
@ -208,15 +246,18 @@ async def save_voice(request: Request):
wav_dest = out_dir / f"{voice_id}.wav"
txt_dest = out_dir / f"{voice_id}.reference.txt"
existed = wav_dest.exists()
_remove_audio_variants(out_dir, voice_id)
loudness = _export_normalized_wav(src, wav_dest)
txt_dest.write_text(transcript, encoding="utf-8")
meta = _load_meta(wav_dest)
meta["enabled"] = True
meta["loudness"] = loudness
if existed:
meta["needs_tts_restart"] = True
_save_meta(wav_dest, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "loudness": loudness}
return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "loudness": loudness, "needs_tts_restart": existed}
# ── Voice library CRUD ────────────────────────────────────────────────────────
@ -707,6 +748,7 @@ async def upload_picture(voice_id: str = Form(...), file: UploadFile = File(...)
if orig_suffix not in _PICTURE_EXTS:
orig_suffix = ".jpg"
_backup_existing_picture(wav)
for ext in _PICTURE_EXTS:
old = wav.with_suffix(ext)
if old.exists():
@ -753,6 +795,7 @@ async def upload_picture_url(request: Request):
content_type = (r.headers.get("content-type") or "").split(";", 1)[0].lower()
if content_type and not content_type.startswith("image/"):
raise HTTPException(400, "Image URL did not return an image")
_backup_existing_picture(wav)
for ext in _PICTURE_EXTS:
old = wav.with_suffix(ext)
if old.exists():

View File

@ -12,13 +12,14 @@ from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from core.database import reh_get_all, reh_get, reh_put, reh_delete
from core.database import reh_get_all, reh_get, reh_put, reh_delete, reh_compact_titles
router = APIRouter()
@router.get("/api/rehearsals")
async def rehearsals_list():
reh_compact_titles()
return {"rehearsals": reh_get_all()}

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from core.config import _load_settings, _save_settings, _normalize_settings, _SETTINGS_KEYS
from core.config import _load_settings, _save_settings, _normalize_settings, _SETTINGS_KEYS, _ensure_external_api_key
from core.routing import _load_tts_routes, _save_tts_routes
from core.constants import _log_buffer, _LOG_BUFFER_MAX, _routing_log, _ROUTING_LOG_MAX
from core.presets import _load_design_presets, _save_design_presets
@ -13,7 +13,21 @@ router = APIRouter()
@router.get("/api/settings")
async def get_settings():
return _load_settings()
s = _load_settings()
# Lazily generated on first read, not at server startup — a fresh
# install shows a real usable key in Settings immediately without a
# migration step, whether or not the gate is actually turned on yet.
s["external_api_key"] = _ensure_external_api_key()
return s
@router.post("/api/settings/regenerate-api-key")
async def regenerate_api_key():
import secrets
s = _load_settings()
s["external_api_key"] = secrets.token_urlsafe(32)
_save_settings(s)
return {"external_api_key": s["external_api_key"]}
@router.post("/api/settings")

View File

@ -20,7 +20,7 @@ from fastapi.responses import Response, StreamingResponse
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
from core.constants import (
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
_routing_log_add,
_routing_log_add, CONFIG_DIR,
)
from core.routing import (
_load_tts_routes, _resolve_tts_route, _route_backend,
@ -469,7 +469,15 @@ async def restart_tts_container():
for container in containers:
path = f"/containers/{quote(container, safe='')}/restart?t=10"
try:
code, raw = _docker_post(path)
# _docker_post is a raw blocking socket call (core/docker_client.py)
# and this loop can span two containers x up to 10s each — run off
# the event loop thread, or every other request on this server
# (including a plain GET /api/characters) hangs for the whole
# restart instead of just this one call. Confirmed live: the
# Studio Voices tab's own character fetch silently failed and
# rendered "No characters yet" while a restart triggered elsewhere
# was still in flight.
code, raw = await asyncio.to_thread(_docker_post, path)
if code not in (204, 304):
detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"HTTP {code}"
errors.append(f"{container}: {detail}")
@ -1085,3 +1093,211 @@ async def get_seed_sample(voice_name: str, seed: int):
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
raise HTTPException(e.response.status_code, str(e))
@router.post("/api/audio/encode-mp3")
async def encode_mp3(request: Request):
"""Encode a raw WAV body into MP3 at an explicit bitrate.
audiobookExport() used to concatenate independently-encoded per-line MP3
byte streams directly into one Blob each clip carries its own frame/ID3
headers, so most players only decode the first one (confirmed live: an
85MB file that reported as 22s playable). The fix merges lossless WAV
clips client-side (mergeWavBlobs, already correct) and sends the single
merged WAV here for one real encode pass also fixes the previous
32kbps default (ffmpeg/lame's unset-bitrate fallback, not a deliberate
choice anywhere in this app) without pretending to add quality beyond
the engine's native 24kHz mono output.
"""
wav_bytes = await request.body()
if not wav_bytes:
raise HTTPException(400, "Empty request body")
try:
from pydub import AudioSegment
segment = AudioSegment.from_file(io.BytesIO(wav_bytes), format="wav")
out = io.BytesIO()
segment.export(out, format="mp3", bitrate="96k")
return Response(content=out.getvalue(), media_type="audio/mpeg")
except Exception as e:
raise HTTPException(400, f"Could not encode audio: {e}")
# ── Per-paragraph synthesized-audio cache ───────────────────────────────────
#
# "Synth all" pre-synthesizes every line for instant playback, but only ever
# kept the result in the browser tab's memory — closing the tab (or a crash,
# or just a normal reload) threw all of it away, and every future playback
# or export had to wait on the GPU again from scratch. This persists each
# line's audio to disk, keyed by a hash of its own content (text + voice +
# instruct/tone) rather than its position in the script — editing a
# paragraph changes its hash, so the edited version simply never matches a
# cached file and gets synthesized fresh, while an untouched paragraph
# reuses its file instantly regardless of how the surrounding lines shifted.
# The key is computed client-side (SHA-256 over the exact inputs that affect
# the audio) and treated here as an opaque cache token — this endpoint never
# needs to know what it means, only that the same key always means the same
# audio.
_LINE_AUDIO_DIR = CONFIG_DIR / "line_audio_cache"
_LINE_AUDIO_KEY_RE = re.compile(r"^[a-f0-9]{16,64}$")
def _line_audio_book_dir(book: str) -> Path:
safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book"
d = _LINE_AUDIO_DIR / safe_book
d.mkdir(parents=True, exist_ok=True)
return d
@router.get("/api/line-audio/{book}/{key}")
async def get_line_audio(book: str, key: str):
if not _LINE_AUDIO_KEY_RE.match(key):
raise HTTPException(400, "Invalid cache key")
path = _line_audio_book_dir(book) / f"{key}.wav"
if not path.exists():
raise HTTPException(404, "Not cached")
return Response(content=path.read_bytes(), media_type="audio/wav")
@router.post("/api/line-audio/{book}/check")
async def check_line_audio(book: str, request: Request):
"""Bulk existence check — one request instead of one GET per line — so
the Stage page can mark its "pre-synthesized" dots correctly right after
a reload, instead of every dot looking unsynthesized just because
rehState.synthCache (this browser tab's own memory) starts empty on
every fresh page load even when the audio is sitting on disk already."""
data = await request.json()
keys = data.get("keys") or []
if not isinstance(keys, list):
raise HTTPException(400, "keys must be a list of cache keys")
book_dir = _line_audio_book_dir(book)
existing = [k for k in keys if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k) and (book_dir / f"{k}.wav").exists()]
return {"ok": True, "existing": existing}
@router.post("/api/line-audio/{book}/prune")
async def prune_line_audio(book: str, request: Request):
"""Delete cached files for this book that no longer match any current
line a paragraph's cache key is its own content hash, so editing it
just makes the old file unreachable rather than actively removing it
(nothing on the write path knows a "previous" key exists to delete).
The client sends every key still valid for the CURRENT script; anything
else on disk for this book is safe to remove.
Registered BEFORE the generic POST /api/line-audio/{book}/{key} route
below FastAPI matches routes in declaration order, and {key} is just
a plain path segment at the routing level (its regex validation only
runs inside the handler, after routing already picked one), so a
literal "prune" segment would otherwise always match that generic
route first and this one would never be reached at all.
"""
data = await request.json()
keep = data.get("keep") or []
if not isinstance(keep, list):
raise HTTPException(400, "keep must be a list of cache keys")
keep_set = {k for k in keep if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k)}
book_dir = _line_audio_book_dir(book)
deleted = 0
for f in book_dir.glob("*.wav"):
if f.stem not in keep_set:
try:
f.unlink()
deleted += 1
except OSError:
pass
return {"ok": True, "deleted": deleted, "kept": len(keep_set)}
@router.post("/api/line-audio/{book}/{key}")
async def put_line_audio(book: str, key: str, request: Request):
if not _LINE_AUDIO_KEY_RE.match(key):
raise HTTPException(400, "Invalid cache key")
wav_bytes = await request.body()
if not wav_bytes:
raise HTTPException(400, "Empty request body")
path = _line_audio_book_dir(book) / f"{key}.wav"
path.write_bytes(wav_bytes)
return {"ok": True}
# ── Finished audiobook chapter exports ──────────────────────────────────────
#
# audiobookExport() already triggers a browser download per chapter, but
# that only ever lands wherever the browser's download settings put it —
# confirmed as a real gap: nothing in the app itself says where the files
# went, and re-finding a chapter later means re-running the whole export.
# This additionally saves the exact same file server-side so the app can
# show a real download link (and the on-disk path) right after the export
# finishes, and again any time later without resynthesizing anything.
_AUDIOBOOK_EXPORT_DIR = CONFIG_DIR / "audiobook_exports"
_EXPORT_FILENAME_RE = re.compile(r"^[^/\\]{1,200}$") # any single path segment, no traversal
def _audiobook_export_book_dir(book: str) -> Path:
safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book"
d = _AUDIOBOOK_EXPORT_DIR / safe_book
d.mkdir(parents=True, exist_ok=True)
return d
@router.get("/api/audiobook-export/{book}")
async def list_audiobook_exports(book: str):
"""List previously-saved chapter exports for this book — lets the app
show a "browse what's already been exported" view without re-running
the export, and without any real filesystem access on the user's part.
Registered before the generic GET .../{filename} route below for the
same reason "zip" and "check"/"prune" are elsewhere in this file: a
plain path segment matches ANY literal string at the routing level.
"""
book_dir = _audiobook_export_book_dir(book)
files = sorted(
({"name": f.name, "size": f.stat().st_size} for f in book_dir.iterdir() if f.is_file()),
key=lambda x: x["name"],
)
return {"book": book, "files": files, "dir": str(book_dir)}
@router.get("/api/audiobook-export/{book}/zip")
async def zip_audiobook_exports(book: str):
"""Bundle every saved chapter for this book into one ZIP download —
the "download everything at once" the per-file list doesn't offer."""
import zipfile
book_dir = _audiobook_export_book_dir(book)
files = [f for f in book_dir.iterdir() if f.is_file()]
if not files:
raise HTTPException(404, "No exported files for this book")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
for f in files:
zf.write(f, arcname=f.name)
buf.seek(0)
zip_name = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "audiobook"
return Response(
content=buf.getvalue(), media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_name}.zip"'},
)
@router.get("/api/audiobook-export/{book}/{filename}")
async def get_audiobook_export(book: str, filename: str):
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
raise HTTPException(400, "Invalid filename")
path = _audiobook_export_book_dir(book) / filename
if not path.exists() or not path.is_file():
raise HTTPException(404, "Not found")
media_type = "audio/mpeg" if path.suffix.lower() == ".mp3" else "audio/wav"
return Response(
content=path.read_bytes(), media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/audiobook-export/{book}/{filename}")
async def put_audiobook_export(book: str, filename: str, request: Request):
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
raise HTTPException(400, "Invalid filename")
audio_bytes = await request.body()
if not audio_bytes:
raise HTTPException(400, "Empty request body")
path = _audiobook_export_book_dir(book) / filename
path.write_bytes(audio_bytes)
return {"ok": True, "path": str(path)}

View File

@ -23,7 +23,7 @@ const MAIN = [
'voice-picker', 'benchmark-voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
'characters-library', 'sillytavern', 'library', 'library-characters',
'characters-library', 'sillytavern', 'library', 'library-characters', 'studio',
].map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');

View File

@ -15,7 +15,7 @@ from fastapi.middleware.gzip import GZipMiddleware
from starlette.datastructures import MutableHeaders
from core.constants import STATIC_DIR, _BufferHandler
from core.config import _load_settings
from core.config import _load_settings, _ensure_external_api_key
from core.voice_index import refresh_voice_index_background
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db
@ -116,6 +116,75 @@ class StaticCacheHeadersMiddleware:
app.add_middleware(StaticCacheHeadersMiddleware)
# ── API-key gate for non-browser callers ────────────────────────────────────
# The app's own UI calls /api/* same-origin from the browser and needs no
# key — everything else (curl, scripts, MCP clients, agents) does. Gated by
# Origin/Referer host matching the request's own Host header, which the
# browser sets automatically and a bare script/curl call generally doesn't.
# /mcp always requires the key regardless of origin, since no in-app browser
# code calls it — it exists specifically for external MCP clients.
# Raw ASGI (not @app.middleware("http")/BaseHTTPMiddleware) for the same
# reason as StaticCacheHeadersMiddleware above: that wrapper's task-group
# around call_next() fights with a client disconnecting mid-SSE-stream.
class ApiKeyGateMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope["path"]
if not (path.startswith("/api/") or path == "/mcp"):
await self.app(scope, receive, send)
return
if not _load_settings().get("external_api_key_required"):
# Off by default — see the settings-key comment in core/config.py
# for why. /mcp still needs SOME signal it's being used
# deliberately even while the general gate is off, but that's a
# judgment call for whoever enables it, not a silent bypass.
await self.app(scope, receive, send)
return
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
host = headers.get("host", "")
def _same_origin(url: str) -> bool:
if not url or not host:
return False
try:
from urllib.parse import urlparse
return urlparse(url).netloc == host
except Exception:
return False
is_browser_same_origin = path != "/mcp" and (
_same_origin(headers.get("origin", "")) or _same_origin(headers.get("referer", ""))
)
if is_browser_same_origin:
await self.app(scope, receive, send)
return
expected = _ensure_external_api_key()
provided = headers.get("x-api-key", "")
if not provided and headers.get("authorization", "").lower().startswith("bearer "):
provided = headers["authorization"][7:]
if provided and provided == expected:
await self.app(scope, receive, send)
return
import json as _json
body = _json.dumps({"detail": "Missing or invalid API key — pass it as X-API-Key. Find/regenerate it in Settings > API Keys > External API Access."}).encode()
await send({
"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())],
})
await send({"type": "http.response.body", "body": body})
app.add_middleware(ApiKeyGateMiddleware)
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(admin.router)

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.14.24">
<meta name="app-version" content="1.17.95">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.14.24">
<link rel="stylesheet" href="/static/style.css?v=1.17.95">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -157,6 +157,17 @@
<div class="nav-tree" id="nav-speak-tree">
<div class="nav-tree-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"><span class="mdi mdi-play"></span> Quick Play</div>
<div class="nav-tree-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"><span class="mdi mdi-forum-outline"></span> Conversation</div>
<div class="nav-tree-head nav-subhead" data-nav-section="s-caststudio" id="nav-caststudio-head" onclick="navTo('s-caststudio')">
<span class="nav-icon"><span class="mdi mdi-movie-open-play"></span></span>
<span class="nav-label">Studio</span>
<span class="nav-chevron" id="nav-caststudio-chevron" onclick="event.stopPropagation();toggleNavTree('nav-caststudio-tree','nav-caststudio-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-caststudio-tree">
<div class="nav-tree-item is-active" data-stu-phase="1" onclick="navTo('s-caststudio');if(typeof showStudioPhase==='function')showStudioPhase(1)"><span class="mdi mdi-file-document-outline"></span> Source</div>
<div class="nav-tree-item" data-stu-phase="2" onclick="navTo('s-caststudio');if(typeof showStudioPhase==='function')showStudioPhase(2)"><span class="mdi mdi-drama-masks"></span> Characters</div>
<div class="nav-tree-item" data-stu-phase="3" onclick="navTo('s-caststudio');if(typeof showStudioPhase==='function')showStudioPhase(3)"><span class="mdi mdi-account-voice"></span> Voices</div>
<div class="nav-tree-item" data-stu-phase="4" onclick="navTo('s-caststudio');if(typeof showStudioPhase==='function')showStudioPhase(4)"><span class="mdi mdi-theater"></span> Perform &amp; Export</div>
</div>
<div class="nav-tree-head nav-subhead" data-nav-section="s-reader" id="nav-reader-head" onclick="navTo('s-reader')">
<span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span>
<span class="nav-label">Read Aloud</span>
@ -203,6 +214,7 @@
<div class="nav-tree-item is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')"><span class="mdi mdi-brain"></span> Language Models</div>
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-account-voice"></span> Text to Speech</div>
<div class="nav-tree-item" data-engines-cat="image" onclick="navEnginesCat('image')"><span class="mdi mdi-image-outline"></span> Image Generation</div>
</div>
<div class="nav-tree-head nav-subhead" id="nav-integrations-head" onclick="toggleNavTree('nav-integrations-tree','nav-integrations-chevron')">
<span class="nav-icon"><span class="mdi mdi-transit-connection-variant"></span></span>
@ -309,6 +321,7 @@
<section class="page-section" id="s-llms" style="display:none"></section>
<section class="page-section" id="s-rehearser" style="display:none"></section>
<section class="page-section" id="s-reader" style="display:none"></section>
<section class="page-section" id="s-caststudio" style="display:none"></section>
<section class="page-section" id="s-conversation" style="display:none"></section>
<section class="page-section" id="s-library" style="display:none"></section>
</main>
@ -365,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.14.24"></script>
<script src="/static/loader.js?v=1.17.95"></script>
</body>
</html>

View File

@ -55,6 +55,83 @@
});
});
// Cloud LLM cards (Groq/OpenRouter/Gemini/Mistral/OpenAI) — Connect (probe
// reachability with the entered key) and "Use as LLM" (make this the
// app's active LLM endpoint), mirroring the same two actions the local
// Docker engine cards already have further up this page.
document.querySelectorAll('.llm-cloud-connect-btn[data-llm-key]').forEach(btn => {
const key = btn.dataset.llmKey;
const endpoint = btn.dataset.llmEndpoint;
const probeType = btn.dataset.llmProbeType || 'llm';
const card = btn.closest('.llm-card');
if (localStorage.getItem('llm-cloud-con-' + key) === '1') {
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
card?.classList.add('llm-local-card-online');
}
btn.addEventListener('click', async () => {
if (btn.classList.contains('ok')) {
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
btn.className = 'llm-cloud-connect-btn llm-local-ping';
card?.classList.remove('llm-local-card-online');
localStorage.removeItem('llm-cloud-con-' + key);
return;
}
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || '';
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
try {
const d = await probeUrl(endpoint, probeType, apiKey);
if (d.ok) {
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
card?.classList.add('llm-local-card-online');
localStorage.setItem('llm-cloud-con-' + key, '1');
toast('✓ Reachable — ' + (d.endpoint || endpoint), 'success');
} else {
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
btn.className = 'llm-cloud-connect-btn llm-local-ping';
localStorage.removeItem('llm-cloud-con-' + key);
toast('Cannot reach ' + endpoint + ': ' + (d.error || 'No response'), 'error');
}
} catch (e) {
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
btn.className = 'llm-cloud-connect-btn llm-local-ping';
toast('Probe failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
});
document.querySelectorAll('.llm-cloud-use-btn[data-llm-key]').forEach(btn => {
btn.addEventListener('click', async () => {
const key = btn.dataset.llmKey;
const url = btn.dataset.llmEndpoint;
const model = btn.dataset.llmModel || '';
const card = btn.closest('.llm-card');
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || '';
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
if (btn.dataset.llmIncompatible && typeof confirmDialog === 'function') {
const proceed = await confirmDialog(
"Anthropic doesn't speak the OpenAI-compatible /chat/completions format every LLM call in this app uses — setting it active will very likely break generation until Anthropic support is added. Set it anyway?",
{ title: 'Not OpenAI-compatible', okLabel: 'Set anyway', danger: true }
);
if (!proceed) return;
}
const patch = { llm_url: url, llm_api_key: apiKey };
if (model) patch.llm_model = model;
await applyAndSaveSettings(patch);
document.querySelectorAll('.llm-cloud-use-btn.active').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
window.syncActiveLlmPanel?.(url, apiKey);
toast(`✓ Active LLM set to ${card?.querySelector('.llm-card-name')?.textContent || key}`, 'success');
});
});
// Local service URL inputs + Connect / Disconnect
function normalizeProbeUrl(raw) {
// 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,16 @@
// Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics.
const CL_EDIT_FIELDS = [
['name', 'Name'], ['aliases', 'Aliases / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['full_name', 'Full name'], ['title', 'Title / role'],
['name', 'Name'], ['aliases', 'Aliases / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['title', 'Title / role'],
['age_estimate', 'Estimated age'], ['race_species', 'Race / species'], ['languages', 'Languages'],
['nationality_background', 'Nationality / background'], ['social_class', 'Social class'],
['archetype', 'Archetype'],
['physical', 'Physical'], ['clothing', 'Clothing & Appearance'],
['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'],
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
['communication_style', 'Communication style'], ['reputation', 'Reputation'], ['religious_beliefs', 'Religious beliefs'], ['notes', 'Notes'],
['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'],
['silly_tavern_prompt', 'SillyTavern Character Prompt'], ['concept_art_prompt', 'Concept Art Prompt'],
['secret', 'Dark Secret / Fatal Flaw'],
@ -46,7 +49,12 @@ async function clPut(rec) {
body: JSON.stringify(rec),
});
if (!r.ok) throw new Error('clPut failed: ' + r.status);
return r.json();
const saved = await r.json();
// Lets an already-open Stage/rehearsal for this same book pick up a voice
// change immediately instead of silently keeping the old one — see
// _rehSyncCastVoiceFromLibrary's own comment for the full story.
if (typeof window._rehSyncCastVoiceFromLibrary === 'function') window._rehSyncCastVoiceFromLibrary(saved);
return saved;
}
async function clDelete(id) {
@ -77,7 +85,14 @@ function clNormalizeColor(color, name) {
return clHslToHex(clNameHue(name), 58, 43);
}
const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title'];
// Deliberately excludes 'title'/'profession' etc. — mirrors CS_IDENTITY_FIELDS
// in character-sheets.js (see the comment there): those are purely
// descriptive text that unrelated characters routinely share verbatim (job
// titles, epithets like "the Executioner" reused for a different character
// later in the book), so treating a shared title as proof of shared identity
// silently merged two different characters' persisted library records into
// one, corrupting both. Keep this list in sync with CS_IDENTITY_FIELDS.
const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name'];
const CL_ALIAS_MAX_TOKENS = 12;
const CL_ALIAS_MAX_CHARS = 500;
// Bare articles/pronouns can end up as "aliases" when a descriptive alias like
@ -175,13 +190,27 @@ function clMergeTags(...parts) {
}
// Upsert one sheet into the library under a book. Returns the stored record.
async function clUpsert(book, sheet) {
// `knownId` bypasses the alias-based identity guess below entirely — pass it
// whenever the caller already holds a concrete, previously-loaded record
// (e.g. saving a voice/image pick from that record's own card/row) rather
// than a freshly-extracted sheet with no stable home yet. Without it, a
// character whose OWN alias list happens to also name a different character
// in the same book (confirmed live: an LLM-extracted "aliases" field for one
// character literally included another character's real name — an
// extraction slip, not a genuine same-person case) silently redirects the
// write to that OTHER character's record instead, since clSameIdentity only
// needs one alias token to overlap. Alias matching is still exactly right
// for its original purpose — deduping a freshly-extracted sheet against
// whatever's already stored — just not for updating a record the caller can
// already point at directly by id.
async function clUpsert(book, sheet, knownId) {
const name = (sheet.name || '').trim();
if (!name) return null;
const bk = (book || '').trim() || 'Unsorted';
const all = await clGetAll().catch(() => []);
const aliasPrev = all.find(r => clSameIdentity(r, bk, sheet));
const id = aliasPrev?.id || clKey(bk, name);
const aliasPrev = knownId
? await clGet(knownId).catch(() => null)
: (await clGetAll().catch(() => [])).find(r => clSameIdentity(r, bk, sheet));
const id = aliasPrev?.id || knownId || clKey(bk, name);
const now = new Date();
const prev = aliasPrev || await clGet(id).catch(() => null);
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name };
@ -195,8 +224,17 @@ async function clUpsert(book, sheet) {
sheet: merged,
color,
analysis: prev?.analysis || null,
voice: prev?.voice || sheet.voice || null,
image: prev?.image || sheet.image || null,
// An explicit new value from the caller (e.g. picking a different voice
// in the picker) must win over whatever was already stored — this used
// to be `prev.voice || sheet.voice`, so once a character had ANY voice,
// every later reassignment silently no-op'd: the picker showed a
// "success" toast and updated the in-memory rec, but the persisted
// record kept the OLD voice forever, with the wrong voice then used for
// every audio-generation pass. Character-sheet regeneration passes never
// set voice/image at all, so falling back to prev here is still correct
// for that path.
voice: sheet.voice || prev?.voice || null,
image: sheet.image || prev?.image || null,
created: prev?.created || now,
updated: now,
};
@ -323,7 +361,6 @@ function clApplyFilter() {
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
(r.sheet?.first_name || '').toLowerCase().includes(q) ||
(r.sheet?.last_name || '').toLowerCase().includes(q) ||
(r.sheet?.full_name || '').toLowerCase().includes(q) ||
(r.sheet?.title || '').toLowerCase().includes(q) ||
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
(r.tags || '').toLowerCase().includes(q) ||
@ -356,17 +393,12 @@ function clApplyFilter() {
}
function clCardHtml(rec) {
const tags = String(rec.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const chips = tags.length
? `<div class="cl-card-tags">${tags.map(t => `<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>${escHtml(t)}</span>`).join('')}</div>`
: '';
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
<div class="cl-card-tools">
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
</div>
${csCardHtml(rec.sheet)}
${chips}
${typeof csOverviewCardHtml === 'function' ? csOverviewCardHtml(rec) : csCardHtml(rec.sheet)}
</div>`;
}

View File

@ -145,7 +145,7 @@
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
const lang = (v.language || 'EN').slice(0, 2).toUpperCase();
const base = (v.title || 'fishaudio').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice';
const base = (typeof _umlautSafe === 'function' ? _umlautSafe(v.title || 'fishaudio') : (v.title || 'fishaudio')).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice';
const voiceId = `${lang}_${base}`;
try {
const r = await fetch('/api/quick-import-voice', {

View File

@ -8,8 +8,15 @@
// window.t('English') for JS-generated strings. Dynamic lists can be translated by
// calling window.applyI18n(container) after rendering.
const I18N_LANGS = { en: 'English', de: 'Deutsch' };
const I18N_LANGS = {
en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español',
it: 'Italiano', pt: 'Português', nl: 'Nederlands', pl: 'Polski',
};
// Every language block below covers the exact same key set — the English UI
// chrome strings (nav, section titles/subtitles, common buttons/labels).
// Adding a new key? Add it to ALL blocks, or it silently falls back to
// English for languages that don't have it yet (see window.t / applyI18n).
const I18N_DICT = {
de: {
// Brand / sidebar groups
@ -64,15 +71,351 @@ const I18N_DICT = {
'All genders': 'Alle Geschlechter', 'Microphone': 'Mikrofon', 'Upload file': 'Datei hochladen',
'Sort': 'Sortieren', 'Cards': 'Karten', 'List': 'Liste', 'Develop': 'Ausarbeiten',
'Match local': 'Lokal zuordnen', 'Match online': 'Online zuordnen', 'Design all': 'Alle entwerfen',
'I play this': 'Ich spiele das', 'Save to library': 'In Bibliothek speichern',
'I play this': 'Ich spiele das',
'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen',
'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript',
'Preview': 'Vorschau', 'Trim your sample': 'Probe zuschneiden', 'Language': 'Sprache',
'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Tags': 'Tags', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
// Common placeholders
'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...',
'Filter name or tag…': 'Name oder Tag filtern…',
},
fr: {
'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Cloner · Concevoir · Déployer',
'Voices': 'Voix', 'Setup': 'Configuration', 'Tags': 'Étiquettes',
'My Voices': 'Mes voix', 'All voices': 'Toutes les voix', 'Cloned': 'Clonées',
'Designed': 'Conçues', 'Favorites': 'Favoris', 'Hidden': 'Masquées',
'Library tools': 'Outils de bibliothèque',
'Clone a Voice': 'Cloner une voix', 'Design a Voice': 'Concevoir une voix',
'Get Voices Online': 'Obtenir des voix en ligne', 'Try It Out': 'Essayer',
'Read Aloud': 'Lecture à voix haute',
'Script Rehearser': 'Répétition de script', 'Library': 'Bibliothèque', 'Cast': 'Distribution',
'Stage': 'Scène', 'Summary': 'Résumé', 'Import / Export': 'Import / Export',
'Conversation': 'Conversation', 'Benchmark': 'Benchmark', 'Engines': 'Moteurs',
'Language Models': 'Modèles de langage', 'Speech to Text': 'Voix vers texte',
'Text to Speech': 'Texte vers voix', 'App Routing': "Routage de l'app",
'Connect Apps': 'Connecter des apps', 'Settings': 'Paramètres',
'Conversation Playground': 'Terrain de jeu conversation',
'Pick a voice on the left, edit on the right.': 'Choisissez une voix à gauche, modifiez-la à droite.',
'Capture 320 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
"Enregistrez 3 à 20 secondes de parole claire, découpez-la, nommez-la, puis enregistrez-la comme clone de voix réutilisable.",
'Describe a voice in words and let the AI create it. No recording needed.':
"Décrivez une voix avec des mots et laissez l'IA la créer. Aucun enregistrement nécessaire.",
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
"Parcourez des sources publiques d'extraits vocaux, prévisualisez des fichiers audio et importez des voix depuis le web.",
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
"Générez de la parole à partir de texte avec n'importe quel moteur et voix. Transcrivez aussi l'audio et refaites-le parler.",
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Téléversez un script, attribuez des voix TTS ou votre micro aux personnages, puis répétez scène par scène.',
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
"Importez un PDF ou un document texte, choisissez une voix et une vitesse, puis faites-le lire à voix haute avec surlignage du mot prononcé.",
'My books': 'Mes livres', 'Voice consistency': 'Cohérence de la voix',
'Normalise loudness': 'Normaliser le volume', 'Export MP3': 'Exporter en MP3',
'Select range': 'Sélectionner une plage', 'Synthesise': 'Synthétiser',
'Tone / style': 'Ton / style', 'Temperature': 'Température', 'Speed': 'Vitesse',
'Saved documents with their synthesised audio — reopen to continue.':
'Documents enregistrés avec leur audio synthétisé — rouvrez pour continuer.',
'Save changes': 'Enregistrer les modifications', 'Save to library': 'Enregistrer dans la bibliothèque',
'Save to Voice Library': 'Enregistrer dans la bibliothèque de voix', 'Delete voice': 'Supprimer la voix',
'Cancel': 'Annuler', 'Refresh': 'Actualiser', 'Delete': 'Supprimer', 'Save': 'Enregistrer',
'Back': 'Retour', 'Apply →': 'Appliquer →', 'Browse': 'Parcourir', 'Download': 'Télécharger',
'Record': 'Enregistrer', 'Stop': 'Arrêter', 'Play': 'Lire', 'Play selection': 'Lire la sélection',
'Check level': 'Vérifier le niveau', 'Stop monitor': 'Arrêter le moniteur', 'Auto trim': 'Découpe auto',
'Auto-transcribe': 'Transcription auto', 'Active': 'Actif', 'copy ID': "copier l'ID",
'edit ID': "modifier l'ID", 'New voice': 'Nouvelle voix', 'All languages': 'Toutes les langues',
'All genders': 'Tous les genres', 'Microphone': 'Microphone', 'Upload file': 'Téléverser un fichier',
'Sort': 'Trier', 'Cards': 'Cartes', 'List': 'Liste', 'Develop': 'Développer',
'Match local': 'Correspondance locale', 'Match online': 'Correspondance en ligne', 'Design all': 'Tout concevoir',
'I play this': 'Je joue ce personnage',
'Open rehearsal': 'Ouvrir la répétition', 'Fetch voices': 'Récupérer les voix',
'Name your voice': 'Nommez votre voix', 'Reference transcript': 'Transcription de référence',
'Preview': 'Aperçu', 'Trim your sample': 'Découpez votre échantillon', 'Language': 'Langue',
'Gender': 'Genre', 'Voice': 'Voix', 'Speaking style · voice-design prompt': "Style d'élocution · invite de conception vocale",
'Search voices…': 'Rechercher des voix…', 'Search voices...': 'Rechercher des voix...',
'Filter name or tag…': 'Filtrer par nom ou étiquette…',
},
es: {
'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Clonar · Diseñar · Implementar',
'Voices': 'Voces', 'Setup': 'Configuración', 'Tags': 'Etiquetas',
'My Voices': 'Mis voces', 'All voices': 'Todas las voces', 'Cloned': 'Clonadas',
'Designed': 'Diseñadas', 'Favorites': 'Favoritos', 'Hidden': 'Ocultas',
'Library tools': 'Herramientas de biblioteca',
'Clone a Voice': 'Clonar una voz', 'Design a Voice': 'Diseñar una voz',
'Get Voices Online': 'Obtener voces en línea', 'Try It Out': 'Probarlo',
'Read Aloud': 'Leer en voz alta',
'Script Rehearser': 'Ensayo de guion', 'Library': 'Biblioteca', 'Cast': 'Reparto',
'Stage': 'Escenario', 'Summary': 'Resumen', 'Import / Export': 'Importar / Exportar',
'Conversation': 'Conversación', 'Benchmark': 'Benchmark', 'Engines': 'Motores',
'Language Models': 'Modelos de lenguaje', 'Speech to Text': 'Voz a texto',
'Text to Speech': 'Texto a voz', 'App Routing': 'Enrutamiento de la app',
'Connect Apps': 'Conectar apps', 'Settings': 'Ajustes',
'Conversation Playground': 'Zona de pruebas de conversación',
'Pick a voice on the left, edit on the right.': 'Elige una voz a la izquierda, edítala a la derecha.',
'Capture 320 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
'Graba de 3 a 20 segundos de habla limpia, recórtala, nómbrala y guárdala como un clon de voz reutilizable.',
'Describe a voice in words and let the AI create it. No recording needed.':
'Describe una voz con palabras y deja que la IA la cree. No se necesita grabación.',
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
'Explora fuentes públicas de clips de voz, previsualiza archivos de audio e importa voces desde la web.',
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
'Genera voz a partir de texto con cualquier motor y voz. También transcribe audio y vuelve a reproducirlo hablado.',
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Sube un guion, asigna voces TTS o tu propio micrófono a los personajes y ensaya escena por escena.',
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
'Importa un PDF o documento de texto, elige una voz y velocidad, y escúchalo mientras se resalta la palabra que se está leyendo.',
'My books': 'Mis libros', 'Voice consistency': 'Consistencia de voz',
'Normalise loudness': 'Normalizar volumen', 'Export MP3': 'Exportar MP3',
'Select range': 'Seleccionar rango', 'Synthesise': 'Sintetizar',
'Tone / style': 'Tono / estilo', 'Temperature': 'Temperatura', 'Speed': 'Velocidad',
'Saved documents with their synthesised audio — reopen to continue.':
'Documentos guardados con su audio sintetizado — vuelve a abrirlos para continuar.',
'Save changes': 'Guardar cambios', 'Save to library': 'Guardar en la biblioteca',
'Save to Voice Library': 'Guardar en la biblioteca de voces', 'Delete voice': 'Eliminar voz',
'Cancel': 'Cancelar', 'Refresh': 'Actualizar', 'Delete': 'Eliminar', 'Save': 'Guardar',
'Back': 'Atrás', 'Apply →': 'Aplicar →', 'Browse': 'Explorar', 'Download': 'Descargar',
'Record': 'Grabar', 'Stop': 'Detener', 'Play': 'Reproducir', 'Play selection': 'Reproducir selección',
'Check level': 'Comprobar nivel', 'Stop monitor': 'Detener monitor', 'Auto trim': 'Recorte automático',
'Auto-transcribe': 'Transcripción automática', 'Active': 'Activo', 'copy ID': 'copiar ID',
'edit ID': 'editar ID', 'New voice': 'Nueva voz', 'All languages': 'Todos los idiomas',
'All genders': 'Todos los géneros', 'Microphone': 'Micrófono', 'Upload file': 'Subir archivo',
'Sort': 'Ordenar', 'Cards': 'Tarjetas', 'List': 'Lista', 'Develop': 'Desarrollar',
'Match local': 'Coincidencia local', 'Match online': 'Coincidencia en línea', 'Design all': 'Diseñar todo',
'I play this': 'Yo interpreto esto',
'Open rehearsal': 'Abrir ensayo', 'Fetch voices': 'Obtener voces',
'Name your voice': 'Nombra tu voz', 'Reference transcript': 'Transcripción de referencia',
'Preview': 'Vista previa', 'Trim your sample': 'Recorta tu muestra', 'Language': 'Idioma',
'Gender': 'Género', 'Voice': 'Voz', 'Speaking style · voice-design prompt': 'Estilo de habla · prompt de diseño de voz',
'Search voices…': 'Buscar voces…', 'Search voices...': 'Buscar voces...',
'Filter name or tag…': 'Filtrar por nombre o etiqueta…',
},
it: {
'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Clona · Progetta · Distribuisci',
'Voices': 'Voci', 'Setup': 'Configurazione', 'Tags': 'Tag',
'My Voices': 'Le mie voci', 'All voices': 'Tutte le voci', 'Cloned': 'Clonate',
'Designed': 'Progettate', 'Favorites': 'Preferiti', 'Hidden': 'Nascoste',
'Library tools': 'Strumenti libreria',
'Clone a Voice': 'Clona una voce', 'Design a Voice': 'Progetta una voce',
'Get Voices Online': 'Ottieni voci online', 'Try It Out': 'Prova',
'Read Aloud': 'Leggi ad alta voce',
'Script Rehearser': 'Prova script', 'Library': 'Libreria', 'Cast': 'Cast',
'Stage': 'Palco', 'Summary': 'Riepilogo', 'Import / Export': 'Importa / Esporta',
'Conversation': 'Conversazione', 'Benchmark': 'Benchmark', 'Engines': 'Motori',
'Language Models': 'Modelli linguistici', 'Speech to Text': 'Voce in testo',
'Text to Speech': 'Testo in voce', 'App Routing': 'Instradamento app',
'Connect Apps': 'Connetti app', 'Settings': 'Impostazioni',
'Conversation Playground': 'Area di prova conversazione',
'Pick a voice on the left, edit on the right.': 'Scegli una voce a sinistra, modificala a destra.',
'Capture 320 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
'Registra 3-20 secondi di voce pulita, taglia, assegna un nome e salva come clone vocale riutilizzabile.',
'Describe a voice in words and let the AI create it. No recording needed.':
"Descrivi una voce a parole e lascia che l'IA la crei. Nessuna registrazione necessaria.",
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
'Sfoglia fonti pubbliche di clip vocali, anteprima file audio e importa voci dal web.',
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
"Genera voce dal testo con qualsiasi backend e voce. Trascrivi anche l'audio e falla riparlare.",
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Carica uno script, assegna voci TTS o il tuo microfono ai personaggi, poi prova scena per scena.',
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
"Importa un PDF o un documento di testo, scegli voce e velocità, e fallo leggere ad alta voce con evidenziazione della parola pronunciata.",
'My books': 'I miei libri', 'Voice consistency': 'Coerenza vocale',
'Normalise loudness': 'Normalizza volume', 'Export MP3': 'Esporta MP3',
'Select range': 'Seleziona intervallo', 'Synthesise': 'Sintetizza',
'Tone / style': 'Tono / stile', 'Temperature': 'Temperatura', 'Speed': 'Velocità',
'Saved documents with their synthesised audio — reopen to continue.':
'Documenti salvati con il loro audio sintetizzato — riaprili per continuare.',
'Save changes': 'Salva modifiche', 'Save to library': 'Salva nella libreria',
'Save to Voice Library': 'Salva nella libreria vocale', 'Delete voice': 'Elimina voce',
'Cancel': 'Annulla', 'Refresh': 'Aggiorna', 'Delete': 'Elimina', 'Save': 'Salva',
'Back': 'Indietro', 'Apply →': 'Applica →', 'Browse': 'Sfoglia', 'Download': 'Scarica',
'Record': 'Registra', 'Stop': 'Ferma', 'Play': 'Riproduci', 'Play selection': 'Riproduci selezione',
'Check level': 'Controlla livello', 'Stop monitor': 'Ferma monitor', 'Auto trim': 'Taglio automatico',
'Auto-transcribe': 'Trascrizione automatica', 'Active': 'Attivo', 'copy ID': 'copia ID',
'edit ID': 'modifica ID', 'New voice': 'Nuova voce', 'All languages': 'Tutte le lingue',
'All genders': 'Tutti i generi', 'Microphone': 'Microfono', 'Upload file': 'Carica file',
'Sort': 'Ordina', 'Cards': 'Schede', 'List': 'Elenco', 'Develop': 'Sviluppa',
'Match local': 'Abbinamento locale', 'Match online': 'Abbinamento online', 'Design all': 'Progetta tutto',
'I play this': 'Interpreto io questo',
'Open rehearsal': 'Apri prova', 'Fetch voices': 'Recupera voci',
'Name your voice': 'Assegna un nome alla voce', 'Reference transcript': 'Trascrizione di riferimento',
'Preview': 'Anteprima', 'Trim your sample': 'Taglia il tuo campione', 'Language': 'Lingua',
'Gender': 'Genere', 'Voice': 'Voce', 'Speaking style · voice-design prompt': 'Stile di parlato · prompt di progettazione vocale',
'Search voices…': 'Cerca voci…', 'Search voices...': 'Cerca voci...',
'Filter name or tag…': 'Filtra per nome o tag…',
},
pt: {
'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Clonar · Projetar · Implantar',
'Voices': 'Vozes', 'Setup': 'Configuração', 'Tags': 'Etiquetas',
'My Voices': 'Minhas vozes', 'All voices': 'Todas as vozes', 'Cloned': 'Clonadas',
'Designed': 'Projetadas', 'Favorites': 'Favoritos', 'Hidden': 'Ocultas',
'Library tools': 'Ferramentas da biblioteca',
'Clone a Voice': 'Clonar uma voz', 'Design a Voice': 'Projetar uma voz',
'Get Voices Online': 'Obter vozes online', 'Try It Out': 'Experimentar',
'Read Aloud': 'Leitura em voz alta',
'Script Rehearser': 'Ensaio de roteiro', 'Library': 'Biblioteca', 'Cast': 'Elenco',
'Stage': 'Palco', 'Summary': 'Resumo', 'Import / Export': 'Importar / Exportar',
'Conversation': 'Conversa', 'Benchmark': 'Benchmark', 'Engines': 'Motores',
'Language Models': 'Modelos de linguagem', 'Speech to Text': 'Voz para texto',
'Text to Speech': 'Texto para voz', 'App Routing': 'Roteamento do app',
'Connect Apps': 'Conectar apps', 'Settings': 'Configurações',
'Conversation Playground': 'Espaço de teste de conversa',
'Pick a voice on the left, edit on the right.': 'Escolha uma voz à esquerda, edite à direita.',
'Capture 320 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
'Grave de 3 a 20 segundos de fala limpa, corte, nomeie e salve como um clone de voz reutilizável.',
'Describe a voice in words and let the AI create it. No recording needed.':
'Descreva uma voz com palavras e deixe a IA criá-la. Não é necessária gravação.',
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
'Navegue por fontes públicas de clipes de voz, pré-visualize arquivos de áudio e importe vozes da web.',
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
'Gere fala a partir de texto com qualquer backend e voz. Também transcreva áudio e reproduza-o falado.',
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Envie um roteiro, atribua vozes TTS ou seu próprio microfone aos personagens e ensaie cena por cena.',
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
'Importe um PDF ou documento de texto, escolha uma voz e velocidade, e ouça-o enquanto a palavra falada é destacada.',
'My books': 'Meus livros', 'Voice consistency': 'Consistência de voz',
'Normalise loudness': 'Normalizar volume', 'Export MP3': 'Exportar MP3',
'Select range': 'Selecionar intervalo', 'Synthesise': 'Sintetizar',
'Tone / style': 'Tom / estilo', 'Temperature': 'Temperatura', 'Speed': 'Velocidade',
'Saved documents with their synthesised audio — reopen to continue.':
'Documentos salvos com seu áudio sintetizado — reabra para continuar.',
'Save changes': 'Salvar alterações', 'Save to library': 'Salvar na biblioteca',
'Save to Voice Library': 'Salvar na biblioteca de vozes', 'Delete voice': 'Excluir voz',
'Cancel': 'Cancelar', 'Refresh': 'Atualizar', 'Delete': 'Excluir', 'Save': 'Salvar',
'Back': 'Voltar', 'Apply →': 'Aplicar →', 'Browse': 'Procurar', 'Download': 'Baixar',
'Record': 'Gravar', 'Stop': 'Parar', 'Play': 'Reproduzir', 'Play selection': 'Reproduzir seleção',
'Check level': 'Verificar nível', 'Stop monitor': 'Parar monitor', 'Auto trim': 'Corte automático',
'Auto-transcribe': 'Transcrição automática', 'Active': 'Ativo', 'copy ID': 'copiar ID',
'edit ID': 'editar ID', 'New voice': 'Nova voz', 'All languages': 'Todos os idiomas',
'All genders': 'Todos os gêneros', 'Microphone': 'Microfone', 'Upload file': 'Enviar arquivo',
'Sort': 'Ordenar', 'Cards': 'Cartões', 'List': 'Lista', 'Develop': 'Desenvolver',
'Match local': 'Correspondência local', 'Match online': 'Correspondência online', 'Design all': 'Projetar tudo',
'I play this': 'Eu interpreto isto',
'Open rehearsal': 'Abrir ensaio', 'Fetch voices': 'Buscar vozes',
'Name your voice': 'Nomeie sua voz', 'Reference transcript': 'Transcrição de referência',
'Preview': 'Pré-visualização', 'Trim your sample': 'Corte sua amostra', 'Language': 'Idioma',
'Gender': 'Gênero', 'Voice': 'Voz', 'Speaking style · voice-design prompt': 'Estilo de fala · prompt de design de voz',
'Search voices…': 'Buscar vozes…', 'Search voices...': 'Buscar vozes...',
'Filter name or tag…': 'Filtrar por nome ou etiqueta…',
},
nl: {
'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Klonen · Ontwerpen · Implementeren',
'Voices': 'Stemmen', 'Setup': 'Instellingen', 'Tags': 'Tags',
'My Voices': 'Mijn stemmen', 'All voices': 'Alle stemmen', 'Cloned': 'Gekloond',
'Designed': 'Ontworpen', 'Favorites': 'Favorieten', 'Hidden': 'Verborgen',
'Library tools': 'Bibliotheektools',
'Clone a Voice': 'Een stem klonen', 'Design a Voice': 'Een stem ontwerpen',
'Get Voices Online': 'Stemmen online ophalen', 'Try It Out': 'Uitproberen',
'Read Aloud': 'Voorlezen',
'Script Rehearser': 'Scriptrepetitie', 'Library': 'Bibliotheek', 'Cast': 'Cast',
'Stage': 'Podium', 'Summary': 'Samenvatting', 'Import / Export': 'Importeren / Exporteren',
'Conversation': 'Gesprek', 'Benchmark': 'Benchmark', 'Engines': 'Engines',
'Language Models': 'Taalmodellen', 'Speech to Text': 'Spraak naar tekst',
'Text to Speech': 'Tekst naar spraak', 'App Routing': 'App-routering',
'Connect Apps': 'Apps verbinden', 'Settings': 'Instellingen',
'Conversation Playground': 'Gesprek-speeltuin',
'Pick a voice on the left, edit on the right.': 'Kies links een stem, bewerk rechts.',
'Capture 320 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
'Neem 320 seconden heldere spraak op, knip bij, geef een naam en sla op als herbruikbare stemkloon.',
'Describe a voice in words and let the AI create it. No recording needed.':
'Beschrijf een stem in woorden en laat de AI hem maken. Geen opname nodig.',
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
'Blader door openbare stemclipbronnen, bekijk audiobestanden vooraf en importeer stemmen van het web.',
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
'Genereer spraak uit tekst met elke backend en stem. Transcribeer ook audio en spreek het opnieuw uit.',
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Upload een script, wijs TTS-stemmen of je eigen microfoon toe aan personages en repeteer scène voor scène.',
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
'Importeer een PDF of tekstdocument, kies een stem en snelheid, en laat het voorlezen terwijl het gesproken woord wordt gemarkeerd.',
'My books': 'Mijn boeken', 'Voice consistency': 'Stemconsistentie',
'Normalise loudness': 'Volume normaliseren', 'Export MP3': 'MP3 exporteren',
'Select range': 'Bereik selecteren', 'Synthesise': 'Synthetiseren',
'Tone / style': 'Toon / stijl', 'Temperature': 'Temperatuur', 'Speed': 'Snelheid',
'Saved documents with their synthesised audio — reopen to continue.':
'Opgeslagen documenten met hun gesynthetiseerde audio — heropen om verder te gaan.',
'Save changes': 'Wijzigingen opslaan', 'Save to library': 'Opslaan in bibliotheek',
'Save to Voice Library': 'Opslaan in stembibliotheek', 'Delete voice': 'Stem verwijderen',
'Cancel': 'Annuleren', 'Refresh': 'Vernieuwen', 'Delete': 'Verwijderen', 'Save': 'Opslaan',
'Back': 'Terug', 'Apply →': 'Toepassen →', 'Browse': 'Bladeren', 'Download': 'Downloaden',
'Record': 'Opnemen', 'Stop': 'Stoppen', 'Play': 'Afspelen', 'Play selection': 'Selectie afspelen',
'Check level': 'Niveau controleren', 'Stop monitor': 'Monitor stoppen', 'Auto trim': 'Automatisch bijsnijden',
'Auto-transcribe': 'Automatisch transcriberen', 'Active': 'Actief', 'copy ID': 'ID kopiëren',
'edit ID': 'ID bewerken', 'New voice': 'Nieuwe stem', 'All languages': 'Alle talen',
'All genders': 'Alle geslachten', 'Microphone': 'Microfoon', 'Upload file': 'Bestand uploaden',
'Sort': 'Sorteren', 'Cards': 'Kaarten', 'List': 'Lijst', 'Develop': 'Ontwikkelen',
'Match local': 'Lokaal matchen', 'Match online': 'Online matchen', 'Design all': 'Alles ontwerpen',
'I play this': 'Ik speel dit',
'Open rehearsal': 'Repetitie openen', 'Fetch voices': 'Stemmen ophalen',
'Name your voice': 'Geef je stem een naam', 'Reference transcript': 'Referentietranscript',
'Preview': 'Voorbeeld', 'Trim your sample': 'Knip je sample bij', 'Language': 'Taal',
'Gender': 'Geslacht', 'Voice': 'Stem', 'Speaking style · voice-design prompt': 'Spreekstijl · stemontwerp-prompt',
'Search voices…': 'Stemmen zoeken…', 'Search voices...': 'Stemmen zoeken...',
'Filter name or tag…': 'Filter op naam of tag…',
},
pl: {
'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Klonuj · Projektuj · Wdrażaj',
'Voices': 'Głosy', 'Setup': 'Konfiguracja', 'Tags': 'Tagi',
'My Voices': 'Moje głosy', 'All voices': 'Wszystkie głosy', 'Cloned': 'Sklonowane',
'Designed': 'Zaprojektowane', 'Favorites': 'Ulubione', 'Hidden': 'Ukryte',
'Library tools': 'Narzędzia biblioteki',
'Clone a Voice': 'Sklonuj głos', 'Design a Voice': 'Zaprojektuj głos',
'Get Voices Online': 'Pobierz głosy online', 'Try It Out': 'Wypróbuj',
'Read Aloud': 'Czytanie na głos',
'Script Rehearser': 'Próba scenariusza', 'Library': 'Biblioteka', 'Cast': 'Obsada',
'Stage': 'Scena', 'Summary': 'Podsumowanie', 'Import / Export': 'Import / Eksport',
'Conversation': 'Rozmowa', 'Benchmark': 'Benchmark', 'Engines': 'Silniki',
'Language Models': 'Modele językowe', 'Speech to Text': 'Mowa na tekst',
'Text to Speech': 'Tekst na mowę', 'App Routing': 'Routing aplikacji',
'Connect Apps': 'Połącz aplikacje', 'Settings': 'Ustawienia',
'Conversation Playground': 'Plac zabaw rozmów',
'Pick a voice on the left, edit on the right.': 'Wybierz głos po lewej, edytuj po prawej.',
'Capture 320 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
'Nagraj 320 sekund czystej mowy, przytnij, nazwij, a następnie zapisz jako wielokrotnego użytku klon głosu.',
'Describe a voice in words and let the AI create it. No recording needed.':
'Opisz głos słowami i pozwól AI go stworzyć. Nagrywanie nie jest potrzebne.',
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
'Przeglądaj publiczne źródła klipów głosowych, podglądaj pliki audio i importuj głosy z sieci.',
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
'Generuj mowę z tekstu za pomocą dowolnego silnika i głosu. Transkrybuj też audio i wypowiedz je ponownie.',
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Prześlij scenariusz, przypisz głosy TTS lub własny mikrofon do postaci, a następnie ćwicz scenę po scenie.',
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
'Zaimportuj PDF lub dokument tekstowy, wybierz głos i prędkość, a następnie posłuchaj czytania z podświetlaniem wypowiadanego słowa.',
'My books': 'Moje książki', 'Voice consistency': 'Spójność głosu',
'Normalise loudness': 'Normalizuj głośność', 'Export MP3': 'Eksportuj MP3',
'Select range': 'Wybierz zakres', 'Synthesise': 'Syntetyzuj',
'Tone / style': 'Ton / styl', 'Temperature': 'Temperatura', 'Speed': 'Prędkość',
'Saved documents with their synthesised audio — reopen to continue.':
'Zapisane dokumenty z zsyntetyzowanym audio — otwórz ponownie, aby kontynuować.',
'Save changes': 'Zapisz zmiany', 'Save to library': 'Zapisz w bibliotece',
'Save to Voice Library': 'Zapisz w bibliotece głosów', 'Delete voice': 'Usuń głos',
'Cancel': 'Anuluj', 'Refresh': 'Odśwież', 'Delete': 'Usuń', 'Save': 'Zapisz',
'Back': 'Wstecz', 'Apply →': 'Zastosuj →', 'Browse': 'Przeglądaj', 'Download': 'Pobierz',
'Record': 'Nagraj', 'Stop': 'Zatrzymaj', 'Play': 'Odtwórz', 'Play selection': 'Odtwórz zaznaczenie',
'Check level': 'Sprawdź poziom', 'Stop monitor': 'Zatrzymaj monitor', 'Auto trim': 'Automatyczne przycinanie',
'Auto-transcribe': 'Automatyczna transkrypcja', 'Active': 'Aktywny', 'copy ID': 'kopiuj ID',
'edit ID': 'edytuj ID', 'New voice': 'Nowy głos', 'All languages': 'Wszystkie języki',
'All genders': 'Wszystkie płcie', 'Microphone': 'Mikrofon', 'Upload file': 'Prześlij plik',
'Sort': 'Sortuj', 'Cards': 'Karty', 'List': 'Lista', 'Develop': 'Rozwiń',
'Match local': 'Dopasuj lokalnie', 'Match online': 'Dopasuj online', 'Design all': 'Zaprojektuj wszystko',
'I play this': 'Ja to gram',
'Open rehearsal': 'Otwórz próbę', 'Fetch voices': 'Pobierz głosy',
'Name your voice': 'Nazwij swój głos', 'Reference transcript': 'Transkrypcja referencyjna',
'Preview': 'Podgląd', 'Trim your sample': 'Przytnij próbkę', 'Language': 'Język',
'Gender': 'Płeć', 'Voice': 'Głos', 'Speaking style · voice-design prompt': 'Styl mówienia · prompt projektowania głosu',
'Search voices…': 'Szukaj głosów…', 'Search voices...': 'Szukaj głosów...',
'Filter name or tag…': 'Filtruj po nazwie lub tagu…',
},
};
let _appLang = 'en';

View File

@ -46,7 +46,7 @@ function integrationVoiceList() {
function virtualDesignVoiceIds() {
return Object.keys(loadDesignPresets ? loadDesignPresets() : {})
.sort((a,b)=>a.localeCompare(b))
.map(name => 'vd_' + name.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, ''));
.map(name => 'vd_' + (typeof _umlautSafe === 'function' ? _umlautSafe(name) : name).replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, ''));
}
function renderIntegrationSnippets() {
if (!$('snippet-sillytavern')) return;

File diff suppressed because it is too large Load Diff

View File

@ -302,6 +302,15 @@ function readerResetDoc() {
}
// Build sentence list from a flat array of word objects (each carries geometry).
// PDF fonts sometimes render a closing quote/bracket glyph as its own
// separate text-content item (confirmed live on a scanned book, for the
// German closing guillemet «) — joining every word with an unconditional
// leading space then bakes a stray space in right before it ("hier. «"
// instead of "hier.«"), which the casting LLM then faithfully preserves
// since it's instructed to reproduce the exact source text. Punctuation
// that only ever closes something never wants a space before it.
const READER_NO_LEADING_SPACE_RE = /^[«"'’”)\]]+$/;
function readerBuildSentences(words) {
const sentences = [];
let cur = null;
@ -314,7 +323,7 @@ function readerBuildSentences(words) {
if (w.para && cur && cur.words.length) { sentences.push(cur); cur = null; }
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, paraStart: !!w.para };
cur.words.push(w);
cur.text += (cur.text ? ' ' : '') + w.text;
cur.text += (cur.text && !READER_NO_LEADING_SPACE_RE.test(w.text) ? ' ' : '') + w.text;
const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, ''));
if ((isEnd && cur.words.length >= 2) || cur.words.length >= 45) {
sentences.push(cur); cur = null;
@ -415,6 +424,19 @@ async function readerGetOcrWorker() {
return readerState.ocrWorker;
}
// Races `promise` against a timer, rejecting instead of leaving the caller
// awaiting forever. A hung (never-settling, not rejecting) render/OCR call
// on one page — confirmed live as the PDF import progress bar getting stuck
// on a single page indefinitely, most likely an image-heavy or blank page
// wedging the Tesseract worker — silently stalled the entire sequential
// extraction loop with no error and no way to recover short of reloading.
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); });
});
}
// Rasterize the top `gapPx` (scale-1 px) of `page` and OCR it, returning
// synthetic word entries (same shape as real getTextContent words) spread
// across the band so they slot into the normal sentence/highlight pipeline.
@ -430,9 +452,9 @@ async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
crop = document.createElement('canvas');
crop.width = viewport.width;
crop.height = cropH;
await page.render({ canvasContext: crop.getContext('2d'), viewport }).promise;
await _readerTimeout(page.render({ canvasContext: crop.getContext('2d'), viewport }).promise, 15000, 'Page render');
const { data } = await worker.recognize(crop);
const { data } = await _readerTimeout(worker.recognize(crop), 20000, 'Heading OCR');
const text = (data.text || '').replace(/\s+/g, ' ').trim();
if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return [];
@ -530,7 +552,13 @@ async function readerExtractPdfText(loaded) {
if (readerIsPageNearView(pgState)) readerRenderPage(pageIdx);
// Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later
const content = await page.getTextContent();
let content;
try {
content = await _readerTimeout(page.getTextContent(), 20000, '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 = [];
@ -717,22 +745,63 @@ function readerEvictCanvases(dr) {
});
}
// A canvas sized directly from page-size * zoom with no upper bound can
// demand a multi-hundred-MB backing buffer on an oversized page (some
// scanned/cover pages declare a MediaBox far larger than a normal printed
// page) at high zoom — confirmed live as a full tab crash (Chromium
// "SIGTRAP") zooming a cover page to ~400%. Independent of the zoom-button
// cap (which only bounds the requested scale, not the page's own intrinsic
// size), clamp the actual rendered backing-buffer resolution to a safe
// pixel budget and let the canvas's CSS size — not its buffer size — carry
// the visual zoom; an oversized page just renders a bit softer instead of
// crashing the tab.
// Shrinking the OUTPUT canvas (as of 1.17.46/47) assumes the render cost
// scales with the target size — confirmed live that it doesn't always:
// the tab still hung at high zoom. If PDF.js is decoding an embedded
// image at ITS OWN native resolution before any downscaling happens, that
// decode runs synchronously on the main thread before our code (including
// the render timeout) ever gets control back — nothing awaitable can
// rescue a hang that already owns the thread. The only actually reliable
// mitigation is to keep the requested scale small enough that this page's
// embedded resource is never decoded at a pathological resolution in the
// first place, so these budgets are deliberately much smaller than before.
const READER_MAX_CANVAS_PIXELS = 2000000; // ~2MP
const READER_MAX_CANVAS_SIDE = 1800;
async function readerRenderPage(idx) {
const pg = readerState.pages[idx];
if (!pg || pg.rendered || !pg.page) return;
pg.rendered = true; // claim immediately to avoid double render
const scale = readerState.scale;
const viewport = pg.page.getViewport({ scale });
let renderViewport = viewport;
const overArea = viewport.width * viewport.height > READER_MAX_CANVAS_PIXELS;
const 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(viewport.width);
canvas.height = Math.floor(viewport.height);
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); // create this page's status boxes lazily (cheap for big books)
try {
pg.renderTask = pg.page.render({ canvasContext: canvas.getContext('2d'), viewport });
await pg.renderTask.promise;
} catch (_) { pg.rendered = false; canvas.remove(); }
pg.renderTask = pg.page.render({ canvasContext: canvas.getContext('2d'), viewport: renderViewport });
// Even a capped-resolution canvas can sit on a page whose embedded
// image is itself huge (some codecs decode at native resolution before
// any downscaling) — a timeout here is the actual guarantee against a
// hung tab, independent of whatever the real bottleneck turns out to
// be. Cancelling leaves the page unrendered so scrolling past and back
// can retry it rather than leaving a permanently broken page.
await _readerTimeout(pg.renderTask.promise, 15000, 'Page render');
} catch (_) {
pg.rendered = false; canvas.remove();
try { pg.renderTask?.cancel(); } catch (_) {}
}
finally { pg.renderTask = null; }
}
@ -1005,54 +1074,67 @@ function readerUpdateScopeLabel() {
if (txt && range) txt.textContent = 'sentences ' + (range[0] + 1) + '' + (range[1] + 1) + ' (' + (range[1] - range[0] + 1) + ')';
}
// Synthesise the given sentence indices (skips already-cached). Returns count done.
// Synthesise the given sentence indices (skips already-cached). Returns
// {done, failed} — `failed` used to be swallowed entirely (a sentence whose
// TTS call errored just got its status reverted to 'pending' and `done` was
// incremented anyway), so a mid-book backend blip silently produced a
// merged/exported file with paragraphs missing and no indication anything
// had gone wrong. Callers now get the failed-index list back and must
// surface it instead of reporting a clean success.
async function readerSynthIndices(targets) {
targets = targets.filter(i => !readerState.blobCache.has(i));
if (!targets.length || readerState.synthRunning) return 0;
if (!targets.length || readerState.synthRunning) return { done: 0, failed: [] };
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) { toast('Pick a voice first', 'error'); return 0; }
if (!backend) { toast('No TTS backend selected', 'error'); return 0; }
if (!voice) { toast('Pick a voice first', 'error'); return { done: 0, failed: [] }; }
if (!backend) { toast('No TTS backend selected', 'error'); return { done: 0, failed: [] }; }
const instruct = $('reader-instruct')?.value.trim() || '';
readerState.synthRunning = true; readerState.synthCancel = false;
const prog = $('reader-synth-prog'); if (prog) prog.hidden = false;
const total = targets.length; let done = 0;
const failed = [];
const update = () => {
const f = $('reader-synth-fill'); if (f) f.style.width = (done / total * 100) + '%';
const l = $('reader-synth-label'); if (l) l.textContent = done + ' / ' + total;
};
update();
const queue = targets.slice();
const worker = async () => {
while (queue.length && !readerState.synthCancel) {
const i = queue.shift();
if (readerState.blobCache.has(i)) { done++; update(); continue; }
if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth');
try {
const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(i, blob);
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready');
} catch (_) {
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'pending');
try {
const queue = targets.slice();
const worker = async () => {
while (queue.length && !readerState.synthCancel) {
const i = queue.shift();
if (readerState.blobCache.has(i)) { done++; update(); continue; }
if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth');
try {
const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(i, blob);
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready');
} catch (e) {
failed.push(i);
if (readerState.sentences[i]?.status === 'synth') readerSetStatus(i, 'pending');
console.error('[reader] synth failed for sentence', i, e);
}
done++; update();
}
done++; update();
}
};
const N = Math.min(2, targets.length); // bounded concurrency — don't overload the engine
await Promise.all(Array.from({ length: N }, worker));
readerState.synthRunning = false;
if (prog) prog.hidden = true;
return done;
};
const N = Math.min(2, targets.length); // bounded concurrency — don't overload the engine
await Promise.all(Array.from({ length: N }, worker));
} finally {
readerState.synthRunning = false;
if (prog) prog.hidden = true;
}
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 = await readerSynthIndices(targets);
if (done || !readerState.synthCancel) toast(readerState.synthCancel ? 'Synthesis cancelled (' + done + ' done)' : 'Synthesised ' + done + ' sentences', readerState.synthCancel ? 'error' : 'success');
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 — ' + failed.length + ' failed, see red markers', 'error'); return; }
toast('Synthesised ' + done + ' sentences', 'success');
}
// ── Export as MP3 (per page, or per sentence) with meaningful filenames ──────
@ -1077,13 +1159,23 @@ async function readerExport(mode) {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
const indices = readerScopeIndices();
const missing = indices.filter(i => !readerState.blobCache.has(i));
let failedCount = 0;
if (missing.length) {
toast('Synthesising ' + missing.length + ' missing sentence(s) before export…', 'success');
await readerSynthIndices(missing);
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; }
// A segment that fails synthesis used to just vanish from the merged/
// exported file with no trace — the export "succeeded" while quietly
// missing paragraphs. Block the export instead: better a clear stop than a
// corrupted audiobook that only reveals the gap when someone listens.
if (failedCount) {
toast(failedCount + ' sentence(s) failed to synthesise — 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') {
@ -2048,4 +2140,26 @@ window.readerOnShow = async function () {
window.showReaderView(window._readerStartView);
window._readerStartView = null;
}
_readerEnsureImportVisible();
};
// #reader-config-card ("Voice & synthesis settings") also holds the only
// paste/drag-and-drop import controls (#reader-import-area) — collapsed by
// default (data-collapse-default="closed"), which is fine once a document is
// loaded, but leaves a first-time or post-delete user staring at an empty
// page with no visible way to import anything (confirmed live: deleting the
// current book landed on an empty box with the import controls hidden inside
// the collapsed card above it). data-collapse-default is only read once at
// initCollapsibleCards() init time — this mirrors its `apply(true)` directly
// rather than trying to re-trigger that logic, and never persists to
// localStorage, so a user's own collapse choice made once a document IS
// loaded is left alone.
function _readerEnsureImportVisible() {
if (readerState.sentences.length) return;
const card = document.getElementById('reader-config-card');
const body = card ? card.querySelector('.card-col-body') : null;
if (body && body.hidden) {
body.hidden = false;
card.classList.remove('card-col-closed');
}
}

View File

@ -32,7 +32,25 @@ function parseScript(text) {
}
for (const rawLine of scanLines) {
const line = rawLine.trim();
// \f (form feed) is one of the characters JS's own .trim() strips as
// whitespace — so the page-break check further down (which tests the
// ALREADY-trimmed `line`) could never actually match, silently eating
// every "\f<pageNum>" marker before it was ever recognized. Confirmed
// live: a real book with 232 page marks in its source text produced
// zero pagebreak lines after parsing, and "PDF pages" mode (which only
// ever breaks at pagebreak markers, never by content height) rendered
// the entire book as a single page as a result. Check the ORIGINAL,
// untrimmed line for the marker before trimming destroys it.
const isPageBreak = rawLine.startsWith('\f');
const line = isPageBreak ? rawLine.slice(1).trim() : rawLine.trim();
if (isPageBreak) {
flushDialog(); flushAction();
const pageNum = line && /^\d+$/.test(line) ? parseInt(line, 10) : null;
result.push({ type: 'pagebreak', speaker: '', text: '', page: pageNum, isDirection: true });
state = 'action'; currentSpeaker = null;
continue;
}
if (!line) {
flushDialog(); flushAction();
@ -54,17 +72,6 @@ function parseScript(text) {
continue;
}
// PDF page-break marker — preserved from importPDFScript / audiobook casting
// Format: bare \f or \f<number> (e.g. \f3 = page 3)
if (line.startsWith('\f')) {
flushDialog(); flushAction();
const pnStr = line.slice(1).trim();
const pageNum = pnStr && /^\d+$/.test(pnStr) ? parseInt(pnStr, 10) : null;
result.push({ type: 'pagebreak', speaker: '', text: '', page: pageNum, isDirection: true });
state = 'action'; currentSpeaker = null;
continue;
}
if (line.startsWith('#')) {
flushDialog(); flushAction();
const dir = line.slice(1).trim();
@ -122,7 +129,7 @@ function parseScript(text) {
// "CHAR: dialog" simple format — speaker must be a plausible cue (≤3 words, ≤24 chars)
// so title lines / page slugs ("PIRATES OF THE CARIBBEAN: …", "POTC: …") aren't cues
const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/);
const colonMatch = line.match(/^([\p{Lu}][\p{Lu}0-9 _\-ß]{0,39}):\s+(.+)$/u);
if (colonMatch && colonMatch[1].trim().length <= 24 && colonMatch[1].trim().split(/\s+/).length <= 3) {
flushDialog(); flushAction();
currentSpeaker = colonMatch[1].trim();
@ -139,11 +146,23 @@ function parseScript(text) {
}
// Character name: ALL-CAPS, optionally followed by (modifier)
// The name regex used to be ASCII-only ([A-Z...]) — any speaker name
// containing a German umlaut or ß (e.g. "Turmwächter", "Freischärler",
// "Mädchen") uppercases to a string the regex couldn't match, so the cue
// was silently missed. That dropped the whole SPEAKER/text pair here
// (both fell through to plain narration), desyncing the emotions array —
// built with one entry per dialogue segment — from the actual parsed
// dialog lines from that point on, confirmed live via a 6-line drift
// partway through a 1958-segment script that shifted every emotion (and,
// once the shifted array ran out of alignment with speakers, every
// visible speaker/text pairing) for everything after it. \p{Lu} (Unicode
// uppercase letter) covers Ä/Ö/Ü and other accented capitals; ß has no
// widely-used uppercase form so it's allowed explicitly.
const nameRaw = line.replace(/\s*\([^)]*\)\s*$/, '').trim();
if (
nameRaw.length >= 2 && nameRaw.length <= 42 &&
nameRaw === nameRaw.toUpperCase() &&
/^[A-Z][A-Z0-9 '.\-]+$/.test(nameRaw) &&
/^[\p{Lu}][\p{Lu}0-9 '.\-ß]+$/u.test(nameRaw) &&
!/^\d+$/.test(nameRaw) &&
!/\.$/.test(nameRaw) // reject sentence fragments ("FERDINAND.", "EYES OPEN.")
) {

View File

@ -629,14 +629,27 @@ async function rehApplySharedCast(title) {
if (!roster) return;
let changed = false;
Object.keys(rehState.cast || {}).forEach(sp => {
if (String(sp).includes('NARRATOR')) return;
const shared = roster[String(sp).toLowerCase()];
// Narrator can now have a real, persisted Library record too (Assign
// Voices synthesizes one per production) — pull its voice the same way
// as any other shared cast entry instead of skipping it, so a voice
// picked there actually reaches the Rehearser/synthesis. The narrator's
// cast key is the emoji-prefixed REH_NARRATOR_KEY sentinel, not its
// plain "Narrator" library name, so the roster lookup below needs the
// same translation renderCastStrip() already uses elsewhere — without
// it, `roster["📖narrator"]` always misses and this silently never
// fires for narrator at all (confirmed live: synthAll() then skips
// every narration line since it reads rehState.narratorVoice directly,
// which this function is also the only place expected to set from a
// shared/library voice).
const lookupName = sp === REH_NARRATOR_KEY ? 'narrator' : String(sp).toLowerCase();
const shared = roster[lookupName];
if (!shared) return;
const slot = rehState.cast[sp];
if (shared.voice && !slot.voice) {
slot.voice = shared.voice;
slot.voiceData = (shared.voice !== 'me' && typeof getVoiceData === 'function') ? getVoiceData(shared.voice) : null;
changed = true;
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = shared.voice;
}
if (shared.gender && !slot.gender) { slot.gender = shared.gender; changed = true; }
if (shared.soul && !slot.soul) { slot.soul = shared.soul; changed = true; }
@ -656,11 +669,36 @@ function bookCover(title) {
let rehLibView = localStorage.getItem('reh-lib-view') || 'shelf';
function rehTitleKey(title) {
return String(title || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function rehUniqueLibraryRecords(records) {
const byTitle = new Map();
const ordered = [...records].sort((a, b) => {
const at = new Date(a.updated || 0).getTime();
const bt = new Date(b.updated || 0).getTime();
if (at !== bt) return bt - at;
return (b.id || 0) - (a.id || 0);
});
for (const rec of ordered) {
const key = rehTitleKey(rec.title);
const bucketKey = key || `__reh__${rec.id}`;
if (!byTitle.has(bucketKey)) byTitle.set(bucketKey, rec);
}
return [...byTitle.values()].sort((a, b) => {
const at = new Date(a.updated || 0).getTime();
const bt = new Date(b.updated || 0).getTime();
if (at !== bt) return bt - at;
return (b.id || 0) - (a.id || 0);
});
}
async function renderLibraryList() {
const list = $('reh-library-list'); if (!list) return;
let all;
try { all = await rehDbGetAll(); } catch(e) { all = []; }
all.sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0));
all = rehUniqueLibraryRecords(all);
list.classList.toggle('list-view', rehLibView === 'list');
const vt = $('reh-lib-view-toggle');
@ -797,6 +835,26 @@ function voiceAvatarHtml(voiceId, color, size = 32) {
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.45)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
}
// Stage line rows used a generic voice icon/initial for the play-avatar
// button — a plain "?" for any speaker whose assigned voice has no picture
// of its own, even when the CHARACTER already has a real portrait in the
// Library (same one shown everywhere else: Cast Audiobook, Assign Voices,
// the Cast sidebar strip right next to this same line). Prefer that.
function _rehCharAvatarHtml(speaker, voiceId, color, size = 32) {
const rec = (_rehLibCharsCache || []).find(r => String(r.name || '').trim().toLowerCase() === String(speaker || '').trim().toLowerCase());
if (rec && rec.image) {
const s = size + 'px'; const radius = Math.round(size / 2);
// Reference the image by URL, never inline the raw data: URL here — this
// renders once PER DIALOGUE LINE, and a character can speak hundreds of
// lines; embedding a multi-KB/MB base64 blob that many times ballooned a
// real 1968-line script's HTML to hundreds of megabytes and silently
// failed to render at all (confirmed live). The browser fetches/caches
// the URL once regardless of how many lines reference it.
return `<img src="/api/characters/${encodeURIComponent(rec.id)}/image" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(speaker)}">`;
}
return voiceAvatarHtml(voiceId, color, size);
}
// ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ──
// ── Phase navigation ───────────────────────────────────────────────────────
@ -1238,7 +1296,7 @@ async function _rehImportFishCandidate(sp, cand) {
return existing.id;
}
const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase();
const vid = `${lang2}_${(cand.title || sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
const vid = `${lang2}_${(typeof _umlautSafe === 'function' ? _umlautSafe(cand.title || sp) : (cand.title || sp)).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
const d = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }),
@ -1314,6 +1372,39 @@ function _safeDomId(value) {
return out || 'voice';
}
// Cross-referencing the Character Library gives the cast cards a real
// portrait, tier badge, and occupation/archetype instead of just a colored
// initial — pulled from whatever book/script this cast shares a title with.
// Fetched once per script title (not per render) and cached. Two separate
// renderers share this one cache — renderCastList (the "Who's playing which
// character?" mecast panel) and renderCastStrip (the Stage sidebar's own
// character list, see below) — but only ONE fetch is ever kicked off per
// script title, guarded by _rehLibCharsCacheBook. Whichever renderer's guard
// check happens to run first "claims" that fetch; the other one sees the
// book already marked as fetched and skips starting its own — so BOTH must
// be re-run once the shared fetch resolves, not just whichever one started
// it. Confirmed live as a real bug: entering Perform & Export borrows both
// panels at once, renderCastList's guard usually wins the race, and its own
// old single-target callback left the Stage sidebar stuck on plain
// colored-letter dots forever (never re-rendered with portraits) even
// though the cache had genuinely finished loading with images moments
// later — only calling renderCastStrip() by hand fixed it.
let _rehLibCharsCache = null;
let _rehLibCharsCacheBook = null;
function _rehEnsureLibCharsCache(scriptTitle) {
if (!scriptTitle || _rehLibCharsCacheBook === scriptTitle || typeof clGetAllByTagOrBook !== 'function') return;
_rehLibCharsCacheBook = scriptTitle;
clGetAllByTagOrBook(scriptTitle).then(recs => {
_rehLibCharsCache = recs || [];
renderCastList();
if (typeof renderCastStrip === 'function') renderCastStrip();
// Stage's per-line portraits (_rehCharAvatarHtml) also read this cache
// directly, not just the sidebar row — re-run the full script page too
// so lines that rendered before the cache arrived pick up portraits.
if (typeof buildScriptPage === 'function') buildScriptPage();
}).catch(() => {});
}
function renderCastList() {
const list = $('reh-cast-list'); if (!list) return;
// Make sure the voice library is loaded so every picker (incl. the narrator) has
@ -1330,6 +1421,18 @@ function renderCastList() {
const speakers = [narr, ...others];
const scriptTitle = $('reh-script-title')?.value.trim() || '';
_rehEnsureLibCharsCache(scriptTitle);
const libRecByName = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r]));
const emotionsFor = sp => {
const seen = new Map();
rehState.lines.forEach(l => {
if (l.speaker === sp && l.type === 'dialog' && l.emotion && !seen.has(l.emotion)) {
seen.set(l.emotion, getEmotionInfo(l.emotion));
}
});
return [...seen.values()];
};
list.innerHTML = speakers.map(sp => {
const c = rehState.cast[sp];
const isNarr = sp === narr;
@ -1347,6 +1450,42 @@ function renderCastList() {
</select>`;
const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : '');
// Library cross-reference — same book/script, matched by name. When a
// real character record exists, the card IS the exact Library card
// (_charCardHtml) — same colorful portrait/tier/occupation/alignment
// design already used in Library → Cast, not a separate look-alike —
// and voice assignment happens by clicking into the full profile (or
// the bulk match tools above) rather than a dropdown on the card.
// Narrator/unmatched speakers (no Library record to point at) keep the
// simple fallback header with an inline voice dropdown, since there's
// no profile page for them to assign a voice from.
const libRec = !isNarr ? libRecByName.get(String(sp).trim().toLowerCase()) : null;
if (libRec) {
const libVoice = typeof libRec.voice === 'object' ? (libRec.voice?.id || '') : (libRec.voice || '');
if (libVoice) c.voice = libVoice;
}
const emotions = isNarr ? [] : emotionsFor(sp);
const emotionsHtml = emotions.length
? `<div class="reh-cc-emotions">${emotions.map(info => `<span class="reh-cc-emo-chip">${info.emoji} ${escHtml(info.label)}</span>`).join('')}</div>`
: '';
const controlsHtml = `<div class="reh-cc-controls">
<label class="reh-cc-me-row" title="Record this character with your own mic instead of a TTS voice"><input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${isMe?'checked':''}><span><span class="mdi mdi-microphone"></span> I play this</span></label>
${(!isMe && c.voice) ? `<button type="button" class="reh-cc-sample-btn" data-speaker="${escHtml(sp)}" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span> Hear a line</button>` : ''}
<span class="reh-cc-actions-spacer"></span>
<button class="reh-cc-iconbtn${c.ignored?' active':''}" data-act="ignore" title="Ignore — grey out &amp; skip this characters lines" aria-label="Ignore this characters lines"><span class="mdi mdi-eye-off-outline"></span></button>
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this characters lines from the script" aria-label="Hide this characters lines"><span class="mdi mdi-minus-circle-outline"></span></button>
<button class="reh-cc-iconbtn reh-cc-del" data-act="delete" title="Delete this character &amp; all their lines" aria-label="Delete this character"><span class="mdi mdi-trash-can-outline"></span></button>
</div>
${emotionsHtml}`;
if (libRec) {
return `<div class="${cardCls} reh-cast-card-libcard" data-speaker="${escHtml(sp)}" data-char-id="${escHtml(libRec.id)}" style="--cc-color:${c.color}">
${(typeof _charCardHtml === 'function') ? _charCardHtml(libRec, _rehLibCharsCache || []) : ''}
<div class="reh-cc-body">${controlsHtml}</div>
</div>`;
}
return `<div class="${cardCls}" data-speaker="${escHtml(sp)}" style="--cc-color:${c.color}">
<div class="reh-cc-head">
<div class="reh-cc-avatar">${castAvatarHtml(sp)}</div>
@ -1354,34 +1493,30 @@ function renderCastList() {
<div class="reh-cc-name" style="color:${c.color}">${isNarr ? '<span class="mdi mdi-book-open-page-variant"></span> ' : ''}${escHtml(label)}</div>
<div class="reh-cc-sub">${sub}</div>
<div class="reh-cc-voicechip${(!isMe && !c.voice) ? ' empty' : ''}"><span class="mdi mdi-${isMe ? 'microphone' : 'account-music-outline'}"></span> ${escHtml(voiceName || 'No voice assigned')}</div>
${(!isMe && c.voice) ? `<button type="button" class="reh-cc-sample-btn" data-speaker="${escHtml(sp)}" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span> Hear a line</button>` : ''}
</div>
</div>
${!isNarr ? `<div class="reh-cc-actions">
<label class="reh-cc-me-row" title="Record this character with your own mic instead of a TTS voice"><input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${isMe?'checked':''}><span><span class="mdi mdi-microphone"></span> I play this</span></label>
<span class="reh-cc-actions-spacer"></span>
<button class="reh-cc-iconbtn${c.ignored?' active':''}" data-act="ignore" title="Ignore — grey out &amp; skip this characters lines" aria-label="Ignore this characters lines"><span class="mdi mdi-eye-off-outline"></span></button>
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this characters lines from the script" aria-label="Hide this characters lines"><span class="mdi mdi-minus-circle-outline"></span></button>
<button class="reh-cc-iconbtn reh-cc-del" data-act="delete" title="Delete this character &amp; all their lines" aria-label="Delete this character"><span class="mdi mdi-trash-can-outline"></span></button>
</div>` : ''}
<div class="reh-cc-body"${isMe?' style="display:none"':''}>
<div class="reh-cc-field reh-cc-voice"><label>Voice</label>${voiceSel}</div>
<div class="reh-cc-extra">
${!isNarr ? castOnlinePanelHtml(sp, c) : ''}
${!isNarr ? `<div class="reh-cc-grid">
<div class="reh-cc-field"><label>Language</label><select class="reh-cc-lang">${langSel}</select></div>
<div class="reh-cc-field"><label>Gender</label><select class="reh-cc-gender">${genSel}</select></div>
<div class="reh-cc-field"><label>Tags</label><input type="text" class="reh-cc-tags" value="${escHtml(c.tags||'')}" placeholder="e.g. villain, raspy"></div>
</div>` : ''}
<div class="reh-cc-field"><label>Speaking style · voice-design prompt</label>
<input type="text" class="reh-cast-instruct" value="${escHtml(c.instruct||'')}" placeholder="${isNarr ? 'Narrator style: calm, measured, cinematic storyteller…' : 'British accent, commanding baritone, quietly menacing…'}"></div>
${!isNarr ? `<details class="reh-cc-soul"${c.soul?' open':''}>
<summary><span class="mdi mdi-script-text-outline"></span> Character soul · LLM brief
<button class="btn-secondary btn-sm reh-cc-develop" type="button" title="Let the LLM read the script &amp; flesh out this character"><span class="mdi mdi-auto-fix"></span> Develop</button>
</summary>
<textarea class="reh-cc-soul-text" placeholder="Backstory, motivation, vocal manner… (used as the LLM brief when designing this voice)">${escHtml(c.soul||'')}</textarea>
</details>` : ''}
</div>
${controlsHtml}
<details class="reh-cc-more">
<summary><span class="mdi mdi-tune-variant"></span> More options</summary>
<div class="reh-cc-extra">
${!isNarr ? castOnlinePanelHtml(sp, c) : ''}
${!isNarr ? `<div class="reh-cc-grid">
<div class="reh-cc-field"><label>Language</label><select class="reh-cc-lang">${langSel}</select></div>
<div class="reh-cc-field"><label>Gender</label><select class="reh-cc-gender">${genSel}</select></div>
<div class="reh-cc-field"><label>Tags</label><input type="text" class="reh-cc-tags" value="${escHtml(c.tags||'')}" placeholder="e.g. villain, raspy"></div>
</div>` : ''}
<div class="reh-cc-field"><label>Speaking style · voice-design prompt</label>
<input type="text" class="reh-cast-instruct" value="${escHtml(c.instruct||'')}" placeholder="${isNarr ? 'Narrator style: calm, measured, cinematic storyteller…' : 'British accent, commanding baritone, quietly menacing…'}"></div>
${!isNarr ? `<details class="reh-cc-soul"${c.soul?' open':''}>
<summary><span class="mdi mdi-script-text-outline"></span> Character soul · LLM brief
<button class="btn-secondary btn-sm reh-cc-develop" type="button" title="Let the LLM read the script &amp; flesh out this character"><span class="mdi mdi-auto-fix"></span> Develop</button>
</summary>
<textarea class="reh-cc-soul-text" placeholder="Backstory, motivation, vocal manner… (used as the LLM brief when designing this voice)">${escHtml(c.soul||'')}</textarea>
</details>` : ''}
</div>
</details>
</div>
</div>`;
}).join('');
@ -1394,6 +1529,14 @@ function renderCastList() {
? `${total} ${total === 1 ? 'character' : 'characters'} + narrator`
: `${shown} of ${total} characters`;
}
// Wire the reused Library cards (portrait upload, export, click → full
// profile) exactly like the Library grid does — the profile page opens
// in place of this list and "back" re-renders the cast list, same pattern
// used for the post-recast results grid on the Read Aloud page.
if (typeof _wireCharCards === 'function' && (_rehLibCharsCache || []).length) {
const recsById = new Map(_rehLibCharsCache.map(r => [r.id, r]));
_wireCharCards(list, recsById, _rehLibCharsCache, renderCastList, { container: list, onBack: renderCastList });
}
_wireCastControls();
applyCastView();
@ -1843,7 +1986,7 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
const language= $('reh-design-lang')?.value || 'English';
const langCode= REH_LANG_CODE[language] || 'EN';
const scriptTitle = $('reh-script-title')?.value.trim() || 'Script';
const tag = scriptTitle.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script';
const tag = (typeof _umlautSafe === 'function' ? _umlautSafe(scriptTitle) : scriptTitle).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script';
const btn = $('reh-autodesign-btn');
const prog = $('reh-autodesign-progress');
@ -1904,7 +2047,7 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
`A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`;
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
const safeName = sp.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
const voiceId = `${langCode}_${gender}_${safeName}_${tag}`.slice(0, 90);
rehMarkCastDesigning(sp, 'designing', null, {
gender, language, voiceId, desc,
@ -2280,37 +2423,89 @@ $('reh-cast-toggle')?.addEventListener('click', rehToggleCast);
// ── A4 Script page ─────────────────────────────────────────────────────────
// Cast sidebar — same look (.ab-char-item rows) as Read Aloud's Casting
// sidebar, but with real portraits (cross-referenced from the Library, same
// cache the Cast tab already builds) instead of plain colored-letter dots,
// plus search and sort. Split out from buildScriptPage so typing in the
// search box doesn't re-render the whole (potentially 1000+ line) script
// page on every keystroke — just this strip.
function renderCastStrip() {
const castStrip = $('reh-cast-strip');
if (!castStrip) return;
const scriptTitle = $('reh-script-title')?.value.trim() || '';
_rehEnsureLibCharsCache(scriptTitle);
const names = Object.keys(rehState.cast);
const lineCountFor = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
const libRecByNameStage = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r]));
const sortMode = $('reh-cast-side-sort')?.value || localStorage.getItem('reh_cast_side_sort') || 'lines';
const query = ($('reh-cast-side-search')?.value || '').trim().toLowerCase();
let rows = names.map(sp => {
const displayName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
const libRec = sp === REH_NARRATOR_KEY ? null : libRecByNameStage.get(String(sp).trim().toLowerCase());
// `sp` is the raw speaker key straight out of script parsing, which
// follows screenplay convention (ALL CAPS speaker tags) — rendering it
// verbatim meant every name in this sidebar showed shouting-case
// regardless of how it's actually spelled anywhere else in the app.
// Prefer the Library's own properly-cased name when there's a match;
// a plain per-word title-case is still better than shouting-case for
// the rarer speaker key with no Library record to match against.
const fallbackName = displayName === 'Narrator' ? displayName
: displayName.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase());
return { sp, displayName: libRec?.name || fallbackName, n: lineCountFor(sp), libRec };
});
if (query) rows = rows.filter(r => r.displayName.toLowerCase().includes(query));
rows.sort((a, b) => sortMode === 'name'
? a.displayName.localeCompare(b.displayName)
: (b.n - a.n) || a.displayName.localeCompare(b.displayName));
castStrip.innerHTML = rows.map(({ sp, displayName, n, libRec }) => {
const c = rehState.cast[sp], isMe = c.voice === 'me';
const avatarHtml = libRec?.image
? `<span class="ab-char-dot ab-char-dot-img" style="border-color:${c.color}"><img src="${libRec.image}" alt=""></span>`
: `<span class="ab-char-dot" style="background:${c.color}">${escHtml((displayName||'?')[0].toUpperCase())}</span>`;
return `<div class="ab-char-item" data-speaker="${escHtml(sp)}" title="${escHtml(displayName)}${isMe?'me':(c.voice||'no voice')}">
${avatarHtml}
<span class="ab-char-name">${escHtml(displayName)}</span>
${isMe?'<span class="mdi mdi-microphone" style="font-size:11px;color:var(--subtext)"></span>':''}
<b class="ab-char-count">${n}</b>
</div>`;
}).join('');
castStrip.querySelectorAll('.ab-char-item').forEach(item => {
item.addEventListener('click', () => {
const line = document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`);
if (line) line.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
const lbl = $('reh-cast-toggle-label');
if (lbl) lbl.textContent = `Characters (${names.length})`;
const searchInp = $('reh-cast-side-search');
const sortSel = $('reh-cast-side-sort');
if (searchInp && !searchInp.dataset.wired) {
searchInp.dataset.wired = '1';
searchInp.addEventListener('input', () => renderCastStrip());
}
if (sortSel && !sortSel.dataset.wired) {
sortSel.dataset.wired = '1';
sortSel.value = sortMode;
sortSel.addEventListener('change', () => { localStorage.setItem('reh_cast_side_sort', sortSel.value); renderCastStrip(); });
}
}
function buildScriptPage() {
const titleEl = $('reh-page-title');
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
// Cast sidebar — same look (.ab-char-item rows) as Read Aloud's Casting sidebar.
const castStrip = $('reh-cast-strip');
if (castStrip) {
const names = Object.keys(rehState.cast);
const lineCountFor = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
castStrip.innerHTML = names.map(sp => {
const c = rehState.cast[sp], isMe = c.voice === 'me';
const displayName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
return `<div class="ab-char-item" data-speaker="${escHtml(sp)}" title="${escHtml(displayName)}${isMe?'me':(c.voice||'no voice')}">
<span class="ab-char-dot" style="background:${c.color}">${escHtml((displayName||'?')[0].toUpperCase())}</span>
<span class="ab-char-name">${escHtml(displayName)}</span>
${isMe?'<span class="mdi mdi-microphone" style="font-size:11px;color:var(--subtext)"></span>':''}
<b class="ab-char-count">${lineCountFor(sp)}</b>
</div>`;
}).join('');
castStrip.querySelectorAll('.ab-char-item').forEach(item => {
item.addEventListener('click', () => {
const line = document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`);
if (line) line.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
const lbl = $('reh-cast-toggle-label');
if (lbl) lbl.textContent = `Characters (${names.length})`;
}
renderCastStrip();
rehApplyStageFont();
rehApplyCastCollapsed();
// Fire-and-forget, guarded against re-running for the same script — see
// _lineAudioSyncDots for why this exists (green dots otherwise look reset
// after every reload even when the audio is safely cached on disk).
if (typeof _lineAudioSyncDots === 'function') _lineAudioSyncDots().catch(() => {});
const linesEl = $('reh-script-lines'); if (!linesEl) return;
linesEl.innerHTML = rehState.lines.map((line, i) => {
@ -2349,11 +2544,18 @@ function buildScriptPage() {
${editBtn} ${synthDot}
</div>`;
case 'action':
case 'action': {
// Narrator paragraphs get a play button too now, but deliberately
// NOT wrapped in .reh-block's boxed/indented dialogue treatment
// (avatar circle, name row, highlighted card) — just a small inline
// icon before the text, same weight as the edit button, so plain
// narration keeps reading like plain narration.
const narrPlayBtn = `<button type="button" class="reh-line-play-avatar reh-action-play-btn" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line"><span class="mdi mdi-play"></span></button>`;
return `<div class="reh-action-block${lineFlags}" data-index="${i}">
${bulkCheck}<span class="reh-action-text">${renderMarkdownInline(line.text)}</span>
${bulkCheck}${narrPlayBtn}<span class="reh-action-text">${renderMarkdownInline(line.text)}</span>
${editBtn} ${synthDot}
</div>`;
}
case 'pagebreak': {
const pbLabel = line.page ? `— Page ${line.page}` : '— Page break —';
@ -2376,7 +2578,7 @@ function buildScriptPage() {
${bulkCheck}<button class="reh-gutter-btn" data-index="${i}" title="Click: play from here · Shift+click: set end marker">${rehState.practiceStart===i?'▶':rehState.practiceEnd===i?'■':''}</button>
<div class="reh-block-inner">
<div class="reh-block-head">
<button type="button" class="reh-line-play-avatar" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line">${voiceAvatarHtml(isMe?'':c.voice, c.color, 32)}</button>
<button type="button" class="reh-line-play-avatar" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line">${_rehCharAvatarHtml(line.speaker, isMe?'':c.voice, c.color, 32)}</button>
<span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</span>
<span class="reh-block-head-spacer"></span>
${isMe
@ -2616,7 +2818,11 @@ function _reindexLineState(d) {
// ── Page mode (endless scroll | auto-pages | pdf-pages) ─────────────────────
const PAGE_MODES = ['auto', 'scroll', 'pdf'];
let _pageMode = localStorage.getItem('reh-page-mode') || 'auto';
// 'pdf' (break at the document's own real page marks) is the default now
// that parseScript's page-break detection actually works (see rehearser-parse.js) —
// 'auto' (break purely by content height, ignoring the source document's own
// pages entirely) was never really what most people mean by "pages".
let _pageMode = localStorage.getItem('reh-page-mode') || 'pdf';
function applyPageMode() {
const wrap = document.querySelector('.reh-page-wrap');
@ -2648,8 +2854,15 @@ function _syncPageModeBtn() {
if (!btn) return;
const icons = { auto: 'mdi-file-document-outline', scroll: 'mdi-format-align-justify', pdf: 'mdi-book-open-page-variant' };
const labels = { auto: 'A4 pages', scroll: 'Scroll', pdf: 'PDF pages' };
btn.innerHTML = `<span class="mdi ${icons[_pageMode] || icons.auto}"></span> ${labels[_pageMode] || labels.auto}`;
btn.title = 'Switch view: ' + labels[_pageMode];
// Labeling this with the CURRENT mode made it look like a passive status
// indicator rather than a button — confirmed live as genuine confusion:
// stuck in "Scroll" (one continuous page, no page breaks) with no visible
// cue that clicking the very button saying "Scroll" is what would change
// it. Show what clicking it switches TO instead, the normal convention
// for a cycle/toggle button.
const nextMode = PAGE_MODES[(PAGE_MODES.indexOf(_pageMode) + 1) % PAGE_MODES.length];
btn.innerHTML = `<span class="mdi ${icons[nextMode] || icons.auto}"></span> ${labels[nextMode] || labels.auto}`;
btn.title = `Currently: ${labels[_pageMode]} — click to switch to ${labels[nextMode]}`;
}
function cyclePageMode() {
@ -3019,6 +3232,41 @@ function _showReSynthBtn(idx, show) {
if (btn) btn.hidden = !show;
}
// A voice reassigned in the Library/Studio Voices tab (clPut calls this
// after every character save) used to never reach an already-open
// rehearsal of the same book: rehState.cast[sp].voice is loaded once from
// the saved rehearsal record, and an explicit prior value always wins over
// a fresher one from the shared roster (see the `saved?.voice ?? def.voice`
// load above) — so the Stage/export kept reading, and kept the cached
// audio for, the OLD voice indefinitely. Confirmed live as the cause of an
// audiobook export that finished suspiciously fast right after changing a
// voice: the reassigned character's lines were still "cached" under the
// old voice and never got marked stale. Only acts when a rehearsal for the
// SAME book is actually open right now and the name matches a real speaker.
function _rehSyncCastVoiceFromLibrary(rec) {
if (!window.rehState || !rehState.lines || !rehState.lines.length) return;
if (!rec || !rec.name || !rec.voice) return;
const openBook = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : '';
if (!openBook || String(rec.book || '').trim().toLowerCase() !== openBook.trim().toLowerCase()) return;
const target = String(rec.name).toUpperCase().trim();
const sp = Object.keys(rehState.cast).find(k => String(k).toUpperCase().trim() === target);
if (!sp) return;
const c = rehState.cast[sp];
if (!c || c.voice === rec.voice) return;
c.voice = rec.voice;
c.voiceData = getVoiceData(rec.voice);
rehState.lines.forEach((line, idx) => {
if (line.type === 'dialog' && line.speaker === sp && rehState.synthCache.has(idx)) {
rehState.synthCache.delete(idx);
rehState.staleLines.add(idx);
if (typeof _markSynthDot === 'function') _markSynthDot(idx, 'stale');
}
});
if (typeof _updateStaleBatchBtn === 'function') _updateStaleBatchBtn();
if (typeof renderCastList === 'function') renderCastList();
}
window._rehSyncCastVoiceFromLibrary = _rehSyncCastVoiceFromLibrary;
async function synthOneLine(idx) {
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') return;
@ -3304,6 +3552,18 @@ function startPlay() {
playNextLine();
}
// Resets the narrator play button's icon back to "play" — separate from
// highlightCurrentLine() (which also moves the active-line highlight and
// scrolls) since pausing/stopping shouldn't jump the page around, just
// stop claiming a line is still playing.
function _rehResetActionPlayIcons() {
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (icon) icon.className = 'mdi mdi-play';
btn.title = 'Play or pause this line';
});
}
function pausePlay() {
rehState.playing = false;
updatePlayBtn();
@ -3311,6 +3571,7 @@ function pausePlay() {
const audio = $('reh-tts-audio');
if (audio && !audio.paused) audio.pause();
hideStatusBar();
_rehResetActionPlayIcons();
}
function stopPlay() {
@ -3320,6 +3581,7 @@ function stopPlay() {
const audio = $('reh-tts-audio');
if (audio) { audio.pause(); audio.src = ''; }
hideStatusBar();
_rehResetActionPlayIcons();
}
function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; }
@ -3373,6 +3635,20 @@ async function playNextLine() {
return;
}
// Captured up front so a later `await` (waiting on a fresh TTS synthesis)
// can tell whether the user has since clicked a DIFFERENT line's play
// button — `rehState.lineIndex` itself gets overwritten by that click, so
// re-reading it after the await always looks "current" even when it
// isn't. Checking only `rehState.playing` (a bare boolean, flipped false
// then true again by the new click's own stopPlay()/startPlay() pair
// before this await ever resumes) let this stale continuation slip
// through and actually play — confirmed live as the reported bug: click
// a line while a previous, not-yet-synthesized line is still loading, and
// once that first synthesis finally finishes it cuts in and starts
// playing anyway, on top of (or right over) the line the user actually
// asked for, with no way to stop just that stray one.
const myLineIndex = rehState.lineIndex;
const stillCurrent = () => rehState.playing && rehState.lineIndex === myLineIndex;
const line = rehState.lines[rehState.lineIndex];
highlightCurrentLine();
@ -3387,17 +3663,26 @@ async function playNextLine() {
await playPreDecoded(rehState.lineIndex, cached, line.text);
} else {
try {
const blob = await fetchTtsPreviewBlob(rehState.narratorVoice, stripMarkdown(line.text), 'wav', '', rehState.backend);
if (!rehState.playing) return;
rehState.synthCache.set(rehState.lineIndex, blob);
_markSynthDot(rehState.lineIndex, 'ok');
await playPreDecoded(rehState.lineIndex, blob, line.text);
const book = _lineAudioBookName();
const cleanNarr = stripMarkdown(line.text);
const cacheKey = await _lineAudioCacheKey(cleanNarr, rehState.narratorVoice, '');
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(rehState.narratorVoice, cleanNarr, 'wav', '', rehState.backend);
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
_markSynthDot(myLineIndex, 'ok');
await playPreDecoded(myLineIndex, blob, line.text);
} catch(_) { await new Promise(r => setTimeout(r, 200)); }
}
} else if (!rehState.skipDescriptions) {
await new Promise(r => setTimeout(r, line.type === 'direction' ? 150 : 250));
}
if (!rehState.playing) return;
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
return;
@ -3415,7 +3700,7 @@ async function playNextLine() {
if (!cast.voice) {
showStatusBar(line.speaker + ' has no voice — skipping…');
await new Promise(r => setTimeout(r, 350));
if (!rehState.playing) return;
if (!stillCurrent()) return;
rehState.lineIndex++; playNextLine(); return;
}
@ -3431,19 +3716,28 @@ async function playNextLine() {
} else {
showStatusBar('Synthesizing…');
try {
const blob = await fetchTtsPreviewBlob(cast.voice, _rehInlineTone(cleanTxt, line.emotion), 'wav', instruct, rehState.backend);
if (!rehState.playing) return;
rehState.synthCache.set(rehState.lineIndex, blob);
const toneText = _rehInlineTone(cleanTxt, line.emotion);
const book = _lineAudioBookName();
const cacheKey = await _lineAudioCacheKey(toneText, cast.voice, instruct);
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(cast.voice, toneText, 'wav', instruct, rehState.backend);
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(rehState.lineIndex, blob, line.text); // also decodes + pre-fetches next
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob });
await playPreDecoded(myLineIndex, blob, line.text); // also decodes + pre-fetches next
rehState.clips.push({ lineIndex: myLineIndex, speaker: line.speaker, type: 'tts', blob });
} catch(e) {
showStatusBar('TTS failed: ' + e.message);
await new Promise(r => setTimeout(r, 1000));
}
}
if (!rehState.playing) return;
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
}
@ -3474,6 +3768,21 @@ function highlightCurrentLine() {
el.classList.toggle('reh-line-active', parseInt(el.dataset.index) === i);
});
// Dialogue's own play button shows play/pause via a pure-CSS badge
// overlay on the avatar (.reh-line-active .reh-line-play-avatar::after),
// but the plain narrator play button (.reh-action-play-btn, no avatar to
// overlay onto) never got the same treatment — its icon just stayed a
// static play triangle even while that exact line was the one actively
// playing, with nothing to show that clicking it again would stop it.
// Confirmed live as the reported "no way to stop it" complaint.
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (!icon) return;
const isActive = rehState.playing && parseInt(btn.dataset.index) === i;
icon.className = isActive ? 'mdi mdi-stop' : 'mdi mdi-play';
btn.title = isActive ? 'Stop' : 'Play or pause this line';
});
const active = document.querySelector(`[data-index="${i}"]`);
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
updatePlayBtn();
@ -3485,6 +3794,94 @@ function updatePlayBtn() {
btn.title = rehState.playing ? 'Pause' : 'Play all';
}
// ── Persistent per-paragraph audio cache ────────────────────────────────────
//
// rehState.synthCache only ever lived in the browser tab's memory — closing
// the tab, reloading, or a crash threw away everything "Synth all" had
// already paid GPU time for, forcing a full re-synthesis (and the pause
// between paragraphs that comes with it) the next time regardless. This
// persists each line's audio to disk, keyed by a hash of exactly what
// determines its sound (text + voice + tone/instruct) rather than its
// position in the script. An untouched paragraph's key never changes, so it
// keeps reusing the same cached file indefinitely; an edited paragraph's
// key changes the instant the text (or voice/tone) does, so it simply never
// matches a cached file again and gets synthesized fresh next time — no
// separate "delete the old file" step needed, the old file just becomes
// unreachable dead weight rather than ever being served again.
async function _lineAudioCacheKey(text, voice, instruct) {
const enc = new TextEncoder().encode(`${text}${voice}${instruct || ''}`);
const digest = await crypto.subtle.digest('SHA-256', enc);
return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
}
function _lineAudioBookName() {
return $('reh-script-title')?.value.trim() || 'Untitled';
}
async function _lineAudioCacheGet(book, key) {
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`);
if (!r.ok) return null;
return await r.blob();
} catch (_) { return null; }
}
function _lineAudioCachePut(book, key, blob) {
// Fire-and-forget — a failed save just means this line resynthesizes next
// time too, no worse than before this cache existed at all.
fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`, { method: 'POST', body: blob }).catch(() => {});
}
// Every line's "pre-synthesized" green dot only ever reflected
// rehState.synthCache — this browser tab's own memory, empty on every fresh
// page load — so a script that was fully "Synth all"-ed and safely
// persisted to disk in an EARLIER session still looked completely
// unsynthesized after a reload, with no way to tell the cached audio was
// actually right there. This asks the server, in one batch request, which
// of the current script's lines already have a matching file, and lights
// up their dots — without downloading any actual audio (that still only
// happens lazily, right when a line is about to play).
let _lineAudioSyncedFor = null; // `${book}::${lineCount}` — avoid re-running the same scan on every small re-render
async function _lineAudioSyncDots() {
const book = _lineAudioBookName();
const scanId = `${book}::${rehState.lines.length}`;
if (_lineAudioSyncedFor === scanId) return;
_lineAudioSyncedFor = scanId;
_ensureNarrator();
const idxToKey = new Map();
for (let i = 0; i < rehState.lines.length; i++) {
const line = rehState.lines[i];
if (line.ignored || line.hidden) continue;
if (rehState.synthCache.has(i)) continue; // already known-good this session
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
idxToKey.set(i, await _lineAudioCacheKey(text, voice, instruct));
}
if (!idxToKey.size) return;
const keyToIdx = new Map([...idxToKey].map(([i, k]) => [k, i]));
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/check`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: [...idxToKey.values()] }),
});
if (!r.ok) return;
const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }
}
// ── Synthesize All ─────────────────────────────────────────────────────────
async function synthAll() {
@ -3515,31 +3912,46 @@ async function synthAll() {
prog(0, ttsLines.length);
let done = 0;
for (const { line, idx } of ttsLines) {
if (rehState.synthCancelled) break;
let voice, instruct;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion);
} else {
voice = rehState.narratorVoice; instruct = '';
const book = _lineAudioBookName();
// Wrapped in try/finally so a thrown error (e.g. the speaker's cast entry
// got deleted mid-run — see the `!c` guard below) can never leave
// synthRunning stuck true forever, which would silently no-op every future
// "Synthesize All" / "Re-synthesize stale" click with no error shown, same
// failure shape as the character-sheets generation-lock bug fixed earlier.
try {
for (const { line, idx } of ttsLines) {
if (rehState.synthCancelled) break;
let voice, instruct;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { _markSynthDot(idx, null); prog(++done, ttsLines.length); continue; }
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion);
} else {
voice = rehState.narratorVoice; instruct = '';
}
_markSynthDot(idx, 'synthesizing');
document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(voice, toneText, 'wav', instruct, rehState.backend);
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
preDecodeBlob(idx, blob); // decode to PCM immediately → zero-latency playback
} catch(_) { _markSynthDot(idx, null); }
prog(++done, ttsLines.length);
}
_markSynthDot(idx, 'synthesizing');
document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
try {
const blob = await fetchTtsPreviewBlob(voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
preDecodeBlob(idx, blob); // decode to PCM immediately → zero-latency playback
} catch(_) { _markSynthDot(idx, null); }
prog(++done, ttsLines.length);
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
}
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
if (!rehState.synthCancelled) toast(`Pre-synthesized ${done} of ${ttsLines.length} lines — ready for instant playback`, 'success');
}
@ -3557,32 +3969,84 @@ $('reh-tb-resynth-stale')?.addEventListener('click', async () => {
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
if (synthBar) synthBar.hidden = false;
let done = 0;
for (const idx of stale) {
if (rehState.synthCancelled) break;
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; }
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; }
const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. ');
_markSynthDot(idx, 'synthesizing');
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
} catch(_) { _markSynthDot(idx, 'stale'); }
if (fill) fill.style.width = ((++done / stale.length) * 100) + '%';
if (label) label.textContent = `${done} / ${stale.length} synthesized`;
const book = _lineAudioBookName();
try {
for (const idx of stale) {
if (rehState.synthCancelled) break;
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; }
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; }
const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. ');
_markSynthDot(idx, 'synthesizing');
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, c.voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(c.voice, toneText, 'wav', instruct, rehState.backend);
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
} catch(_) { _markSynthDot(idx, 'stale'); }
if (fill) fill.style.width = ((++done / stale.length) * 100) + '%';
if (label) label.textContent = `${done} / ${stale.length} synthesized`;
}
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
_updateStaleBatchBtn();
}
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
_updateStaleBatchBtn();
if (!rehState.synthCancelled) toast(`Re-synthesized ${done} stale line${done !== 1 ? 's' : ''}`, 'success');
});
$('reh-synth-cancel')?.addEventListener('click', () => { rehState.synthCancelled = true; rehState.synthRunning = false; });
// Editing a paragraph doesn't delete its OLD cached audio file — the write
// path only ever knows the NEW content's hash, not whatever the line used
// to hash to before the edit, so the stale file just sits there unreferenced
// forever. This computes every key the CURRENT script would actually use
// and asks the server to delete anything else on disk for this book.
$('reh-tb-clean-cache')?.addEventListener('click', async () => {
const btn = $('reh-tb-clean-cache');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Scanning…'; }
try {
_ensureNarrator();
const keep = [];
for (const line of rehState.lines) {
if (line.ignored || line.hidden) continue;
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
keep.push(await _lineAudioCacheKey(text, voice, instruct));
}
const book = _lineAudioBookName();
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/prune`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep }),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
toast(d.deleted ? `Cleaned up ${d.deleted} unused cached audio file${d.deleted !== 1 ? 's' : ''}` : 'Nothing to clean up — every cached file is still in use', 'success');
} catch (e) {
toast('Cache cleanup failed: ' + (e.message || e), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="mdi mdi-broom"></span> Clean cache'; }
}
});
// ── Recording overlay ───────────────────────────────────────────────────────
function showRecOverlay(line) {

View File

@ -4,6 +4,33 @@ const SETTINGS_SEEN_KEY = 'vcf-settings-seen';
let _appSettings = {};
let _ttsBackends = [];
// Curated so the Model field is an actual dropdown instead of a blank text
// box the user has to already know a valid model ID to fill in — Custom…
// still falls back to free text for anything newer than this list.
const IMAGE_GEN_MODELS = {
openai: [
{ value: '', label: '(default — gpt-image-1)' },
{ value: 'gpt-image-1', label: 'gpt-image-1' },
{ value: 'dall-e-3', label: 'dall-e-3' },
{ value: 'dall-e-2', label: 'dall-e-2' },
],
google: [
{ value: '', label: '(default — gemini-2.5-flash-image)' },
{ value: 'gemini-2.5-flash-image', label: 'gemini-2.5-flash-image' },
{ value: 'gemini-2.5-flash-image-preview', label: 'gemini-2.5-flash-image-preview' },
{ value: 'imagen-4.0-generate-001', label: 'imagen-4.0-generate-001' },
{ value: 'imagen-4.0-ultra-generate-001', label: 'imagen-4.0-ultra-generate-001' },
{ value: 'imagen-3.0-generate-002', label: 'imagen-3.0-generate-002' },
],
openrouter: [
{ value: '', label: '(default — gemini-2.5-flash-image-preview:free)' },
{ value: 'google/gemini-2.5-flash-image-preview:free', label: 'google/gemini-2.5-flash-image-preview:free' },
{ value: 'google/gemini-2.5-flash-image-preview', label: 'google/gemini-2.5-flash-image-preview' },
{ value: 'google/gemini-2.5-flash-image', label: 'google/gemini-2.5-flash-image' },
],
};
const IMAGE_GEN_MODEL_CUSTOM = '__custom__';
function availableTtsBackends() {
return (_ttsBackends || []).filter(b => b.available);
}
@ -444,6 +471,27 @@ function enhanceSettingsHelp(root = document) {
});
}
// Builds the Model dropdown for whichever provider is active, selecting the
// saved value if it's one of the curated options, or "Custom…" (revealing
// the free-text field next to it) if it's something else — e.g. a newer
// model ID typed in before this list was updated to include it.
function _populateImageGenModelSelect(selectEl, provider, savedModel) {
if (!selectEl) return;
const options = IMAGE_GEN_MODELS[provider] || [{ value: '', label: '(default for provider)' }];
const known = options.some(o => o.value === (savedModel || ''));
selectEl.innerHTML = options.map(o =>
`<option value="${escHtml(o.value)}">${escHtml(o.label)}</option>`
).join('') + `<option value="${IMAGE_GEN_MODEL_CUSTOM}">Custom…</option>`;
const imgModel = $('image-gen-model');
if (known) {
selectEl.value = savedModel || '';
if (imgModel) imgModel.style.display = 'none';
} else {
selectEl.value = IMAGE_GEN_MODEL_CUSTOM;
if (imgModel) { imgModel.style.display = ''; imgModel.value = savedModel || ''; }
}
}
async function loadSettings() {
const s = await fetch('/api/settings').then(r => r.json());
const sv = (id, val) => { const el = $(id); if (el) el.value = val; };
@ -512,6 +560,35 @@ async function loadSettings() {
sv('s-voices-scan-dir', s.voices_scan_dir || '');
sv('s-output-dir', s.output_dir || '');
restoreEngineApiKeyInputs();
const extKeyInp = $('s-external-api-key');
if (extKeyInp) extKeyInp.value = s.external_api_key || '';
const extKeyReq = $('s-external-api-key-required');
if (extKeyReq) extKeyReq.checked = !!s.external_api_key_required;
if (extKeyReq && !extKeyReq.dataset.wired) {
extKeyReq.dataset.wired = '1';
extKeyReq.addEventListener('change', () => _patchSettings({ external_api_key_required: extKeyReq.checked }));
}
const extKeyCopyBtn = $('s-external-api-key-copy');
if (extKeyCopyBtn && !extKeyCopyBtn.dataset.wired) {
extKeyCopyBtn.dataset.wired = '1';
extKeyCopyBtn.addEventListener('click', async () => {
if (typeof copyText === 'function') await copyText($('s-external-api-key')?.value || '');
toast('API key copied', 'success');
});
}
const extKeyRegenBtn = $('s-external-api-key-regen');
if (extKeyRegenBtn && !extKeyRegenBtn.dataset.wired) {
extKeyRegenBtn.dataset.wired = '1';
extKeyRegenBtn.addEventListener('click', async () => {
const ok = await confirmDialog('Regenerate the external API key? Anything using the current key (scripts, MCP clients) will stop working until updated.', { title: 'Regenerate API key?', okLabel: 'Regenerate', danger: true });
if (!ok) return;
const r = await fetch('/api/settings/regenerate-api-key', { method: 'POST' });
const d = await r.json();
if ($('s-external-api-key')) $('s-external-api-key').value = d.external_api_key || '';
if (_appSettings) _appSettings.external_api_key = d.external_api_key;
toast('API key regenerated', 'success');
});
}
const seedFinderDirEl = $('s-seed-finder-dir');
if (seedFinderDirEl) seedFinderDirEl.value = s.seed_finder_dir || '';
const ptDirEl = $('s-pt-dir');
@ -520,6 +597,129 @@ async function loadSettings() {
if (seedTextEl) seedTextEl.value = s.seed_finder_text || '';
const themeEl = $('s-theme-select');
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
const imgProv = $('image-gen-provider');
const imgModelSelect = $('image-gen-model-select');
const imgModel = $('image-gen-model');
if (imgProv) imgProv.value = s.image_gen_provider || '';
_populateImageGenModelSelect(imgModelSelect, imgProv?.value || '', s.image_gen_model || '');
// loadSettings() re-runs on every Settings open / Reload click, so guard
// against re-attaching (dataset flag) rather than accumulating listeners.
if (imgProv && !imgProv.dataset.wired) {
imgProv.dataset.wired = '1';
imgProv.addEventListener('change', () => {
_patchSettings({ image_gen_provider: imgProv.value });
// Switching provider invalidates whatever model was picked for the
// old one — reset to that provider's default instead of keeping a
// stale/incompatible model ID silently in place.
_populateImageGenModelSelect(imgModelSelect, imgProv.value, '');
_patchSettings({ image_gen_model: '' });
});
}
if (imgModelSelect && !imgModelSelect.dataset.wired) {
imgModelSelect.dataset.wired = '1';
imgModelSelect.addEventListener('change', () => {
if (imgModelSelect.value === IMAGE_GEN_MODEL_CUSTOM) {
if (imgModel) { imgModel.style.display = ''; imgModel.focus(); }
return;
}
if (imgModel) imgModel.style.display = 'none';
_patchSettings({ image_gen_model: imgModelSelect.value });
});
}
if (imgModel && !imgModel.dataset.wired) {
imgModel.dataset.wired = '1';
let _imgModelTimer;
imgModel.addEventListener('input', () => {
clearTimeout(_imgModelTimer);
_imgModelTimer = setTimeout(() => _patchSettings({ image_gen_model: imgModel.value }), 600);
});
}
// ── Local ComfyUI workflow config ──────────────────────────────────────
const comfyPanel = $('comfyui-config-panel');
if (comfyPanel) comfyPanel.hidden = (imgProv?.value || '') !== 'comfyui';
if (imgProv && !imgProv.dataset.comfyWired) {
imgProv.dataset.comfyWired = '1';
imgProv.addEventListener('change', () => { if (comfyPanel) comfyPanel.hidden = imgProv.value !== 'comfyui'; });
}
const cfUrl = $('comfyui-url');
const cfWorkflow = $('comfyui-workflow');
const cfPromptId = $('comfyui-prompt-node-id');
const cfPromptFld= $('comfyui-prompt-field');
const cfOutId = $('comfyui-output-node-id');
if (cfUrl) cfUrl.value = s.comfyui_url || 'http://host.docker.internal:8188';
if (cfWorkflow) cfWorkflow.value = s.comfyui_workflow || '';
if (cfPromptId) cfPromptId.value = s.comfyui_prompt_node_id || '';
if (cfPromptFld) cfPromptFld.value = s.comfyui_prompt_field || 'text';
if (cfOutId) cfOutId.value = s.comfyui_output_node_id || '';
[[cfUrl, 'comfyui_url'], [cfWorkflow, 'comfyui_workflow'], [cfPromptId, 'comfyui_prompt_node_id'],
[cfPromptFld, 'comfyui_prompt_field'], [cfOutId, 'comfyui_output_node_id']].forEach(([el, key]) => {
if (!el || el.dataset.wired) return;
el.dataset.wired = '1';
let t;
el.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => _patchSettings({ [key]: el.value }), 600);
});
});
const cfTestBtn = $('comfyui-test-btn');
const cfTestGenBtn = $('comfyui-test-gen-btn');
const cfTestStatus = $('comfyui-test-status');
const cfTestPreview= $('comfyui-test-preview');
const cfTestImg = $('comfyui-test-img');
if (cfTestBtn && !cfTestBtn.dataset.wired) {
cfTestBtn.dataset.wired = '1';
cfTestBtn.addEventListener('click', async () => {
const url = (cfUrl?.value || '').trim();
if (!url) { toast('Enter a ComfyUI URL first', 'error'); return; }
cfTestBtn.disabled = true;
if (cfTestStatus) { cfTestStatus.textContent = 'Checking…'; cfTestStatus.className = 'llm-active-status'; }
try {
const d = await window.probeUrl(url, 'comfyui');
if (d.ok) {
if (cfTestStatus) { cfTestStatus.textContent = '✓ Reachable'; cfTestStatus.className = 'llm-active-status ok'; }
toast('✓ ComfyUI reachable', 'success');
} else {
if (cfTestStatus) { cfTestStatus.textContent = 'Unreachable'; cfTestStatus.className = 'llm-active-status err'; }
toast('Cannot reach ComfyUI: ' + (d.error || 'No response'), 'error');
}
} catch (e) {
if (cfTestStatus) { cfTestStatus.textContent = 'Failed'; cfTestStatus.className = 'llm-active-status err'; }
toast('Test failed: ' + e.message, 'error');
} finally {
cfTestBtn.disabled = false;
}
});
}
if (cfTestGenBtn && !cfTestGenBtn.dataset.wired) {
cfTestGenBtn.dataset.wired = '1';
cfTestGenBtn.addEventListener('click', async () => {
cfTestGenBtn.disabled = true;
const orig = cfTestGenBtn.innerHTML;
cfTestGenBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating… (can take minutes)';
if (cfTestPreview) cfTestPreview.hidden = true;
if (cfTestStatus) { cfTestStatus.textContent = ''; cfTestStatus.className = 'llm-active-status'; }
try {
const r = await fetch('/api/character-generate-image', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'A simple test character portrait, fantasy art style', provider: 'comfyui' }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.statusText);
if (cfTestImg) cfTestImg.src = d.image;
if (cfTestPreview) cfTestPreview.hidden = false;
if (cfTestStatus) { cfTestStatus.textContent = '✓ Success'; cfTestStatus.className = 'llm-active-status ok'; }
toast('✓ ComfyUI generation succeeded', 'success');
} catch (e) {
if (cfTestStatus) { cfTestStatus.textContent = 'Failed'; cfTestStatus.className = 'llm-active-status err'; }
toast('ComfyUI test generate failed: ' + e.message, 'error');
} finally {
cfTestGenBtn.disabled = false;
cfTestGenBtn.innerHTML = orig;
}
});
}
if (window.populateLangSelect) window.populateLangSelect();
// Captures settings
const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || '';
@ -629,6 +829,8 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
refine_punctuation: $('s-refine-punctuation')?.checked ?? true,
captures_default_voice: $('s-captures-default-voice')?.value || '',
seed_finder_text: $('s-seed-finder-text')?.value || '',
image_gen_provider: $('image-gen-provider')?.value || '',
image_gen_model: $('image-gen-model')?.value || '',
}) });
_appSettings.tts_stream_url = $('s-tts-stream-url').value;
_appSettings.customvoice_url = $('s-customvoice-url').value;

420
static/js/studio.js Normal file
View File

@ -0,0 +1,420 @@
// ── Studio ────────────────────────────────────────────────────────────────
// A unified 4-phase view (Source → Characters → Voices → Perform & Export)
// over the same PDF-to-audiobook pipeline that Read Aloud (s-reader) and
// Script Rehearser (s-rehearser) already implement. Studio is a thin shell:
// it borrows the DOM subtrees those sections already render into (and that
// reader.js/audiobook.js/rehearser.js/character-sheets.js/library-characters.js
// already attach their listeners to) via runtime reparenting, rather than
// duplicating any business logic. See the Studio build plan for the full
// rationale — in short: every section's HTML is present in the live DOM at
// all times (loader.js injects all of them up front), so a second COPY of
// the same element ids would collide; physically relocating the existing
// node (same id, same listeners) does not.
let _stuActive = 1;
// id -> { parent, next } — where a borrowed node came from, so it can go home.
const _stuHomes = new Map();
// Moves the real DOM node `id` into `slotId`. Safe to call repeatedly; only
// records the original location once (first borrow), so returning always
// goes back to the true original spot even after several borrow/re-borrows.
function _stuBorrow(id, slotId) {
const el = document.getElementById(id);
const slot = document.getElementById(slotId);
if (!el || !slot) return;
if (!_stuHomes.has(id)) _stuHomes.set(id, { parent: el.parentNode, next: el.nextSibling });
slot.appendChild(el);
}
// Returns every currently-borrowed node to its original parent/position.
// Called unconditionally whenever navigation leaves Studio (see nav.js),
// so any other section that owns one of these subtrees always finds it
// back in place before that section's own onShow logic runs.
function _stuReturnAll() {
_stuIsActive = false;
if (typeof _stuRestoreCastFoot === 'function') _stuRestoreCastFoot();
_stuHomes.forEach(function (home, id) {
const el = document.getElementById(id);
if (el && home.parent) home.parent.insertBefore(el, home.next);
});
_stuHomes.clear();
}
window._stuReturnAll = _stuReturnAll;
// Borrowed entry points scattered across reader.js/audiobook.js/rehearser.js
// call navTo('s-reader')/navTo('s-library')/navTo('s-rehearser') themselves
// as a "make sure the right section is showing" safety measure — reasonable
// when invoked from those sections directly, but while Studio is the one
// orchestrating the call it would snap the user away from Studio (and, via
// nav.js's return-on-leave hook, immediately un-borrow whatever was just
// relocated there). These calls come from all over — a click handler wired
// at panel-render time (library.js's book cards), deep inside a multi-second
// PDF parse (readerOpenLibraryDoc), or buried in a multi-passage LLM casting
// pipeline that can run for minutes (audiobookCast's "Continue casting") —
// confirmed live, each one surfaced only after chasing and patching the
// previous one individually, which doesn't scale to arbitrary future call
// sites the same way.
//
// Instead of chasing each function, gate on DESTINATION: while Studio is the
// user's current section, any navTo call targeting one of the three sections
// Studio borrows from is presumed to be one of these internal "stay on my
// home section" reflexes and is dropped — UNLESS the user just clicked that
// section's own OLD sidebar entry, which is unambiguous real intent to leave
// (tracked as a one-shot allow flag set by a capture-phase listener on those
// specific sidebar links, so it can never itself be the thing that needs
// suppressing). This covers every current and future delayed/nested
// call site uniformly, with no timing window to get wrong.
let _stuIsActive = false;
let _stuAllowNextNav = false;
const _STU_BORROWED_FROM = { 's-reader': 1, 's-library': 1, 's-rehearser': 1 };
// Installed lazily (on first entry to Studio) rather than at script-load time:
// nav.js loads AFTER studio.js in loader.js's batch order and unconditionally
// does `window.navTo = function (sectionId) {...}` — patching eagerly here
// got silently clobbered the instant nav.js finished loading, since that
// happens strictly later (confirmed live: the wrapper was installed, then
// overwritten before the user could ever click anything). By the time
// studioOnShow() first runs, the whole page (including nav.js) has finished
// loading, so window.navTo is guaranteed to already be its real, final self.
let _stuNavGuardInstalled = false;
function _stuInstallNavGuardOnce() {
if (_stuNavGuardInstalled) return;
_stuNavGuardInstalled = true;
const realNavTo = window.navTo;
window.navTo = function (id) {
if (_stuIsActive && !_stuAllowNextNav && _STU_BORROWED_FROM[id]) return; // internal reflex, ignored
_stuAllowNextNav = false;
return realNavTo(id);
};
// showReaderView('cast'|'chars') toggles .hidden directly on the real
// reader-audiobook-panel/reader-charsheets-panel elements — but Studio
// shows/hides those same (borrowed) elements one level up, via its OWN
// slot visibility (_stuShowCastView). Any code that calls showReaderView
// directly (csForReader's "New recast", cast-from-rehearser, the emotion
// dialogue's fallback nav, ...) — not just navTo — can flip the panel
// visible while Studio's slot around it is still hidden, or vice versa.
// Confirmed live: clicking "New recast (discard & rebuild all)" while
// Studio's Characters tab was on "Identify" left the ENTIRE tab blank —
// the panel showReaderView just unhid (charsheets, with the live
// progress UI) sat inside a slot Studio still had hidden, while the
// slot the user could actually see had just had its own content hidden.
// Keep Studio's own tab UI in lockstep whenever this happens, on top of
// whatever the real function already does.
const realShowReaderView = window.showReaderView;
let _stuInShowReaderView = false;
window.showReaderView = function (view) {
const result = typeof realShowReaderView === 'function' ? realShowReaderView(view) : undefined;
// INFINITE RECURSION, confirmed live (renderer pegged at steady high CPU,
// then crashing/going blank): audiobookCastView() itself calls
// navReaderView('cast') -> this wrapper -> _stuShowCastView('identify')
// -> audiobookOpenCastView() -> audiobookCastView() again -> forever.
// Two guards: never re-enter this wrapper while already inside a call it
// triggered, AND skip entirely if Studio's sub-tab already matches (the
// overwhelmingly common case — most showReaderView('cast') calls are
// just the underlying code re-asserting a view that's already showing).
if (_stuIsActive && !_stuInShowReaderView && (view === 'cast' || view === 'chars')) {
const target = view === 'chars' ? 'sheets' : 'identify';
if (_stuCastView !== target) {
_stuInShowReaderView = true;
try { _stuShowCastView(target); } finally { _stuInShowReaderView = false; }
}
}
return result;
};
// Sub-items (Casting, Stage, Summary, Books, Theater Plays, Cast, …) call
// navTo for the same section but don't carry data-nav-section themselves —
// only their parent head does — so cover each section's whole nav-tree,
// not just its head.
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 = true; }, true);
});
}
// Kept as a thin no-op-friendly shim: callers elsewhere in this file already
// wrap borrowed function calls with this name; with the persistent guard
// above, no actual patch/restore is needed per call, so this now just runs
// the function.
function _stuCallSuppressingNav(fn) { return fn(); }
// Per-phase borrow wiring — called by showStudioPhase whenever a phase
// becomes active. Each case borrows the real subtree that section already
// renders into/wires listeners on; no logic is duplicated here.
function _stuEnterPhase(n) {
if (n === 1) {
_stuBorrow('reader-main-view', 'stu-source-slot');
if (typeof window.readerOnShow === 'function') _stuCallSuppressingNav(window.readerOnShow);
_stuBorrow('lib-books-list', 'stu-books-slot');
_stuCallSuppressingNav(function () {
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
});
} else if (n === 2) {
_stuShowCastView(_stuCastView);
} else if (n === 3) {
_stuBorrow('lib-chars-list', 'stu-voices-slot');
// Scope the grid to the current book, same mechanism the old "Assign
// Voices" WF_STEPS stop already uses (utils.js). readerState.title can
// go stale — it's left pointing at whichever book was last opened via
// readerOpenLibraryDoc, which isn't necessarily the book whose cast is
// actually active (confirmed live: navigating here from a book 2 "View
// cast" action expanded book 1's section instead, because readerState
// still pointed at book 1 from earlier in the session). _audiobook.bookId
// is the field audiobookOpenCastView() actually keeps in sync with
// whichever book's cast session is active, so resolve the title from
// that via the server instead of trusting readerState.title first.
(async () => {
let title = null;
if (window._audiobook && window._audiobook.bookId) {
try {
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
} catch (_) {}
}
window._libCharsScrollToBook = title || (window.readerState && readerState.title) || null;
_stuCallSuppressingNav(function () {
if (typeof window.libraryRender === 'function') window.libraryRender('characters');
});
})();
} else if (n === 4) {
_stuBorrow('reh-phase-3', 'stu-stage-slot');
_stuBorrow('reh-cast-list', 'stu-mecast-slot');
_stuCallSuppressingNav(async function () {
if (window.rehState && rehState.lines && rehState.lines.length) {
if (typeof buildScriptPage === 'function') buildScriptPage();
if (typeof showPhase === 'function') showPhase(3);
if (typeof highlightCurrentLine === 'function') highlightCurrentLine();
return;
}
// _audiobook.segments is a purely in-memory cache — it's only ever
// populated by actually visiting the Characters phase first (which
// triggers audiobookOpenCastView()'s own draft-restore). Jumping
// straight to Perform & Export in a fresh session (confirmed live)
// left it empty even though a perfectly good, fully-cast draft was
// sitting on the server the whole time — Stage just rendered blank
// instead of loading it. Fetch it directly rather than requiring a
// detour through Characters first.
if (!(window._audiobook && window._audiobook.segments && window._audiobook.segments.length)
&& typeof _abLoadDraftServer === 'function' && typeof _abBookId === 'function') {
const bookId = _abBookId();
const draft = bookId ? await _abLoadDraftServer(bookId) : null;
if (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();
}
});
// audiobookOpenCurrentInRehearser's own navTo('s-rehearser') is now
// suppressed, but its DOM effects (rehLoadRecord -> renderCastList,
// buildScriptPage, showPhase(3)) still ran against the real elements —
// just re-assert reh-phase-3 isn't left hidden after all that.
const rp3 = document.getElementById('reh-phase-3');
if (rp3) rp3.hidden = false;
_stuSyncModeToggle();
}
}
// The Rehearse ⇄ Audiobook toggle — reads/writes the exact same rehState
// fields the old Script Rehearser toolbar already exposes (#reh-skip-desc-toggle,
// cast[sp].voice==='me'); no new synthesis/export codepath.
function _stuSyncModeToggle() {
const cb = document.getElementById('stu-mode-audiobook');
const details = document.getElementById('stu-mecast-details');
if (!cb || !window.rehState) return;
cb.checked = !rehState.skipDescriptions;
if (details) details.hidden = cb.checked;
}
document.getElementById('stu-mode-audiobook')?.addEventListener('change', function () {
const audiobookMode = this.checked;
if (window.rehState) {
rehState.skipDescriptions = !audiobookMode;
const t = document.getElementById('reh-skip-desc-toggle');
if (t) t.checked = rehState.skipDescriptions;
}
const details = document.getElementById('stu-mecast-details');
if (details) details.hidden = audiobookMode;
});
// Phase 2 (Characters) has its own two sub-views, mirroring Read Aloud's
// existing 'cast' (speaker attribution) and 'chars' (character sheets)
// views — same panels, same entry points (audiobookOpenCastView/csForReader),
// just borrowed into Studio's slots instead of shown in place.
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');
const sheetsSlot = document.getElementById('stu-castchars-slot');
if (identifySlot) identifySlot.hidden = (view !== 'identify');
if (sheetsSlot) sheetsSlot.hidden = (view !== 'sheets');
if (view === 'identify') {
_stuBorrow('reader-audiobook-panel', 'stu-cast-slot');
_stuCallSuppressingNav(function () {
if (typeof window.audiobookOpenCastView === 'function') return window.audiobookOpenCastView();
else if (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');
// This tab is the old page's WF_STEPS step 3 ("Cast Characters ·
// optional — let the AI fill out full character profiles"), i.e. the
// passage-by-passage PROFILE-generation pass (appearance/backstory/voice
// notes, live "Passage N / M…" progress) — a completely different thing
// from the dialogue SPEAKER-attribution pass on the Identify Characters
// tab. An earlier version of this empty-state wrongly pointed at that
// other pass's menu instead. The real entry point is `csForReader()`
// (character-sheets.js) — it locates its own render target via
// `csReaderPageHost()` (`$('reader-charsheets-panel')`), so calling it
// with no args renders inline into wherever this panel currently lives,
// borrowed or not.
if (panel && !panel.innerHTML.trim()) {
panel.innerHTML = '<div class="card" style="padding:24px; min-height:220px; display:flex; flex-direction:column; gap:12px; justify-content:center; align-items:flex-start;">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>'
+ '<p class="card-subtitle" style="margin:0;">Optional — let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.</p>'
+ '<button class="btn-primary btn-sm" id="stu-goto-cast-menu"><span class="mdi mdi-creation"></span> Generate character sheets</button>'
+ '</div>';
const goBtn = document.getElementById('stu-goto-cast-menu');
if (goBtn) goBtn.addEventListener('click', function () {
if (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); });
});
// 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
// plain Identify/Cast Characters tabs above just duplicated by label without
// offering any of that. Its flyout menus are DOM-position-independent
// (_abToggleFootMenu in audiobook.js appends to document.body and positions
// via the trigger button's own getBoundingClientRect, not any ancestor), so
// the buttons can be physically relocated up next to Studio's own tabs
// instead of living in a separate bar at the bottom. "Open Script
// Rehearser" is dropped — Studio already has its own Perform & Export tab.
//
// audiobookCastView() does a full `panel.innerHTML = ...` on EVERY call —
// not just the first time the view opens, but also after in-place edits
// (e.g. "Split text to Unknown Speaker") and casting-progress updates — so a
// fresh #ab-cv-foot (same id) reappears inside the panel each time. A single
// poll-and-move only caught the first rebuild; confirmed live that a later
// rebuild left a second, un-relocated, un-hidden footer (with "Open Script
// Rehearser" still showing) sitting back at the bottom. Watch for it instead
// of relocating once.
let _stuCastFootObserver = null;
function _stuRelocateCastFoot() {
_stuTryRelocateCastFoot();
if (_stuCastFootObserver) return;
const slot = document.getElementById('stu-cast-slot');
if (!slot) return;
_stuCastFootObserver = new MutationObserver(function () { _stuTryRelocateCastFoot(); });
_stuCastFootObserver.observe(slot, { childList: true, subtree: true });
}
function _stuTryRelocateCastFoot() {
const slot = document.getElementById('stu-cast-slot');
const tabs = document.getElementById('stu-cast-inner-tabs');
if (!tabs) return;
const freshFoot = slot ? slot.querySelector('#ab-cv-foot') : null;
const alreadyRelocated = tabs.querySelector('#ab-cv-foot');
// Transient states (e.g. "Checking saved cast before starting a new
// one…") rebuild the panel WITHOUT a footer at all — confirmed live: the
// plain tabs stayed hidden from an earlier relocation with nothing having
// replaced them, leaving no navigation visible whatsoever. Fall back to
// showing the plain tabs any time there's genuinely no footer to relocate
// (neither a fresh one nor an already-relocated one), rather than only
// ever hiding them and never re-checking.
if (!freshFoot && !alreadyRelocated) {
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; });
return;
}
if (!freshFoot || freshFoot.parentElement === tabs) return;
tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); });
freshFoot.style.borderTop = 'none';
freshFoot.style.padding = '0';
freshFoot.style.justifyContent = 'flex-start';
tabs.appendChild(freshFoot);
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = true; });
const openReh = freshFoot.querySelector('#ab-cv-open-reh');
if (openReh) openReh.hidden = true;
}
// The footer isn't a whole-panel borrow tracked by _stuHomes (only the
// bigger #reader-audiobook-panel is), so leaving Studio would otherwise
// carry the relocated node's origin nowhere — the old Read Aloud page would
// come back missing its own footer. Called from _stuReturnAll before the
// panel itself goes home, so the footer rides back inside it intact.
function _stuRestoreCastFoot() {
if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; }
const tabs = document.getElementById('stu-cast-inner-tabs');
const panel = document.getElementById('reader-audiobook-panel');
const 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');
if (openReh) openReh.hidden = false;
panel.appendChild(foot);
}
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; });
}
function showStudioPhase(n) {
_stuActive = n;
for (let i = 1; i <= 4; i++) {
const el = document.getElementById('stu-phase-' + i);
if (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');
if (prevBtn) prevBtn.disabled = (n <= 1);
if (nextBtn) nextBtn.disabled = (n >= 4);
if (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));
});
});
document.getElementById('stu-phase-prev')?.addEventListener('click', function () { if (_stuActive > 1) showStudioPhase(_stuActive - 1); });
document.getElementById('stu-phase-next')?.addEventListener('click', function () { if (_stuActive < 4) showStudioPhase(_stuActive + 1); });
// Called once when Studio becomes the active section (see nav.js showSection).
function studioOnShow() {
// We're here because nav.js's navTo()/showSection() called us — meaning
// window.navTo is already nav.js's real, final function no matter what
// order scripts loaded in, so it's safe to capture it now.
_stuInstallNavGuardOnce();
// Set BEFORE showStudioPhase/_stuEnterPhase run — those synchronously
// borrow subtrees and call into reader.js/audiobook.js code that may
// itself call navTo, and the guard above needs _stuIsActive true for
// that first entry too, not just on subsequent clicks once Studio's own
// .is-active class has actually been applied by nav.js (which happens
// AFTER this hook runs).
_stuIsActive = true;
showStudioPhase(_stuActive);
}
window.studioOnShow = studioOnShow;

View File

@ -22,6 +22,42 @@ function toast(msg, type = '', ms = 6500) {
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.className = '', ms);
}
// In-app replacement for window.confirm() — native browser confirm dialogs
// show the page's raw URL/IP ("192.168.178.8:7890 says…") and can't be
// styled, which reads as broken/untrustworthy next to the rest of the UI.
// Returns a Promise<boolean> so call sites just `await confirmDialog(...)`
// instead of the synchronous window.confirm() return value.
function confirmDialog(message, opts = {}) {
return new Promise(resolve => {
const ov = document.createElement('div');
ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-alert-circle-outline"></span> ${escHtml(opts.title || 'Are you sure?')}</div>
<div class="audiobook-msg">${escHtml(message || '')}</div>
<div class="audiobook-actions">
<button type="button" class="btn-secondary btn-sm" id="cd-cancel">${escHtml(opts.cancelLabel || 'Cancel')}</button>
<button type="button" class="btn-primary btn-sm${opts.danger ? ' btn-danger' : ''}" id="cd-ok">${escHtml(opts.okLabel || 'OK')}</button>
</div>
</div>`;
document.body.appendChild(ov);
const cleanup = (result) => {
ov.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
};
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); cleanup(false); }
else if (e.key === 'Enter') { e.preventDefault(); cleanup(true); }
};
document.addEventListener('keydown', onKey);
ov.querySelector('#cd-cancel').addEventListener('click', () => cleanup(false));
ov.querySelector('#cd-ok').addEventListener('click', () => cleanup(true));
ov.addEventListener('click', e => { if (e.target === ov) cleanup(false); });
ov.querySelector('#cd-ok').focus();
});
}
window.confirmDialog = confirmDialog;
function status(msg) {
ensureStatusBar();
const el = $('status-message') || $('status-bar');
@ -160,9 +196,17 @@ function _statusFlyupList(anchor, items, onPick, emptyLabel, kind) {
const el = document.createElement('div');
el.className = 'status-flyup';
if (kind) el.dataset.forKind = kind;
el.innerHTML = items.length
? items.map(it => `<button type="button" class="status-flyup-item${it.active ? ' is-active' : ''}" data-val="${escHtml(it.id)}">${it.active ? '<span class="mdi mdi-check"></span>' : ''}<span>${escHtml(it.label)}</span></button>`).join('')
// A long model list (OpenRouter alone lists 50+) is unusable to scan by
// eye — add a live-filter search box once there's enough items that
// scrolling to find one is slower than typing its name.
const showSearch = items.length > 8;
const searchHtml = showSearch
? `<div class="status-flyup-search"><span class="mdi mdi-magnify"></span><input type="text" placeholder="Search…" spellcheck="false"></div>`
: '';
const listHtml = items.length
? items.map(it => `<button type="button" class="status-flyup-item${it.active ? ' is-active' : ''}" data-val="${escHtml(it.id)}" data-search="${escHtml(it.label.toLowerCase())}">${it.active ? '<span class="mdi mdi-check"></span>' : ''}<span>${escHtml(it.label)}</span></button>`).join('')
: `<div class="status-flyup-empty">${escHtml(emptyLabel || 'Nothing available')}</div>`;
el.innerHTML = searchHtml + `<div class="status-flyup-list">${listHtml}</div>`;
document.body.appendChild(el);
const rect = anchor.getBoundingClientRect();
el.style.left = Math.min(rect.left, window.innerWidth - el.offsetWidth - 12) + 'px';
@ -170,6 +214,17 @@ function _statusFlyupList(anchor, items, onPick, emptyLabel, kind) {
el.querySelectorAll('.status-flyup-item').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); onPick(btn.dataset.val); _statusCloseFlyup(); });
});
const searchInp = el.querySelector('.status-flyup-search input');
if (searchInp) {
searchInp.addEventListener('click', e => e.stopPropagation());
searchInp.addEventListener('input', () => {
const q = searchInp.value.trim().toLowerCase();
el.querySelectorAll('.status-flyup-item').forEach(btn => {
btn.hidden = q.length > 0 && !btn.dataset.search.includes(q);
});
});
setTimeout(() => searchInp.focus(), 0);
}
_statusFlyup = el;
}
@ -433,6 +488,18 @@ function enhanceAccessibility(root = document) {
icon.setAttribute('aria-hidden', 'true');
});
_syncNavActiveState();
}
// Split out of enhanceAccessibility() so the MutationObserver's attribute
// handler (fires on every class-attribute change anywhere in the app — very
// frequent once a session has any dynamic UI toggling active/hover classes)
// doesn't have to re-run the full enhancement pass (several document-wide
// querySelectorAll calls) just to keep nav aria-current in sync. That
// mismatch is what caused multi-second rAF-adjacent blocking / an
// effectively frozen page once this session's larger grids made class
// mutations much more frequent than when this observer was first written.
function _syncNavActiveState() {
document.querySelectorAll('.nav-item, .nav-tree-item').forEach(el => {
const active = el.classList.contains('active') || el.classList.contains('is-active');
if (active && el.getAttribute('aria-current') !== 'page') el.setAttribute('aria-current', 'page');
@ -440,6 +507,33 @@ function enhanceAccessibility(root = document) {
});
}
// Focus trap + restore for the ~11 places across the app that build a modal
// as `<div class="audiobook-overlay">…</div>` appended straight to
// document.body (confirmDialog, the avatar lightbox, csShow, voice pickers,
// etc.) — none of them individually managed focus, so a keyboard/screen-
// reader user could Tab straight out of an open dialog into the page behind
// it, and focus never returned to whatever triggered the dialog on close.
// Hooking this into the childList observer that's already watching
// document.body for every dynamically-inserted node avoids touching all 11
// call sites individually.
const _a11yModalStack = [];
function _a11yFocusables(root) {
return [...root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
.filter(el => !el.disabled && el.offsetParent !== null);
}
function _a11yOnModalOpened(ov) {
_a11yModalStack.push({ ov, trigger: document.activeElement });
const focusables = _a11yFocusables(ov);
(focusables[0] || ov).focus?.({ preventScroll: true });
if (!ov.hasAttribute('tabindex') && !focusables.length) ov.setAttribute('tabindex', '-1');
}
function _a11yOnModalClosed(ov) {
const idx = _a11yModalStack.findIndex(entry => entry.ov === ov);
if (idx < 0) return;
const [entry] = _a11yModalStack.splice(idx, 1);
if (document.body.contains(entry.trigger)) entry.trigger.focus?.({ preventScroll: true });
}
function initAccessibilityEnhancements() {
if (window.__ttsvcA11yReady) { enhanceAccessibility(document); return; }
window.__ttsvcA11yReady = true;
@ -451,12 +545,33 @@ function initAccessibilityEnhancements() {
e.preventDefault();
el.click();
});
// Tab trap: while any modal is open, Tab/Shift+Tab cycles only within the
// TOPMOST one's focusable elements instead of escaping into the page
// behind it.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Tab' || !_a11yModalStack.length) return;
const { ov } = _a11yModalStack[_a11yModalStack.length - 1];
const focusables = _a11yFocusables(ov);
if (!focusables.length) return;
const first = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
m.addedNodes.forEach(n => { if (n.nodeType === 1) enhanceAccessibility(n); });
m.addedNodes.forEach(n => {
if (n.nodeType !== 1) return;
enhanceAccessibility(n);
if (n.classList?.contains('audiobook-overlay')) _a11yOnModalOpened(n);
});
m.removedNodes.forEach(n => {
if (n.nodeType !== 1) return;
if (n.classList?.contains('audiobook-overlay')) _a11yOnModalClosed(n);
});
} else if (m.type === 'attributes') {
enhanceAccessibility(document);
// Cheap, targeted sync only — NOT a full-document re-scan.
_syncNavActiveState();
}
}
});
@ -768,10 +883,39 @@ function detectLang(text) {
for (const w of words) for (const l in sets) if (sets[l].has(w)) counts[l]++;
let best = '', bestN = 0;
for (const l in counts) if (counts[l] > bestN) { bestN = counts[l]; best = l; }
return bestN >= 3 ? best : 'English';
// '' on weak signal, matching this function's own documented contract
// above — NOT 'English'. Every caller already does `detectLang(x) || ''`
// expecting a falsy result here to mean "couldn't tell", so silently
// returning 'English' instead defeated that: _resolveBookLang
// (library-characters.js) treats any truthy result as a confident,
// final answer and never falls through to its own book-majority-vote
// fallback — confirmed live as the actual cause of sparse/minor
// characters in an all-German book (e.g. "Junker", "Kroah") getting
// designed as English voices, even though the book-majority fallback
// that exists specifically to prevent this was right there unused.
return bestN >= 3 ? best : '';
}
window.detectLang = detectLang;
// German umlauts/ß transliterated to their standard ASCII spelling (ä→ae,
// ö→oe, ü→ue, ß→ss) before any [^A-Za-z0-9]-style sanitizer strips them —
// every one of those sanitizers (voice IDs, filenames, tags) treats a
// non-ASCII letter as junk to collapse into an underscore rather than a
// real letter to keep, so "Torwächter" silently became "Torw_chter" and
// "Mädchen" became "M_dchen" (both later displayed as just the tail after
// the LAST underscore, e.g. "chter"/"dchen", since a display-name fallback
// takes the last `_`-separated segment of the id). This is also the exact
// transliteration the actual TTS engine's own directory-scan voice-naming
// already uses (confirmed live: it registers a voice literally as
// "...Hoerbuch..." for a reference file named with "ö"), so this keeps
// generated IDs consistent with what the engine itself expects.
function _umlautSafe(str) {
return String(str || '').replace(/[äöüÄÖÜß]/g, ch => ({
'ä':'ae', 'ö':'oe', 'ü':'ue', 'Ä':'Ae', 'Ö':'Oe', 'Ü':'Ue', 'ß':'ss',
}[ch]));
}
window._umlautSafe = _umlautSafe;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => switchTab(tab.dataset.tab)));
document.addEventListener('click', e => {
const btn = e.target.closest('.backend-jump');
@ -800,13 +944,28 @@ let _wfActive = 'source';
// just land you on the same Reader screen — merged into one "Source" stop
// (matches the "Source" label already used on the Reader's import card).
const WF_STEPS = [
{ key: 'source', label: 'Source', enabled: () => true },
{ key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length },
{ key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true },
{ key: 'castlib', label: 'Cast', enabled: () => !!window.readerState?.sentences?.length },
{ key: 'rehearser', label: 'Script Rehearser',enabled: () => !!window.rehState?.lines?.length },
{ key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length },
{ key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length },
{ key: 'source', label: 'Source', enabled: () => true,
hint: 'Import a PDF or text file and skim the extracted pages. Once the text looks right, move on.' },
{ key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length,
hint: 'Split the text into narration and dialogue and attribute each line to a speaker. Check the result and fix any misattributed lines before continuing.' },
{ key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true,
hint: 'Optional — let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip if you just want to cast voices quickly.' },
{ key: 'castlib', label: 'Cast', enabled: () => !!window.readerState?.sentences?.length,
hint: 'Review the cast list. Merge any duplicate characters and fix names before assigning voices.' },
{ key: 'voices', label: 'Assign Voices', enabled: () => !!window.readerState?.sentences?.length, optional: true,
hint: 'Every character needs a voice before audio can be generated — assign one manually or use "Auto assign". Come back here if a voice sounds wrong.' },
{ key: 'rehearser', label: 'Script Rehearser',
// Gating this on rehState.lines.length alone was circular: that's only
// ever populated by loading a script INTO the Rehearser, which is
// exactly what clicking this step does (workflowCrumbGo builds it from
// the live Audiobook segments) — so the button could never become
// enabled from a fresh session, permanently blocking the only path in.
enabled: () => !!(window.rehState?.lines?.length || window._audiobook?.segments?.length),
hint: 'Fine-tune the script line by line — attribution, emotion, and pacing. Fix problems here rather than after audio is generated.' },
{ key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length,
hint: 'Generate audio for every line. Wait for synthesis to finish and re-run any failed lines before merging into the final audiobook.' },
{ key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length,
hint: 'Merge the generated clips into the final audiobook file and download it. Go back to Generate MP3s if anything sounds off.' },
];
function workflowCrumbGo(key) {
@ -830,8 +989,32 @@ function workflowCrumbGo(key) {
if (typeof navLibraryView === 'function') navLibraryView('characters');
_wfActive = 'castlib';
refreshWorkflowCrumbs();
} else if (key === 'voices') {
// Same Character Library grid as "Cast" — voice/image assignment already
// lives there (per-character voice pick/auto, avatar upload, AI-generate
// image, and "Cast selected character roles" for a missing sheet). This
// step just scrolls straight to the CURRENT book's block instead of
// landing on the whole cross-book library unfocused.
window._libCharsScrollToBook = window.readerState?.title || window.rehState?.title || null;
if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters');
_wfActive = 'voices';
refreshWorkflowCrumbs();
} else if (key === 'rehearser') {
if (typeof navTo === 'function') navTo('s-rehearser');
// A bare navTo left the Stage empty (0/0 lines) whenever this step was
// reached by clicking the stepper/Next directly, since nothing actually
// parsed the current cast into rehState — only opening the Rehearser via
// its own "Rehearse" entry points did that. Build it from the live
// Audiobook segments (same cast/voices already assigned in Assign
// Voices) unless a script is already loaded, in which case leave it alone
// rather than re-parsing over in-progress Stage edits.
if (window.rehState?.lines?.length) {
if (typeof navTo === 'function') navTo('s-rehearser');
} else if (typeof audiobookOpenCurrentInRehearser === 'function' && (window._audiobook?.segments || []).length) {
audiobookOpenCurrentInRehearser();
} else {
if (typeof navTo === 'function') navTo('s-rehearser');
}
} else if (key === 'mp3') {
if (typeof navTo === 'function') navTo('s-rehearser');
if (typeof showPhase === 'function' && window.rehState?.lines?.length) showPhase(3);
@ -854,32 +1037,38 @@ function refreshWorkflowCrumbs(active) {
if (active) _wfActive = active;
const title = window.readerState?.title || window.rehState?.title || '';
const containers = document.querySelectorAll('.wf-stepper');
if (!containers.length) return;
// Hide the strip until some stage beyond the always-available "Source" is
// reachable — otherwise a fresh session shows a stepper with one live stop.
const anyState = WF_STEPS.some(st => st.key !== 'source' && st.enabled());
const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive);
if (!containers.length) { _wfUpdateHeaderNav(anyState, curIdx); return; }
containers.forEach(el => {
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
el.hidden = false;
const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive);
const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null;
const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null;
const parts = [];
parts.push(`<button type="button" class="wf-nav-btn wf-nav-prev" data-wf-nav="prev" title="${prevStep ? 'Back to ' + escHtml(prevStep.label) : 'No previous step'}"${prevStep ? '' : ' disabled'}><span class="mdi mdi-chevron-left"></span></button>`);
if (title) parts.push(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
// Two rows instead of one long strip: the book title on top, the
// numbered step sequence (now leading with the back-to-previous-step
// nudge, moved down from the title row to sit with the rest of the step
// navigation) below — the single-row version forced the steps into a
// horizontally-scrolling sliver that hid most of them behind a scrollbar.
const row1 = [];
if (title) row1.push(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
const row2 = [];
row2.push(`<button type="button" class="wf-nav-btn wf-nav-prev" data-wf-nav="prev" title="${prevStep ? 'Back to ' + escHtml(prevStep.label) : 'No previous step'}"${prevStep ? '' : ' disabled'}><span class="mdi mdi-chevron-left"></span></button>`);
WF_STEPS.forEach((st, i) => {
const enabled = st.enabled();
const cur = _wfActive === st.key;
const done = curIdx >= 0 && i < curIdx && enabled;
if (i > 0) parts.push(`<span class="wf-step-arrow${done ? ' is-done' : ''}"></span>`);
parts.push(
if (i > 0) row2.push(`<span class="wf-step-arrow${done ? ' is-done' : ''}"></span>`);
row2.push(
`<button type="button" class="wf-step${cur ? ' is-current' : ''}${done ? ' is-done' : ''}${st.optional ? ' is-optional' : ''}" ` +
`data-wf="${st.key}"${(!enabled || cur) ? ' disabled' : ''} title="${st.optional ? 'Optional — ' : ''}${escHtml(st.label)}">` +
`<span class="wf-step-num">${done ? '<span class="mdi mdi-check"></span>' : i + 1}</span><span>${escHtml(st.label)}${st.optional ? '<small>optional</small>' : ''}</span></button>`
);
});
parts.push(`<button type="button" class="wf-nav-btn wf-nav-next" data-wf-nav="next" title="${nextStep ? 'On to ' + escHtml(nextStep.label) : 'No next step'}"${nextStep ? '' : ' disabled'}><span class="mdi mdi-chevron-right"></span></button>`);
el.innerHTML = parts.join('');
row2.push(`<button type="button" class="wf-nav-btn wf-nav-next" data-wf-nav="next" title="${nextStep ? 'On to ' + escHtml(nextStep.label) : 'No next step'}"${nextStep ? '' : ' disabled'}><span class="mdi mdi-chevron-right"></span></button>`);
el.innerHTML = `<div class="wf-stepper-row1">${row1.join('')}</div><div class="wf-stepper-row2">${row2.join('')}</div>`;
el.querySelectorAll('.wf-step[data-wf]').forEach(btn => {
btn.addEventListener('click', () => workflowCrumbGo(btn.dataset.wf));
});
@ -888,9 +1077,76 @@ function refreshWorkflowCrumbs(active) {
const nextBtn = el.querySelector('.wf-nav-next');
if (nextBtn && !nextBtn.disabled) nextBtn.addEventListener('click', () => workflowCrumbGo(nextStep.key));
});
_wfUpdateHeaderNav(anyState, curIdx);
}
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
// Previous/Next workflow buttons on every page's own section header (not
// just the stepper widget further down) — lets you step through the
// pipeline without scrolling to find the stepper first. Injected once per
// .section-head (every section has one) and just updated in place after
// that, since section HTML is loaded once and never rebuilt.
function _wfUpdateHeaderNav(anyState, curIdx) {
const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null;
const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null;
const curStep = curIdx >= 0 ? WF_STEPS[curIdx] : null;
document.querySelectorAll('.section-head').forEach(head => {
// Studio has its own self-contained 4-phase nav (studio.js) — this
// stepper's Previous/Next buttons are hardcoded to the old s-reader/
// s-library/s-rehearser sections and don't know about Studio's phases,
// so injecting them here just breaks navigation when clicked.
if (head.closest('#s-caststudio')) {
const staleNav = head.querySelector('.wf-header-nav');
if (staleNav) staleNav.hidden = true;
const staleHint = head.nextElementSibling?.classList?.contains('wf-step-hint') ? head.nextElementSibling : null;
if (staleHint) staleHint.hidden = true;
return;
}
let nav = head.querySelector('.wf-header-nav');
// A slim guidance bar right under the header — what to actually do on
// THIS step and when it's safe to move on/back — since the generic
// "Previous"/"Next" buttons alone gave no clue what either direction
// actually did or when a step was "done".
let hintBar = head.nextElementSibling?.classList?.contains('wf-step-hint') ? head.nextElementSibling : null;
if (!anyState) { if (nav) nav.hidden = true; if (hintBar) hintBar.hidden = true; return; }
if (!nav) {
nav = document.createElement('div');
nav.className = 'wf-header-nav';
nav.innerHTML =
`<button type="button" class="wf-header-nav-btn wf-header-nav-prev"><span class="mdi mdi-chevron-left"></span><span class="wf-header-nav-label">Previous</span></button>` +
`<button type="button" class="wf-header-nav-btn wf-header-nav-next"><span class="wf-header-nav-label">Next</span><span class="mdi mdi-chevron-right"></span></button>`;
head.appendChild(nav);
nav.querySelector('.wf-header-nav-prev').addEventListener('click', () => { if (nav.dataset.prevKey) workflowCrumbGo(nav.dataset.prevKey); });
nav.querySelector('.wf-header-nav-next').addEventListener('click', () => { if (nav.dataset.nextKey) workflowCrumbGo(nav.dataset.nextKey); });
}
if (!hintBar) {
hintBar = document.createElement('div');
hintBar.className = 'wf-step-hint';
head.insertAdjacentElement('afterend', hintBar);
}
nav.hidden = false;
const prevBtn = nav.querySelector('.wf-header-nav-prev');
const nextBtn = nav.querySelector('.wf-header-nav-next');
nav.dataset.prevKey = prevStep ? prevStep.key : '';
nav.dataset.nextKey = nextStep ? nextStep.key : '';
prevBtn.disabled = !prevStep;
prevBtn.title = prevStep ? 'Go back to fix something on ' + prevStep.label : 'No previous step';
prevBtn.querySelector('.wf-header-nav-label').textContent = prevStep ? prevStep.label : 'Previous';
nextBtn.disabled = !nextStep;
nextBtn.title = nextStep ? 'Continue to ' + nextStep.label : 'No next step';
nextBtn.querySelector('.wf-header-nav-label').textContent = nextStep ? nextStep.label : 'Next';
if (curStep?.hint) {
hintBar.hidden = false;
hintBar.innerHTML =
`<span class="wf-step-hint-badge">Step ${curIdx + 1}/${WF_STEPS.length}${curStep.optional ? ' · optional' : ''}</span>` +
`<span class="wf-step-hint-text">${escHtml(curStep.hint)}</span>`;
} else {
hintBar.hidden = true;
}
});
}
// Rehearser's own phase tabs (Library/Cast/Stage/Summary) map onto the last
// four stepper stops — keep the stepper's "current" highlight following them.
window.onRehearserPhaseChange = function (n) {

View File

@ -736,7 +736,8 @@ function voiceDesignPayload(instruct, sampleText, language, source = null, gende
let _dVoiceIdManual = false;
function designSafeName(name) {
return String(name || 'VoiceDesign')
const base = name || 'VoiceDesign';
return (typeof _umlautSafe === 'function' ? _umlautSafe(base) : String(base))
.replace(/^[A-Z]{2}_[FMN]_/, '')
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
@ -744,7 +745,7 @@ function designSafeName(name) {
}
function voiceIdSafePart(value, fallback = 'style') {
return String(value || fallback)
return (typeof _umlautSafe === 'function' ? _umlautSafe(value || fallback) : String(value || fallback))
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 32) || fallback;

View File

@ -1293,7 +1293,7 @@ function setLibAddStatus(msg) {
}
function suggestLibVoiceId(filename) {
if ($('lib-add-voice-id').value.trim()) return;
const base = String(filename || 'NewVoice')
const base = (typeof _umlautSafe === 'function' ? _umlautSafe(filename || 'NewVoice') : String(filename || 'NewVoice'))
.replace(/\.[^.]+$/, '')
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/^_+|_+$/g, '')
@ -3067,10 +3067,21 @@ function makeVoiceRow(v) {
mergeBenchmarkResults(d);
const hit = (d.voices || []).find(x => x.voice_id === v.id);
if (hit && hit.benchmark) v.benchmark = hit.benchmark;
const benchCell = wrap.querySelector('.vr-bench');
benchCell.className = 'vr-bench ' + benchmarkClass(v);
benchCell.title = benchmarkTitle(v);
benchCell.querySelector('.vr-bench-value').textContent = fmtBenchmark(v);
// While this voice is open in the inspector, voice-inspector.js's
// selectVoice() physically moves .vr-main-row (which contains
// .vr-bench) out of `wrap` and into #voices-inspector — confirmed
// live as the cause of "Cannot set properties of null (setting
// 'className')" when rebenchmarking from the single-voice detail
// view, since `wrap.querySelector('.vr-bench')` then finds nothing.
// Fall back to the inspector's own copy, and skip the UI update
// entirely (rather than crash) if neither has it.
const benchCell = wrap.querySelector('.vr-bench') || document.querySelector('#voices-inspector .vr-bench');
if (benchCell) {
benchCell.className = 'vr-bench ' + benchmarkClass(v);
benchCell.title = benchmarkTitle(v);
const benchValue = benchCell.querySelector('.vr-bench-value');
if (benchValue) benchValue.textContent = fmtBenchmark(v);
}
// Also update the table row cells
const tblRow = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (tblRow) {
@ -3134,7 +3145,18 @@ function makeVoiceRow(v) {
? (v.transcript?.trim() || benchmarkSampleText())
: benchmarkSampleText();
const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text';
const backend = libraryTtsBackend();
// A designed voice has no reference WAV and can't play through the
// voice_clone engine at all — the global "Library TTS backend"
// selector (libraryTtsBackend()) is a deliberate override for bulk
// actions (benchmark/precompute against one chosen engine on
// purpose), but blindly applying it here meant a single click on a
// designed voice's own row failed outright unless the user had
// separately remembered to flip that selector to Voice Design first
// — confirmed live: with the selector left on its voice_clone
// default, every designed voice's preview failed with a voice_clone
// connection error even though Voice Design itself was reachable.
// Always honor what this specific voice actually needs.
const backend = isClone ? libraryTtsBackend() : 'voice_design';
const source = await createTtsAudioSource(v.id, text, backend, 'settings', '');
audio.src = source.url;
if (!source.streaming) _activePlayUrl = source.url;
@ -3334,6 +3356,12 @@ renderVoiceList = function() {
_bulkInjectCheckboxes();
};
// Shift-click range select: remembers the last checkbox actually clicked
// (not just toggled programmatically) so a shift-click on a later row can
// fill in everything between them — the same convention as a file manager
// or spreadsheet, requested explicitly since checking 74 individual rows
// one at a time to bulk-act on a whole book's cast was the alternative.
let _bulkLastClickedId = null;
function _bulkInjectCheckboxes() {
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
if (row.querySelector('.vl-bulk-cb')) return; // already injected
@ -3343,9 +3371,27 @@ function _bulkInjectCheckboxes() {
cb.className = 'vl-bulk-cb';
cb.dataset.id = id;
cb.checked = _bulkSelected.has(id);
cb.title = 'Select for bulk edit';
cb.addEventListener('change', e => { e.stopPropagation(); _bulkToggle(id, cb.checked); });
cb.addEventListener('click', e => e.stopPropagation());
cb.title = 'Select for bulk edit (shift-click to select a range)';
cb.addEventListener('click', e => {
e.stopPropagation();
// cb.checked already reflects the NEW state by the time 'click' fires.
if (e.shiftKey && _bulkLastClickedId) {
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
const ids = rows.map(r => r.dataset.id);
const from = ids.indexOf(_bulkLastClickedId);
const to = ids.indexOf(id);
if (from !== -1 && to !== -1) {
const [lo, hi] = from < to ? [from, to] : [to, from];
for (let i = lo; i <= hi; i++) {
if (cb.checked) _bulkSelected.add(ids[i]); else _bulkSelected.delete(ids[i]);
}
}
} else {
_bulkToggle(id, cb.checked);
}
_bulkLastClickedId = id;
_bulkUpdate();
});
// Prepend into vl-compact
const compact = row.querySelector('.vl-compact');
if (compact) compact.prepend(cb);

View File

@ -16,7 +16,7 @@
const SECTIONS = [
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings',
's-llms', 's-conversation', 's-library',
];
@ -150,6 +150,7 @@
'/static/js/sillytavern.js',
'/static/js/library.js',
'/static/js/library-characters.js',
'/static/js/studio.js',
];
var _bundleOk = false;
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }

View File

@ -18,7 +18,7 @@
llms: 's-llms'
};
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library'];
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library'];
function storedSection() {
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
@ -82,6 +82,7 @@
{ tree: 'nav-tags-tree', chevron: 'nav-tags-chevron' },
{ tree: 'nav-actions-tree', chevron: 'nav-actions-chevron' },
{ tree: 'nav-speak-tree', chevron: 'nav-speak-chevron' },
{ tree: 'nav-caststudio-tree', chevron: 'nav-caststudio-chevron', section: 's-caststudio' },
{ tree: 'nav-reader-tree', chevron: 'nav-reader-chevron', section: 's-reader' },
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
{ tree: 'nav-library-tree', chevron: 'nav-library-chevron', section: 's-library' },
@ -143,6 +144,12 @@
function showSection(sectionId) {
if (!SECTIONS.includes(sectionId)) sectionId = 's-voices';
// Studio borrows DOM subtrees that natively live in s-reader/s-library/
// s-rehearser (see studio.js) — return them BEFORE any other section's
// own onShow logic below runs, so e.g. entering s-reader directly right
// after leaving Studio finds #reader-audiobook-panel back in its normal
// place instead of still parked inside Studio's slot.
if (sectionId !== 's-caststudio' && typeof window._stuReturnAll === 'function') window._stuReturnAll();
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop();
// Capture before readerOnShow (async) eventually consumes it: a caller that
@ -162,6 +169,7 @@
}
}
if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books');
if (sectionId === 's-caststudio' && typeof window.studioOnShow === 'function') window.studioOnShow();
if (typeof window.initCollapsibleCards === 'function') window.initCollapsibleCards();
setStoredSection(sectionId);
setSectionHash(sectionId);

View File

@ -0,0 +1,76 @@
<div class="section-head">
<span class="section-icon"><span class="mdi mdi-movie-open-play"></span></span>
<div class="section-title">
<h2>Studio</h2>
<p>Source, cast, voice, and perform a whole audiobook or script in one place.</p>
</div>
<span style="flex:1"></span>
<!-- Studio's own Previous/Next — deliberately its OWN class, not a
reuse of .wf-header-nav: that cross-section widget is actively
hidden inside #s-caststudio (utils.js _wfUpdateHeaderNav) since
it only knows about the old s-reader/s-library/s-rehearser
sections, not these 4 phases — reusing its class here would have
gotten this hidden by that exact same suppression. Without this,
the only way to move between phases was clicking a tab directly
— no visible "what's next" affordance, confirmed as a real gap
live. -->
<div class="stu-phase-nav" id="stu-phase-nav">
<button type="button" class="stu-phase-nav-btn" id="stu-phase-prev"><span class="mdi mdi-chevron-left"></span><span>Previous</span></button>
<button type="button" class="stu-phase-nav-btn" id="stu-phase-next"><span>Next</span><span class="mdi mdi-chevron-right"></span></button>
</div>
</div>
<!-- Phase tabs — a self-contained system deliberately separate from both
the cross-section .wf-stepper and Script Rehearser's own .reh-subtab
system; see the Studio build plan for why. Numbered so the tab bar
itself reads as an ordered flow, not just 4 unrelated sections. -->
<div class="stu-subtabs" id="stu-subtabs">
<button class="stu-subtab active" data-stu-phase="1"><span class="stu-subtab-num">1</span> <span class="mdi mdi-file-document-outline"></span> <span>Source</span></button>
<button class="stu-subtab" data-stu-phase="2"><span class="stu-subtab-num">2</span> <span class="mdi mdi-drama-masks"></span> <span>Characters</span></button>
<button class="stu-subtab" data-stu-phase="3"><span class="stu-subtab-num">3</span> <span class="mdi mdi-account-voice"></span> <span>Voices</span></button>
<button class="stu-subtab" data-stu-phase="4"><span class="stu-subtab-num">4</span> <span class="mdi mdi-theater"></span> <span>Perform &amp; Export</span></button>
</div>
<div class="tab-content" id="tab-caststudio">
<!-- ── Phase 1: Source ─────────────────────────────────────────── -->
<div class="stu-phase" id="stu-phase-1">
<div id="stu-source-slot"></div>
<div class="card" style="margin-top:16px">
<h2><span class="mdi mdi-bookshelf"></span> Already uploaded</h2>
<p class="card-subtitle" style="margin:0 0 12px">Pick a book you've imported before instead of pasting/uploading it again.</p>
<div id="stu-books-slot"></div>
</div>
</div>
<!-- ── Phase 2: Characters ─────────────────────────────────────── -->
<div class="stu-phase" id="stu-phase-2" hidden>
<div class="stu-inner-tabs" id="stu-cast-inner-tabs">
<button class="stu-inner-tab active" data-stu-cast-view="identify">Identify Characters</button>
<button class="stu-inner-tab" data-stu-cast-view="sheets">Cast Characters <small style="opacity:.6;font-weight:400">(optional)</small></button>
</div>
<div id="stu-cast-slot"></div>
<div id="stu-castchars-slot" hidden></div>
</div>
<!-- ── Phase 3: Voices ─────────────────────────────────────────── -->
<div class="stu-phase" id="stu-phase-3" hidden>
<div id="stu-voices-slot"></div>
</div>
<!-- ── Phase 4: Perform & Export ───────────────────────────────── -->
<div class="stu-phase" id="stu-phase-4" hidden>
<label class="stu-mode-toggle">
<input type="checkbox" id="stu-mode-audiobook">
<span><span class="mdi mdi-headphones"></span> Generate full audiobook (voice the Narrator too)</span>
</label>
<!-- "I play this" lives on the per-character cast cards, not the
Stage line view — only relevant in Rehearse mode. -->
<details class="card" id="stu-mecast-details" style="margin-bottom:14px">
<summary style="cursor:pointer;font-weight:700;padding:4px 0"><span class="mdi mdi-microphone"></span> Who's playing which character?</summary>
<div id="stu-mecast-slot"></div>
</details>
<div id="stu-stage-slot"></div>
</div>
</div>

View File

@ -36,6 +36,13 @@
<span class="llm-free-badge llm-free-local">100% Local</span>
</div>
</div>
<div class="engines-head-context header-cat-block" data-engines-cat="image">
<div class="engines-context-copy">
<h3 class="llm-sec-title">Image Generation</h3>
<p class="llm-sec-note">Used to generate character profile pictures. Pick the active provider below and add its API key.</p>
</div>
</div>
</div>
<!-- ── Language Models sub-page ──────────────────────────── -->
@ -103,6 +110,10 @@
<span class="llm-model-tag">qwen-qwq-32b</span>
<span class="llm-model-tag">deepseek-r1-distill-llama-70b</span>
</div>
<div class="dc-controls-row" style="margin-top:10px">
<button class="llm-local-ping llm-cloud-connect-btn" data-llm-key="groq_llm" data-llm-endpoint="https://api.groq.com/openai/v1"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-use-btn llm-cloud-use-btn" data-llm-key="groq_llm" data-llm-endpoint="https://api.groq.com/openai/v1" data-llm-model="llama-3.3-70b-versatile" style="margin-left:auto"><span class="mdi mdi-arrow-right-circle-outline"></span> Use as LLM</button>
</div>
</div>
<div class="llm-card">
@ -133,6 +144,10 @@
<span class="llm-model-tag">deepseek/deepseek-r1-0528:free</span>
<span class="llm-model-tag">mistralai/mistral-7b-instruct:free</span>
</div>
<div class="dc-controls-row" style="margin-top:10px">
<button class="llm-local-ping llm-cloud-connect-btn" data-llm-key="openrouter" data-llm-endpoint="https://openrouter.ai/api/v1"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-use-btn llm-cloud-use-btn" data-llm-key="openrouter" data-llm-endpoint="https://openrouter.ai/api/v1" data-llm-model="qwen/qwen3-235b-a22b:free" style="margin-left:auto"><span class="mdi mdi-arrow-right-circle-outline"></span> Use as LLM</button>
</div>
</div>
<div class="llm-card">
@ -159,6 +174,10 @@
<code>https://generativelanguage.googleapis.com/v1beta/openai/</code>
</div>
<div class="llm-info-note">Uses OpenAI-compat wrapper &mdash; use model <code>gemini-2.5-flash</code></div>
<div class="dc-controls-row" style="margin-top:10px">
<button class="llm-local-ping llm-cloud-connect-btn" data-llm-key="gemini" data-llm-endpoint="https://generativelanguage.googleapis.com/v1beta/openai/"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-use-btn llm-cloud-use-btn" data-llm-key="gemini" data-llm-endpoint="https://generativelanguage.googleapis.com/v1beta/openai/" data-llm-model="gemini-2.5-flash" style="margin-left:auto"><span class="mdi mdi-arrow-right-circle-outline"></span> Use as LLM</button>
</div>
</div>
<div class="llm-card">
@ -188,6 +207,64 @@
<span class="llm-model-tag">mistral-small-latest</span>
<span class="llm-model-tag">mistral-large-latest</span>
</div>
<div class="dc-controls-row" style="margin-top:10px">
<button class="llm-local-ping llm-cloud-connect-btn" data-llm-key="mistral" data-llm-endpoint="https://api.mistral.ai/v1"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-use-btn llm-cloud-use-btn" data-llm-key="mistral" data-llm-endpoint="https://api.mistral.ai/v1" data-llm-model="mistral-small-latest" style="margin-left:auto"><span class="mdi mdi-arrow-right-circle-outline"></span> Use as LLM</button>
</div>
</div>
<div class="llm-card">
<div class="llm-card-head">
<span class="llm-card-icon"><span class="mdi mdi-robot-outline"></span></span>
<div>
<div class="llm-card-name">OpenAI</div>
<div class="llm-card-sub">GPT models &middot; pay-as-you-go</div>
</div>
<span class="llm-tier-badge llm-tier-paid">Paid</span>
</div>
<div class="llm-field-row">
<label class="llm-label">API key</label>
<input type="password" class="llm-input" placeholder="sk-&hellip;" data-llm-key="openai_llm">
<a class="llm-link" href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">Get key <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-endpoint">
<span class="llm-endpoint-label">Endpoint</span>
<code>https://api.openai.com/v1</code>
</div>
<div class="llm-models">
<span class="llm-model-tag">gpt-4o</span>
<span class="llm-model-tag">gpt-4o-mini</span>
<span class="llm-model-tag">o3-mini</span>
</div>
<div class="dc-controls-row" style="margin-top:10px">
<button class="llm-local-ping llm-cloud-connect-btn" data-llm-key="openai_llm" data-llm-endpoint="https://api.openai.com/v1"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-use-btn llm-cloud-use-btn" data-llm-key="openai_llm" data-llm-endpoint="https://api.openai.com/v1" data-llm-model="gpt-4o-mini" style="margin-left:auto"><span class="mdi mdi-arrow-right-circle-outline"></span> Use as LLM</button>
</div>
</div>
<div class="llm-card">
<div class="llm-card-head">
<span class="llm-card-icon"><span class="mdi mdi-triangle-outline"></span></span>
<div>
<div class="llm-card-name">Anthropic</div>
<div class="llm-card-sub">Claude models &middot; pay-as-you-go</div>
</div>
<span class="llm-tier-badge llm-tier-paid">Paid</span>
</div>
<div class="llm-field-row">
<label class="llm-label">API key</label>
<input type="password" class="llm-input" placeholder="sk-ant-&hellip;" data-llm-key="anthropic_llm">
<a class="llm-link" href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">Get key <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-endpoint">
<span class="llm-endpoint-label">Endpoint</span>
<code>https://api.anthropic.com/v1</code>
</div>
<div class="llm-info-note">Not OpenAI-compatible &mdash; needs the Anthropic Messages API, not <code>/chat/completions</code></div>
<div class="dc-controls-row" style="margin-top:10px">
<button class="llm-local-ping llm-cloud-connect-btn" data-llm-key="anthropic_llm" data-llm-endpoint="https://api.anthropic.com/v1" data-llm-probe-type="anthropic"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-use-btn llm-cloud-use-btn" data-llm-key="anthropic_llm" data-llm-endpoint="https://api.anthropic.com/v1" data-llm-model="claude-3-5-haiku-latest" data-llm-incompatible="1" style="margin-left:auto"><span class="mdi mdi-arrow-right-circle-outline"></span> Use as LLM</button>
</div>
</div>
</div>
@ -568,6 +645,156 @@
</div><!-- /tts -->
<!-- ── Image Generation sub-page ─────────────────────────── -->
<div class="s-engines-page" data-page="image">
<div class="llm-active-panel card">
<div class="llm-active-head">
<span class="llm-active-icon"><span class="mdi mdi-star-four-points"></span></span>
<div>
<h4 class="llm-active-title">Active Image Generation Provider</h4>
<p class="llm-active-sub">Used for the "Generate Image" action on a character's profile picture.</p>
</div>
</div>
<div class="llm-active-controls">
<label class="llm-active-lbl"><span class="mdi mdi-cloud-outline"></span> Provider</label>
<select id="image-gen-provider">
<option value="">— None selected —</option>
<option value="openai">OpenAI (gpt-image-1)</option>
<option value="google">Google (Gemini / Imagen)</option>
<option value="openrouter">OpenRouter (image-capable models)</option>
<option value="pollinations">Pollinations.ai (free, no key)</option>
<option value="comfyui">Local ComfyUI</option>
</select>
<label class="llm-active-lbl"><span class="mdi mdi-image-outline"></span> Model</label>
<select id="image-gen-model-select" style="max-width:240px"></select>
<input type="text" id="image-gen-model" placeholder="Custom model ID" style="max-width:200px;display:none">
<span id="image-gen-status" class="llm-active-status"></span>
</div>
<p class="llm-active-sub" style="margin-top:8px">OpenRouter reuses the same API key as its LLM card below &mdash; no separate key needed here.</p>
</div>
<div class="card" id="comfyui-config-panel" hidden style="margin-top:16px">
<div class="s-page-head">
<h3><span class="mdi mdi-graph-outline"></span> Local ComfyUI Workflow</h3>
<p>Runs a workflow on your own GPU instead of a paid API. Export it from ComfyUI's own UI first &mdash; Workflow menu &rarr; Export (API Format) &mdash; then paste the result below. The same workflow is used for both single portraits and character concept sheets; only the prompt text sent to it differs.</p>
</div>
<div style="display:flex; flex-direction:column; gap:14px">
<div class="s-field">
<label>ComfyUI URL</label>
<input type="text" id="comfyui-url" placeholder="http://host.docker.internal:8188">
<span class="s-hint">Base URL of your running ComfyUI instance (container port 8188 by default).</span>
</div>
<div class="s-field">
<label>Workflow JSON (API format)</label>
<textarea id="comfyui-workflow" rows="6" spellcheck="false" placeholder='{"3": {"class_type": "KSampler", "inputs": {...}}, "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", ...}}, ...}' style="font-family:monospace; font-size:12px"></textarea>
<span class="s-hint">Paste the raw JSON exported from ComfyUI's "Export (API Format)" action — not a regular saved workflow (that format has "nodes"/"links" keys and won't work here).</span>
</div>
<div style="display:flex; gap:14px; flex-wrap:wrap">
<div class="s-field" style="flex:1; min-width:160px">
<label>Prompt node ID</label>
<input type="text" id="comfyui-prompt-node-id" placeholder="e.g. 6">
<span class="s-hint">The node whose text input gets replaced with the character prompt each run.</span>
</div>
<div class="s-field" style="flex:1; min-width:160px">
<label>Prompt input field name</label>
<input type="text" id="comfyui-prompt-field" placeholder="text">
<span class="s-hint">Usually "text" (CLIPTextEncode) — check the node's "inputs" key in the JSON above.</span>
</div>
<div class="s-field" style="flex:1; min-width:160px">
<label>Output node ID</label>
<input type="text" id="comfyui-output-node-id" placeholder="e.g. 22">
<span class="s-hint">The SaveImage/PreviewImage node whose result to fetch.</span>
</div>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap">
<button type="button" class="btn-secondary" id="comfyui-test-btn"><span class="mdi mdi-lan-connect"></span> Test connection</button>
<button type="button" class="btn-secondary" id="comfyui-test-gen-btn"><span class="mdi mdi-image-outline"></span> Test generate</button>
<span id="comfyui-test-status" class="llm-active-status"></span>
</div>
<div id="comfyui-test-preview" hidden><img id="comfyui-test-img" style="max-width:220px; border-radius:8px; border:1px solid var(--border)"></div>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:28px;margin-bottom:12px">
<span class="mdi mdi-cloud-outline"></span>
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Cloud APIs</h4>
</div>
<div class="llm-service-grid">
<div class="llm-card llm-card-highlight">
<div class="llm-card-head">
<span class="llm-card-icon"><span class="mdi mdi-robot-outline"></span></span>
<div>
<div class="llm-card-name">OpenAI</div>
<div class="llm-card-sub">gpt-image-1 &middot; Images API</div>
</div>
</div>
<div class="llm-field-row">
<label class="llm-label">API key</label>
<input type="password" class="llm-input" placeholder="sk-&hellip;" data-llm-key="openai_image">
<a class="llm-link" href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">Get key <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-endpoint">
<span class="llm-endpoint-label">Endpoint</span>
<code>https://api.openai.com/v1/images/generations</code>
</div>
</div>
<div class="llm-card">
<div class="llm-card-head">
<span class="llm-card-icon"><span class="mdi mdi-google"></span></span>
<div>
<div class="llm-card-name">Google</div>
<div class="llm-card-sub">Gemini 2.5 Flash Image / Imagen</div>
</div>
</div>
<div class="llm-field-row">
<label class="llm-label">API key</label>
<input type="password" class="llm-input" placeholder="AIza&hellip;" data-llm-key="google_image">
<a class="llm-link" href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">Get key <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-endpoint">
<span class="llm-endpoint-label">Endpoint</span>
<code>generativelanguage.googleapis.com</code>
</div>
</div>
<div class="llm-card">
<div class="llm-card-head">
<span class="llm-card-icon"><span class="mdi mdi-flower-outline"></span></span>
<div>
<div class="llm-card-name">Pollinations.ai</div>
<div class="llm-card-sub">Free &middot; no API key &middot; no account</div>
</div>
<span class="llm-tier-badge llm-tier-free">Free</span>
</div>
<div class="llm-card-stats">
<span>No signup</span>
<span>Shared free queue</span>
<span>Third-party public service</span>
</div>
<div class="llm-endpoint">
<span class="llm-endpoint-label">Endpoint</span>
<code>image.pollinations.ai</code>
</div>
<div class="llm-models">
<span class="llm-model-tag">flux</span>
</div>
<div class="llm-info-note">No key or setup needed &mdash; just pick it as the Active Provider above. No SLA/rate-limit guarantee since it's a shared free public queue; retries automatically on "queue full" errors.</div>
</div>
</div>
<div class="llm-info-note" style="margin-top:16px;">Local ComfyUI (no API key, runs on your own GPU) is configured separately above, in the Active Image Generation Provider panel.</div>
<div class="s-page-actions" style="margin-top: 15px;">
<button class="btn-primary s-save-btn">Save settings</button>
<button class="btn-secondary s-reload-btn">Reload settings</button>
</div>
</div><!-- /image -->
<!-- ── Add Custom Engine dialog ─────────────────────────────── -->
<dialog id="add-engine-dlg" class="add-engine-dlg">
<div class="add-engine-form">

View File

@ -318,12 +318,18 @@
<button class="btn-secondary btn-sm" id="reh-tb-audiobook" title="Synthesise every line and download the audiobook as MP3 (one file per chapter)">
<span class="mdi mdi-book-music-outline"></span> Audiobook
</button>
<button class="btn-secondary btn-sm" id="reh-tb-browse-exports" title="Browse and re-download audiobook chapters already exported for this book, without re-exporting">
<span class="mdi mdi-folder-music-outline"></span> Exports
</button>
<button class="btn-secondary btn-sm" id="reh-charsheets-btn" title="Extract actor-facing RPG-style character sheets (with sources) for this script">
<span class="mdi mdi-account-details-outline"></span> Character sheets
</button>
<button class="btn-secondary btn-sm" id="reh-tb-resynth-stale" title="Re-synthesize only lines whose tone changed" hidden>
<span class="mdi mdi-refresh" style="color:var(--red)"></span> Re-synth stale
</button>
<button class="btn-secondary btn-sm" id="reh-tb-clean-cache" title="Delete cached audio files on disk for lines that no longer match this script (edited/removed since they were synthesized)">
<span class="mdi mdi-broom"></span> Clean cache
</button>
<label class="chunk-toggle-label" style="font-size:11px;gap:4px;white-space:nowrap" title="Skip scene headings &amp; action descriptions during playback">
<input type="checkbox" id="reh-skip-desc-toggle" checked>
<span>Skip desc.</span>
@ -390,12 +396,20 @@
</div>
</div>
<!-- Character sidebar — same look as Read Aloud's Casting sidebar -->
<!-- Character sidebar — same look as Read Aloud's Casting sidebar,
plus search/sort and real portraits pulled from the Library. -->
<div class="ab-cv-side" id="reh-cast-row">
<div class="ab-cv-side-head">
<span class="ab-cv-side-title" id="reh-cast-toggle-label">Characters</span>
<button class="ab-cv-side-collapse" id="reh-cast-toggle" type="button" title="Collapse to avatars" aria-expanded="true"><span class="mdi mdi-chevron-left"></span></button>
</div>
<div class="ab-cv-side-tools">
<input type="search" class="ab-cv-side-search" id="reh-cast-side-search" placeholder="Search characters…" autocomplete="off">
<select class="ab-cv-side-sort" id="reh-cast-side-sort" title="Sort characters by">
<option value="lines">Lines</option>
<option value="name">Name</option>
</select>
</div>
<div class="ab-cv-chars" id="reh-cast-strip"></div>
</div>
</div>

View File

@ -192,6 +192,32 @@
<button class="btn-secondary s-reload-btn">Reload settings</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="s-page-head">
<h3>External API Access</h3>
<p>The app's own key — required for anything calling its <code>/api/*</code> or <code>/mcp</code> routes from outside the browser (scripts, MCP clients, AI agents). The app's own UI never needs this; it's same-origin.</p>
</div>
<div class="settings-grid three">
<div class="s-field" style="grid-column: 1 / -1">
<label>API key</label>
<div class="s-key-row">
<input type="password" id="s-external-api-key" readonly placeholder="generating…" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-external-api-key"><span class="mdi mdi-eye-outline"></span></button>
<button type="button" class="btn-secondary btn-sm" id="s-external-api-key-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
<button type="button" class="btn-secondary btn-sm" id="s-external-api-key-regen"><span class="mdi mdi-refresh"></span> Regenerate</button>
</div>
<span class="s-hint">Pass as header <code>X-API-Key: &lt;key&gt;</code> (or <code>Authorization: Bearer &lt;key&gt;</code>). Regenerating instantly invalidates the old key.</span>
</div>
<label class="s-toggle-row" style="grid-column: 1 / -1">
<input type="checkbox" id="s-external-api-key-required">
<div>
<span class="s-toggle-title">Require this key for external callers</span>
<span class="s-hint">Off by default. When off, /api/* and /mcp are reachable by anyone who can reach this container — fine on a trusted local network, not for exposing beyond it. Turning this on does not affect the app's own UI.</span>
</div>
</label>
</div>
</div>
</div>
<!-- ── Backup ─────────────────────────────────────────────── -->

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,13 @@ self.addEventListener('fetch', (e) => {
if (url.pathname.startsWith('/api/')) return; // never cache the API
e.respondWith((async () => {
try {
const net = await fetch(req);
// cache:'reload' forces this inner fetch to bypass the HTTP cache and go
// to the network every time. Without it, a browser's Ctrl+Shift+R hard
// reload does NOT propagate its cache-bypass into fetch() calls made from
// inside a service worker's fetch handler — so this "network-first" SW
// could still hand back a stale cached response even right after a hard
// reload, which is exactly what made JS edits look like they never landed.
const net = await fetch(req, { cache: 'reload' });
// Cache only successful static shell assets for offline fallback
if (net && net.ok && (url.pathname === '/' || url.pathname.startsWith('/static/'))) {
const c = await caches.open(CACHE);