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:
parent
f6e5449eb6
commit
ea50267c30
1016
CHANGELOG.md
1016
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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", {})),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)})
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
@ -12,6 +13,7 @@ import time
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from typing import Optional
|
||||
@ -600,8 +602,9 @@ def _charsheets_prepare(data: dict) -> dict:
|
||||
f"The source text is in {language}.\n"
|
||||
f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n"
|
||||
f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, "
|
||||
f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, "
|
||||
f"conflict_style, win_condition, archetype, aliases, first_name, last_name, full_name, title.\n"
|
||||
f"capabilities, backstory, relationships, motivation, fears, mannerisms, communication_style, voice_pattern, "
|
||||
f"secret, conflict_style, win_condition, archetype, aliases, first_name, last_name, title, age_estimate, "
|
||||
f"race_species, languages, nationality_background, social_class, profession, reputation, religious_beliefs, notes.\n"
|
||||
f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n"
|
||||
f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n"
|
||||
f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n"
|
||||
@ -615,7 +618,20 @@ def _charsheets_prepare(data: dict) -> dict:
|
||||
"- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name "
|
||||
"and put only the proven alternate form in aliases/title/full_name.\n"
|
||||
"- Do NOT invent brand-new profiles in this pass. Only refine the already-casted roster; ignore places, institutions, and other non-person entities.\n"
|
||||
"- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n"
|
||||
"- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n"
|
||||
"- Before deciding a passage is about someone else: check whether the person could BE one of the targets "
|
||||
"under an alias, title, role, or nickname instead of their literal listed name — check both this passage's "
|
||||
"own context (action beats, who's being addressed, profession mentioned) AND the 'existing sheets so far' "
|
||||
"below, which may already record that alias for a target (e.g. a target's existing sheet says "
|
||||
"'aliases: der Schmied' — a passage about 'der Schmied' with no other name given IS that target, attribute "
|
||||
"it there, do not skip it just because the passage never says the target's literal name).\n"
|
||||
"- Only if, after that check, the passage is clearly about a DIFFERENT person who is NOT one of the listed "
|
||||
"targets and NOT an alias/role already recorded for one of them (a scene that doesn't actually involve any "
|
||||
"target), output NO sheet for this passage at all — an empty 'sheets' array is correct and expected. Never "
|
||||
"force unrelated content about someone else into a target's profile just because a target happens to be "
|
||||
"listed and you can't create a new one — but do not use this as an excuse to skip a passage that genuinely "
|
||||
"is about a target under an alias; skipping a real match is just as wrong as gluing a fact onto the wrong "
|
||||
"character.\n\n"
|
||||
) if target_mode and known else ""
|
||||
|
||||
# A user-editable prompt (client-side "Prompt" panel, mirroring the
|
||||
@ -642,14 +658,19 @@ def _charsheets_prepare(data: dict) -> dict:
|
||||
"dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.\n"
|
||||
"For each character output these fields:\n"
|
||||
"- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n"
|
||||
"- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n"
|
||||
"- first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty.\n"
|
||||
"- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain.\n"
|
||||
"- first_name, last_name: split the character identity when known. Leave unknown parts empty.\n"
|
||||
"- title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').\n"
|
||||
"- profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').\n"
|
||||
"- age_estimate: estimated age or age range, if inferable\n"
|
||||
"- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant\n"
|
||||
"- languages: spoken languages / dialects / tongues, comma-separated if multiple\n"
|
||||
"- nationality_background: homeland, culture, origin, or social background if known\n"
|
||||
"- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.)\n"
|
||||
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
|
||||
"- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.\n"
|
||||
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
|
||||
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
|
||||
"- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known.\n"
|
||||
"- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look\n"
|
||||
"- alignment: strict moral code + the one line they will never cross\n"
|
||||
"- moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous\n"
|
||||
"- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'\n"
|
||||
@ -662,28 +683,34 @@ def _charsheets_prepare(data: dict) -> dict:
|
||||
"- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)\n"
|
||||
"- fears: their deepest fears, phobias or dread\n"
|
||||
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
|
||||
"- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.\n"
|
||||
"- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n"
|
||||
"- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.\n"
|
||||
"- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. "
|
||||
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though the prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
|
||||
+ "Do NOT mention plot spoilers.\n"
|
||||
"- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n"
|
||||
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
|
||||
"- secret: dark secret or fatal flaw\n"
|
||||
"- conflict_style: fight, flight, or manipulate — how they act when cornered\n"
|
||||
"- win_condition: the specific event that would make them feel they have won\n"
|
||||
"- reputation: how other characters or society see them\n"
|
||||
"- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it\n"
|
||||
"- notes: brief catch-all notes for useful details that do not fit elsewhere\n"
|
||||
"- tier: 'main' or 'supporting'\n"
|
||||
"- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, "
|
||||
"a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). "
|
||||
"Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.\n"
|
||||
"IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). "
|
||||
"Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. "
|
||||
"Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.\n"
|
||||
"Examples: 'Marcus', 'the Executioner', and 'Bloodfang' may all refer to ONE profile if context shows they are the same person. "
|
||||
"Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title and keep one canonical name.\n"
|
||||
"Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. "
|
||||
"Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n"
|
||||
"Respond with STRICT JSON only:\n"
|
||||
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","archetype":"","gender":"","physical":"","clothing":"",'
|
||||
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"",'
|
||||
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
|
||||
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
|
||||
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
|
||||
'"inventory":[],"secret":"","conflict_style":"","win_condition":"",'
|
||||
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
|
||||
'"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"",'
|
||||
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
|
||||
)
|
||||
lang_reminder = (
|
||||
@ -773,9 +800,19 @@ def _charsheets_parse(raw: str) -> dict:
|
||||
arc = str(s.get("arc_direction") or "neutral").strip()
|
||||
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
|
||||
arc = "neutral"
|
||||
gender = str(s.get("gender") or "").strip().lower()
|
||||
if gender not in ("male", "female", "nonbinary"):
|
||||
gender = ""
|
||||
# The model answers in whatever language the source text is in (this
|
||||
# app is used heavily with German books), so a strict English-only
|
||||
# whitelist silently discarded valid answers like "weiblich"/"männlich"
|
||||
# instead of normalizing them — every non-German-speaking character's
|
||||
# gender was quietly wiped to blank.
|
||||
gender_raw = str(s.get("gender") or "").strip().lower()
|
||||
_GENDER_NORMALIZE = {
|
||||
"male": "male", "m": "male", "man": "male", "mann": "male", "männlich": "male",
|
||||
"female": "female", "f": "female", "woman": "female", "frau": "female", "weiblich": "female",
|
||||
"nonbinary": "nonbinary", "non-binary": "nonbinary", "n": "nonbinary",
|
||||
"nichtbinär": "nonbinary", "nicht-binär": "nonbinary", "divers": "nonbinary",
|
||||
}
|
||||
gender = _GENDER_NORMALIZE.get(gender_raw, "")
|
||||
s.update({
|
||||
"name": name, "aliases": aliases, "inventory": inv[:3],
|
||||
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
|
||||
@ -999,6 +1036,117 @@ async def character_deep_analysis(request: Request):
|
||||
return {"name": name, "analysis": analysis}
|
||||
|
||||
|
||||
def _silly_tavern_prompt_instruction(sheet: dict) -> str:
|
||||
"""Full SillyTavern character-card template (card + scenario + first
|
||||
message), adapted from a user-supplied prompt originally written for
|
||||
building a card from scratch via internet research (fandom/wikipedia).
|
||||
Here the character's profile is already fully known from the book, so
|
||||
the internet-research instructions are dropped and replaced with
|
||||
'use ONLY the profile provided' (already stated in the shared preamble
|
||||
this gets appended to) — everything else (exact field structure, the
|
||||
generic-but-deliberately-open Scenario block, the First Message rules)
|
||||
is kept close to the original since it's a well-tested format."""
|
||||
gender = str((sheet or {}).get("gender") or "").strip().lower()
|
||||
is_female = gender.startswith("f")
|
||||
is_male = gender.startswith("m")
|
||||
|
||||
if is_female:
|
||||
appearance = (
|
||||
'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/'
|
||||
'long (arms-length)/short (chin-length)], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
|
||||
'body: [PICK FROM:slim/curvy, PICK FROM:perfect figure/sensual/abs, PICK FROM:light skin/tanned skin/'
|
||||
'brown skin/green skin/blue skin/red skin], breasts: [SIZE, CUP, PICK FROM:big areolas/medium-sized '
|
||||
'areolas/small areolas, PICK FROM:cherry-tan nipples/cherry-pink nipples/honey-tan nipples/'
|
||||
'golden-brown nipples/dark-brown nipples], armpit hair: PICK FROM:shaved/natural, '
|
||||
'pubic hair: PICK FROM:shaved/natural, fingernails: PICK FROM:natural/painted (color), '
|
||||
'toenails: PICK FROM:natural/painted (color)'
|
||||
)
|
||||
outfits = (
|
||||
'{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
|
||||
'DESCRIBE SHOES (COLOR), lingerie: [lace bra (COLOR), lace thong (COLOR)]}\n'
|
||||
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
|
||||
'DESCRIBE SHOES (COLOR), lingerie: [lace bra (color), lace thong (color)]}\n'
|
||||
'{{"Sleeping Outfit"}}:{nightgown (COLOR), thong (COLOR), soft slippers (white)}\n'
|
||||
'{{"Running Outfit"}}:{sports bra (COLOR), leggings (COLOR), sports shoes (white), lingerie: thong (COLOR)}\n'
|
||||
'{{"Exercise Outfit"}}:{sports bra (COLOR), leggings (COLOR), bare feet, lingerie: lace thong (COLOR)}\n'
|
||||
'{{"Swimsuit"}}:{PICK FROM: bikini/one-piece (COLOR), DESCRIBE SHOES (COLOR)}'
|
||||
)
|
||||
elif is_male:
|
||||
appearance = (
|
||||
'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/'
|
||||
'long (arms-length)/short (chin-length)], facial hair: PICK FROM:beard/goatie/beard & moustache/'
|
||||
'moustache/clean-shaven, eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
|
||||
'body: [PICK FROM:slim/muscular/bulky/fat, PICK FROM:light skin/tanned skin/brown skin/green skin/'
|
||||
'blue skin/red skin], penis: [SIZE, LENGTH cm, PICK FROM:big balls/medium-sized balls/small balls, '
|
||||
'PICK FROM:circumcised/uncircumcised], armpit hair: PICK FROM:shaved/natural, '
|
||||
'pubic hair: PICK FROM:shaved/natural'
|
||||
)
|
||||
outfits = (
|
||||
'{{"Main Outfit"}}:{DESCRIBE TOP (color), DESCRIBE BOTTOM (color), DESCRIBE SHOES (COLOR), '
|
||||
'lingerie: DESCRIBE LINGERIE (COLOR)}\n'
|
||||
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
|
||||
'DESCRIBE SHOES (COLOR), lingerie: DESCRIBE LINGERIE (COLOR)}\n'
|
||||
'{{"Sleeping Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, soft slippers (white)}\n'
|
||||
'{{"Running Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, sports shoes (white), lingerie: DESCRIBE LINGERIE (COLOR)}\n'
|
||||
'{{"Exercise Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, bare feet, lingerie: DESCRIBE LINGERIE (COLOR)}\n'
|
||||
'{{"Swimsuit"}}:{DESCRIBE BOTTOM, DESCRIBE SHOES (COLOR)}'
|
||||
)
|
||||
else:
|
||||
# Gender unknown/non-binary/narrator role — keep the same card
|
||||
# shape but skip the anatomy-specific appearance fields entirely
|
||||
# rather than guessing a binary that doesn't fit.
|
||||
appearance = (
|
||||
'hair: [COLOR, STYLE, LENGTH], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
|
||||
'body: [BUILD, SKIN TONE], distinguishing features: DESCRIBE'
|
||||
)
|
||||
outfits = (
|
||||
'{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n'
|
||||
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n'
|
||||
'{{"Sleeping Outfit"}}:{DESCRIBE SLEEPWEAR, soft slippers (white)}'
|
||||
)
|
||||
|
||||
return (
|
||||
"Produce exactly this field:\n"
|
||||
"- silly_tavern_prompt: a complete SillyTavern character card for this character, in the EXACT format "
|
||||
"below — a card body, then a Scenario block, then a First Message. Fill every field from the character "
|
||||
"profile above; only invent a value when the profile truly has nothing for it, and mark anything invented "
|
||||
"with a trailing '*'. Do not add bullet points, extra spaces, or commentary — follow the formatting "
|
||||
"exactly. Do not replace '{{char}}' with the character's actual name — keep it literal. Keep every '{', "
|
||||
"'}', '[', ']', '(', ')' character exactly as shown.\n\n"
|
||||
"{{char}}:\n"
|
||||
"{\n"
|
||||
'{{"Personal Information"}}:{name: NAME, surname: SURNAME, race: PICK FROM PROFILE OR INFER, '
|
||||
"nationality: NATIONALITY, gender: GENDER, age: AGE, profession: PROFESSION, "
|
||||
"residence: [PLACE, TYPE OF DWELLING], marital status: MARITAL STATUS}\n\n"
|
||||
f'{{{{"Appearance"}}}}:{{{appearance}}}\n\n'
|
||||
'{{"Personality"}}:{A DETAILED, SPECIFIC DESCRIPTION OF THIS CHARACTER\'S OWN PERSONALITY, SPEECH PATTERN '
|
||||
"AND QUIRKS FROM THE PROFILE ABOVE — NOT A GENERIC PERSONALITY TYPE. BE SPECIFIC TO THIS CHARACTER.}\n\n"
|
||||
'{{"Likes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
|
||||
'{{"Dislikes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
|
||||
'{{"Goals"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
|
||||
'{{"Skills"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
|
||||
'{{"Weapons"}}:{LIST ONLY IF THIS CHARACTER PLAUSIBLY CARRIES ONE PER THE PROFILE — OMIT THIS FIELD '
|
||||
"ENTIRELY OTHERWISE}\n\n"
|
||||
f"{outfits}\n"
|
||||
"}\n\n"
|
||||
"Then, in the SAME string, add a scenario block as clear instructions/definitions for the LLM, not "
|
||||
"narration — {{char}}'s relationship with {{user}}, everyday routine, current mood, current plans. Keep "
|
||||
"it open-ended (many different stories could start from it) rather than building one specific scene. "
|
||||
"Use this exact structure:\n\n"
|
||||
'{{"Scenario"}}:{"{{char}} is living everyday life","{{char}} and {{user}} keep crossing each other\'s '
|
||||
'paths as {{char}} and {{user}} relationship develops","everyday routine":["mornings":"{{char}} GENERATE",'
|
||||
'"days":"{{char}} GENERATE","evenings":"{{char}} GENERATE"],"current mood":"{{char}} GENERATE"]}\n\n'
|
||||
"Then add a section literally titled 'First Message:' on its own line, followed by the message itself: "
|
||||
"maximum 3 paragraphs, balancing narration with {{char}} dialogue, true to the profile's personality and "
|
||||
"the scenario above. Never decide what {{user}} does or says. Avoid describing eyes. Use direct speech "
|
||||
"with no markdown for dialogue, and *asterisks* for narration.\n\n"
|
||||
"The finished silly_tavern_prompt string MUST contain all three parts, in this order: the {{char}} card "
|
||||
"block, the {{\"Scenario\"}} block, and the 'First Message:' section — never stop after the card or the "
|
||||
"Scenario alone.\n\n"
|
||||
'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think'
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/character-generate-prompts")
|
||||
async def character_generate_prompts(request: Request):
|
||||
"""Turn an already-extracted character sheet into four ready-to-use external
|
||||
@ -1053,45 +1201,89 @@ async def character_generate_prompts(request: Request):
|
||||
"voice_design_prompt": (
|
||||
preamble
|
||||
+ "Produce exactly this field:\n"
|
||||
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, "
|
||||
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (25-60 words, one paragraph, "
|
||||
"no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
|
||||
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not "
|
||||
"mention plot events — describe only how the voice should SOUND.\n\n"
|
||||
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. "
|
||||
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though this prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
|
||||
+
|
||||
"Generic category labels alone ('young female voice, energetic tone, clear pitch') describe a whole "
|
||||
"demographic, not a person, and different characters who happen to share an age/gender end up "
|
||||
"sounding like the same person — confirmed live as a real problem with several young-female "
|
||||
"characters in one book. Every one of these fields needs a SPECIFIC, CONCRETE choice, not the safe "
|
||||
"generic default: pick a distinctive timbre (breathy/husky/bright/nasal/silvery/gravelly/reedy, not "
|
||||
"just 'clear'), a specific pace/rhythm quirk (clipped consonants, unhurried drawl, rapid-fire, "
|
||||
"deliberate pauses before key words), and a specific emotional baseline drawn from THIS character's "
|
||||
"own personality/backstory (guarded warmth, brittle confidence, weary sarcasm — not just 'friendly' "
|
||||
"or 'energetic'). Two characters with the same age and gender in your profile should still end up "
|
||||
"with visibly different prompts once you've done this. Do not mention plot events — describe only "
|
||||
"how the voice should SOUND.\n\n"
|
||||
'Respond with STRICT JSON only: {"voice_design_prompt":""}/no-think'
|
||||
),
|
||||
"image_prompt": (
|
||||
preamble
|
||||
+ "Produce exactly this field:\n"
|
||||
"- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. "
|
||||
"Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, "
|
||||
"clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style "
|
||||
"(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
|
||||
"are fine.\n\n"
|
||||
"- image_prompt: a detailed English image-generation prompt for a full CHARACTER REFERENCE SHEET "
|
||||
"(a single composite image, like a game/animation production turnaround), not just one portrait. "
|
||||
"FIRST, from the book title and profile, work out the story's genre, setting, and era (e.g. "
|
||||
"'medieval European-inspired high fantasy', 'grimdark low fantasy', 'space opera sci-fi', "
|
||||
"'contemporary urban fantasy') — an image model has no idea what a book title implies and will "
|
||||
"default to generic modern/real-world imagery unless told explicitly, which is exactly wrong for "
|
||||
"an occupation like 'Admiral' or 'General' in a fantasy world (it will draw a 20th-century military "
|
||||
"uniform instead of that world's actual equivalent). State that genre/setting/era explicitly, as "
|
||||
"its own descriptor near the START of the prompt, and make clear the world has no real-world 20th "
|
||||
"or 21st century technology, uniforms, or clothing unless the story is actually confirmed "
|
||||
"contemporary/near-future — every other visual choice (armor, dress, rank insignia, weapons) must "
|
||||
"fit THAT world, not the real one. Then ask the image model for: (1) a full-body front-view "
|
||||
"illustration as the anchor, (2) a turnaround panel with side and back views, (3) a small "
|
||||
"expression sheet with 3-5 headshots showing this character's typical emotional range "
|
||||
"(calm/determined/etc. — pick expressions that fit their personality), (4) a color palette swatch "
|
||||
"panel for hair/eyes/outfit, (5) callouts for their signature props, tools, or clothing details "
|
||||
"with short labels. Include age impression, build, hair/eyes/skin if known, and clothing "
|
||||
"appropriate to the established setting. Specify a clean production-design/concept-art layout "
|
||||
"with a plain neutral background, and explicitly note this is an ORIGINAL character, not based on "
|
||||
"any copyrighted character. One dense paragraph, comma-separated descriptors are fine.\n\n"
|
||||
'Respond with STRICT JSON only: {"image_prompt":""}/no-think'
|
||||
),
|
||||
"silly_tavern_prompt": (
|
||||
preamble
|
||||
+ "Produce exactly this field:\n"
|
||||
"- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their "
|
||||
"own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), "
|
||||
"'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character "
|
||||
"greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character "
|
||||
"lines showing their manner of speech). Keep each section a few lines at most.\n\n"
|
||||
'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think'
|
||||
),
|
||||
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
|
||||
"concept_art_prompt": (
|
||||
preamble
|
||||
+ "Produce exactly this field:\n"
|
||||
"- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — "
|
||||
"a turnaround/reference sheet with multiple views and expressions: front view, side or back view, "
|
||||
"2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, "
|
||||
"in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
|
||||
"white background').\n\n"
|
||||
"- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC "
|
||||
"reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
|
||||
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
|
||||
"(not a list). This mirrors a well-tested prompt-engineering pattern for these sheets; follow it "
|
||||
"precisely rather than writing free-form:\n"
|
||||
" Task: name the sheet type, e.g. 'Generate a character/NPC design sheet.'\n"
|
||||
" Subject: 'an original adult [role/archetype from the profile]' plus its 3-6 most visually "
|
||||
"distinctive, ALREADY-ESTABLISHED traits (skin/hair, signature garment layers, and — only if the "
|
||||
"profile actually gives this character a prop, weapon, or tool — its exact count, e.g. 'exactly one "
|
||||
"quiver' or 'exactly two throwing knives'; omit props entirely if the profile has none).\n"
|
||||
" Context: one clause on what the sheet is for and the story's genre/setting/era — infer this from "
|
||||
"the book/profile the same way you would for a portrait (a fantasy Admiral is NOT a real-world 20th-"
|
||||
"century Admiral) and state it explicitly, since an image model defaults to generic modern imagery "
|
||||
"otherwise.\n"
|
||||
" Style: a concept-art style matching that genre/setting (painterly game concept art / detailed "
|
||||
"semi-realistic concept art / hand-painted concept art — pick what fits), grounded materials, clear "
|
||||
"shape language.\n"
|
||||
" Composition: full-body front, side, and back views across the top; below them, ONE clean row of "
|
||||
"isolated callouts for this character's established props/costume components ONLY (skip this row "
|
||||
"entirely if the profile establishes no distinct props/costume pieces worth separating out) — state "
|
||||
"the exact number of callouts and name each one.\n"
|
||||
" Lighting: neutral studio/museum-style light, no cinematic color cast.\n"
|
||||
" Constraints: face/silhouette/garment/prop consistency across every view; the exact prop count "
|
||||
"restated; no duplicate gear, no extra limbs, no readable text/logos/watermark, no real-world/"
|
||||
"franchise references, this is an ORIGINAL character not based on any copyrighted one.\n"
|
||||
" Output: one production-ready 3:2 reference sheet.\n\n"
|
||||
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
|
||||
),
|
||||
}
|
||||
requested = [f for f in (data.get("fields") or []) if f in all_groups]
|
||||
field_groups = [((f,), all_groups[f]) for f in (requested or all_groups.keys())]
|
||||
# The SillyTavern card is a full structured card + scenario + first
|
||||
# message now, not a few short labelled lines — needs a much bigger
|
||||
# budget than the other three (single-paragraph) prompt fields or it
|
||||
# reliably truncates mid-card.
|
||||
_field_max_tokens = {"silly_tavern_prompt": 3000}
|
||||
|
||||
def _call_group(fields: tuple, system: str) -> dict:
|
||||
payload: dict = {
|
||||
@ -1100,7 +1292,7 @@ async def character_generate_prompts(request: Request):
|
||||
{"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1536,
|
||||
"max_tokens": _field_max_tokens.get(fields[0], 1536),
|
||||
}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
@ -1148,6 +1340,417 @@ async def character_generate_prompts(request: Request):
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/audiobook-consistency-check")
|
||||
async def audiobook_consistency_check(request: Request):
|
||||
"""Check whether every line already attributed to ONE character across the
|
||||
whole book actually sounds like them — using the character's OWN other
|
||||
lines as the reference, not a chunk-local judgement. Unlike the casting/
|
||||
verification passes (which re-read the source text chunk by chunk), this
|
||||
works purely over already-attributed lines gathered from anywhere in the
|
||||
book, so it can catch a character who briefly "borrows" someone else's
|
||||
voice in a way no single passage-sized chunk would ever expose.
|
||||
|
||||
Body: {character, lines: [{index, text}], known_characters: [...], llm_url, model}
|
||||
Returns: {outliers: [{index, reason, suggested_speaker}]}
|
||||
"""
|
||||
data = await request.json()
|
||||
character: str = (data.get("character") or "").strip()
|
||||
lines: list = data.get("lines") or []
|
||||
known: list = data.get("known_characters") or []
|
||||
_settings = _load_settings()
|
||||
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
|
||||
if not character:
|
||||
raise HTTPException(400, "Character name required")
|
||||
if not lines:
|
||||
raise HTTPException(400, "No lines to check")
|
||||
|
||||
numbered = "\n".join(
|
||||
f"[{l.get('index')}] {str(l.get('text') or '').strip()}"
|
||||
for l in lines if str(l.get("text") or "").strip()
|
||||
)
|
||||
known_note = (
|
||||
f"\n\nOther characters already recognized in this book (only suggest one of these, or 'Unknown' — "
|
||||
f"never invent a new name): {', '.join(str(n) for n in known)}"
|
||||
) if known else ""
|
||||
|
||||
system = (
|
||||
f"You are a dramaturge auditing dialogue attribution in a novel already cast for audiobook production. "
|
||||
f"Below are ALL the lines currently attributed to ONE character, '{character}', gathered from across the "
|
||||
f"whole book (not necessarily consecutive). Your job: read them as a whole and judge whether each line "
|
||||
f"actually sounds like the SAME person speaking — same tone, vocabulary, register, and personality — or "
|
||||
f"whether one or more lines sound like they were misattributed from someone else (a different tone, "
|
||||
f"formality, vocabulary, or a statement that contradicts what the rest of {character}'s lines establish "
|
||||
f"about them).\n\n"
|
||||
f"Be conservative: most lines are correctly attributed. Only flag a line if it genuinely reads like a "
|
||||
f"different voice compared to the REST of {character}'s own lines here — not just because it's short, "
|
||||
f"blunt, or otherwise unremarkable.{known_note}\n\n"
|
||||
'Respond with STRICT JSON only: {"outliers":[{"index":0,"quote":"","reason":"","suggested_speaker":""}]}\n'
|
||||
"index: the EXACT number shown in [square brackets] right before the flagged line below — copy that "
|
||||
"number verbatim, do NOT count lines yourself or renumber them (the brackets are the book's real line "
|
||||
"numbers, not a 0/1/2/3 sequence). quote: the first few words of the flagged line, verbatim, so the "
|
||||
"index can be double-checked. reason: one short sentence explaining why this line doesn't fit. "
|
||||
"suggested_speaker: your best guess at who actually said it (exact name from the list above), or "
|
||||
"'Unknown' if you can't tell. Omit any line that isn't an outlier — do not list every line.\n/no-think"
|
||||
)
|
||||
user = f"{character}'s lines (index — text):\n{numbered}"
|
||||
|
||||
payload: dict = {
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
|
||||
def _call() -> dict:
|
||||
resp = requests.post(
|
||||
f"{llm_url}/chat/completions", json=payload,
|
||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
msg = resp.json()["choices"][0]["message"]
|
||||
raw = (msg.get("content") or _reasoning_text(msg) or "").strip()
|
||||
content = re.sub(r"<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:
|
||||
|
||||
@ -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)]
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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()}
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
220
routes/tts.py
220
routes/tts.py
@ -20,7 +20,7 @@ from fastapi.responses import Response, StreamingResponse
|
||||
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
|
||||
from core.constants import (
|
||||
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
|
||||
_routing_log_add,
|
||||
_routing_log_add, CONFIG_DIR,
|
||||
)
|
||||
from core.routing import (
|
||||
_load_tts_routes, _resolve_tts_route, _route_backend,
|
||||
@ -469,7 +469,15 @@ async def restart_tts_container():
|
||||
for container in containers:
|
||||
path = f"/containers/{quote(container, safe='')}/restart?t=10"
|
||||
try:
|
||||
code, raw = _docker_post(path)
|
||||
# _docker_post is a raw blocking socket call (core/docker_client.py)
|
||||
# and this loop can span two containers x up to 10s each — run off
|
||||
# the event loop thread, or every other request on this server
|
||||
# (including a plain GET /api/characters) hangs for the whole
|
||||
# restart instead of just this one call. Confirmed live: the
|
||||
# Studio Voices tab's own character fetch silently failed and
|
||||
# rendered "No characters yet" while a restart triggered elsewhere
|
||||
# was still in flight.
|
||||
code, raw = await asyncio.to_thread(_docker_post, path)
|
||||
if code not in (204, 304):
|
||||
detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"HTTP {code}"
|
||||
errors.append(f"{container}: {detail}")
|
||||
@ -1085,3 +1093,211 @@ async def get_seed_sample(voice_name: str, seed: int):
|
||||
raise HTTPException(502, "Could not reach TTS server")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise HTTPException(e.response.status_code, str(e))
|
||||
|
||||
|
||||
@router.post("/api/audio/encode-mp3")
|
||||
async def encode_mp3(request: Request):
|
||||
"""Encode a raw WAV body into MP3 at an explicit bitrate.
|
||||
|
||||
audiobookExport() used to concatenate independently-encoded per-line MP3
|
||||
byte streams directly into one Blob — each clip carries its own frame/ID3
|
||||
headers, so most players only decode the first one (confirmed live: an
|
||||
85MB file that reported as 22s playable). The fix merges lossless WAV
|
||||
clips client-side (mergeWavBlobs, already correct) and sends the single
|
||||
merged WAV here for one real encode pass — also fixes the previous
|
||||
32kbps default (ffmpeg/lame's unset-bitrate fallback, not a deliberate
|
||||
choice anywhere in this app) without pretending to add quality beyond
|
||||
the engine's native 24kHz mono output.
|
||||
"""
|
||||
wav_bytes = await request.body()
|
||||
if not wav_bytes:
|
||||
raise HTTPException(400, "Empty request body")
|
||||
try:
|
||||
from pydub import AudioSegment
|
||||
segment = AudioSegment.from_file(io.BytesIO(wav_bytes), format="wav")
|
||||
out = io.BytesIO()
|
||||
segment.export(out, format="mp3", bitrate="96k")
|
||||
return Response(content=out.getvalue(), media_type="audio/mpeg")
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Could not encode audio: {e}")
|
||||
|
||||
|
||||
# ── Per-paragraph synthesized-audio cache ───────────────────────────────────
|
||||
#
|
||||
# "Synth all" pre-synthesizes every line for instant playback, but only ever
|
||||
# kept the result in the browser tab's memory — closing the tab (or a crash,
|
||||
# or just a normal reload) threw all of it away, and every future playback
|
||||
# or export had to wait on the GPU again from scratch. This persists each
|
||||
# line's audio to disk, keyed by a hash of its own content (text + voice +
|
||||
# instruct/tone) rather than its position in the script — editing a
|
||||
# paragraph changes its hash, so the edited version simply never matches a
|
||||
# cached file and gets synthesized fresh, while an untouched paragraph
|
||||
# reuses its file instantly regardless of how the surrounding lines shifted.
|
||||
# The key is computed client-side (SHA-256 over the exact inputs that affect
|
||||
# the audio) and treated here as an opaque cache token — this endpoint never
|
||||
# needs to know what it means, only that the same key always means the same
|
||||
# audio.
|
||||
_LINE_AUDIO_DIR = CONFIG_DIR / "line_audio_cache"
|
||||
_LINE_AUDIO_KEY_RE = re.compile(r"^[a-f0-9]{16,64}$")
|
||||
|
||||
|
||||
def _line_audio_book_dir(book: str) -> Path:
|
||||
safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book"
|
||||
d = _LINE_AUDIO_DIR / safe_book
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/api/line-audio/{book}/{key}")
|
||||
async def get_line_audio(book: str, key: str):
|
||||
if not _LINE_AUDIO_KEY_RE.match(key):
|
||||
raise HTTPException(400, "Invalid cache key")
|
||||
path = _line_audio_book_dir(book) / f"{key}.wav"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not cached")
|
||||
return Response(content=path.read_bytes(), media_type="audio/wav")
|
||||
|
||||
|
||||
@router.post("/api/line-audio/{book}/check")
|
||||
async def check_line_audio(book: str, request: Request):
|
||||
"""Bulk existence check — one request instead of one GET per line — so
|
||||
the Stage page can mark its "pre-synthesized" dots correctly right after
|
||||
a reload, instead of every dot looking unsynthesized just because
|
||||
rehState.synthCache (this browser tab's own memory) starts empty on
|
||||
every fresh page load even when the audio is sitting on disk already."""
|
||||
data = await request.json()
|
||||
keys = data.get("keys") or []
|
||||
if not isinstance(keys, list):
|
||||
raise HTTPException(400, "keys must be a list of cache keys")
|
||||
book_dir = _line_audio_book_dir(book)
|
||||
existing = [k for k in keys if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k) and (book_dir / f"{k}.wav").exists()]
|
||||
return {"ok": True, "existing": existing}
|
||||
|
||||
|
||||
@router.post("/api/line-audio/{book}/prune")
|
||||
async def prune_line_audio(book: str, request: Request):
|
||||
"""Delete cached files for this book that no longer match any current
|
||||
line — a paragraph's cache key is its own content hash, so editing it
|
||||
just makes the old file unreachable rather than actively removing it
|
||||
(nothing on the write path knows a "previous" key exists to delete).
|
||||
The client sends every key still valid for the CURRENT script; anything
|
||||
else on disk for this book is safe to remove.
|
||||
|
||||
Registered BEFORE the generic POST /api/line-audio/{book}/{key} route
|
||||
below — FastAPI matches routes in declaration order, and {key} is just
|
||||
a plain path segment at the routing level (its regex validation only
|
||||
runs inside the handler, after routing already picked one), so a
|
||||
literal "prune" segment would otherwise always match that generic
|
||||
route first and this one would never be reached at all.
|
||||
"""
|
||||
data = await request.json()
|
||||
keep = data.get("keep") or []
|
||||
if not isinstance(keep, list):
|
||||
raise HTTPException(400, "keep must be a list of cache keys")
|
||||
keep_set = {k for k in keep if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k)}
|
||||
book_dir = _line_audio_book_dir(book)
|
||||
deleted = 0
|
||||
for f in book_dir.glob("*.wav"):
|
||||
if f.stem not in keep_set:
|
||||
try:
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True, "deleted": deleted, "kept": len(keep_set)}
|
||||
|
||||
|
||||
@router.post("/api/line-audio/{book}/{key}")
|
||||
async def put_line_audio(book: str, key: str, request: Request):
|
||||
if not _LINE_AUDIO_KEY_RE.match(key):
|
||||
raise HTTPException(400, "Invalid cache key")
|
||||
wav_bytes = await request.body()
|
||||
if not wav_bytes:
|
||||
raise HTTPException(400, "Empty request body")
|
||||
path = _line_audio_book_dir(book) / f"{key}.wav"
|
||||
path.write_bytes(wav_bytes)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Finished audiobook chapter exports ──────────────────────────────────────
|
||||
#
|
||||
# audiobookExport() already triggers a browser download per chapter, but
|
||||
# that only ever lands wherever the browser's download settings put it —
|
||||
# confirmed as a real gap: nothing in the app itself says where the files
|
||||
# went, and re-finding a chapter later means re-running the whole export.
|
||||
# This additionally saves the exact same file server-side so the app can
|
||||
# show a real download link (and the on-disk path) right after the export
|
||||
# finishes, and again any time later without resynthesizing anything.
|
||||
_AUDIOBOOK_EXPORT_DIR = CONFIG_DIR / "audiobook_exports"
|
||||
_EXPORT_FILENAME_RE = re.compile(r"^[^/\\]{1,200}$") # any single path segment, no traversal
|
||||
|
||||
|
||||
def _audiobook_export_book_dir(book: str) -> Path:
|
||||
safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book"
|
||||
d = _AUDIOBOOK_EXPORT_DIR / safe_book
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/api/audiobook-export/{book}")
|
||||
async def list_audiobook_exports(book: str):
|
||||
"""List previously-saved chapter exports for this book — lets the app
|
||||
show a "browse what's already been exported" view without re-running
|
||||
the export, and without any real filesystem access on the user's part.
|
||||
Registered before the generic GET .../{filename} route below for the
|
||||
same reason "zip" and "check"/"prune" are elsewhere in this file: a
|
||||
plain path segment matches ANY literal string at the routing level.
|
||||
"""
|
||||
book_dir = _audiobook_export_book_dir(book)
|
||||
files = sorted(
|
||||
({"name": f.name, "size": f.stat().st_size} for f in book_dir.iterdir() if f.is_file()),
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
return {"book": book, "files": files, "dir": str(book_dir)}
|
||||
|
||||
|
||||
@router.get("/api/audiobook-export/{book}/zip")
|
||||
async def zip_audiobook_exports(book: str):
|
||||
"""Bundle every saved chapter for this book into one ZIP download —
|
||||
the "download everything at once" the per-file list doesn't offer."""
|
||||
import zipfile
|
||||
book_dir = _audiobook_export_book_dir(book)
|
||||
files = [f for f in book_dir.iterdir() if f.is_file()]
|
||||
if not files:
|
||||
raise HTTPException(404, "No exported files for this book")
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
||||
for f in files:
|
||||
zf.write(f, arcname=f.name)
|
||||
buf.seek(0)
|
||||
zip_name = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "audiobook"
|
||||
return Response(
|
||||
content=buf.getvalue(), media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{zip_name}.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/audiobook-export/{book}/{filename}")
|
||||
async def get_audiobook_export(book: str, filename: str):
|
||||
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
path = _audiobook_export_book_dir(book) / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(404, "Not found")
|
||||
media_type = "audio/mpeg" if path.suffix.lower() == ".mp3" else "audio/wav"
|
||||
return Response(
|
||||
content=path.read_bytes(), media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/audiobook-export/{book}/{filename}")
|
||||
async def put_audiobook_export(book: str, filename: str, request: Request):
|
||||
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
audio_bytes = await request.body()
|
||||
if not audio_bytes:
|
||||
raise HTTPException(400, "Empty request body")
|
||||
path = _audiobook_export_book_dir(book) / filename
|
||||
path.write_bytes(audio_bytes)
|
||||
return {"ok": True, "path": str(path)}
|
||||
|
||||
@ -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');
|
||||
|
||||
71
server.py
71
server.py
@ -15,7 +15,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
||||
from starlette.datastructures import MutableHeaders
|
||||
|
||||
from core.constants import STATIC_DIR, _BufferHandler
|
||||
from core.config import _load_settings
|
||||
from core.config import _load_settings, _ensure_external_api_key
|
||||
from core.voice_index import refresh_voice_index_background
|
||||
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db
|
||||
|
||||
@ -116,6 +116,75 @@ class StaticCacheHeadersMiddleware:
|
||||
app.add_middleware(StaticCacheHeadersMiddleware)
|
||||
|
||||
|
||||
# ── API-key gate for non-browser callers ────────────────────────────────────
|
||||
# The app's own UI calls /api/* same-origin from the browser and needs no
|
||||
# key — everything else (curl, scripts, MCP clients, agents) does. Gated by
|
||||
# Origin/Referer host matching the request's own Host header, which the
|
||||
# browser sets automatically and a bare script/curl call generally doesn't.
|
||||
# /mcp always requires the key regardless of origin, since no in-app browser
|
||||
# code calls it — it exists specifically for external MCP clients.
|
||||
# Raw ASGI (not @app.middleware("http")/BaseHTTPMiddleware) for the same
|
||||
# reason as StaticCacheHeadersMiddleware above: that wrapper's task-group
|
||||
# around call_next() fights with a client disconnecting mid-SSE-stream.
|
||||
class ApiKeyGateMiddleware:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope["path"]
|
||||
if not (path.startswith("/api/") or path == "/mcp"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if not _load_settings().get("external_api_key_required"):
|
||||
# Off by default — see the settings-key comment in core/config.py
|
||||
# for why. /mcp still needs SOME signal it's being used
|
||||
# deliberately even while the general gate is off, but that's a
|
||||
# judgment call for whoever enables it, not a silent bypass.
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
|
||||
host = headers.get("host", "")
|
||||
|
||||
def _same_origin(url: str) -> bool:
|
||||
if not url or not host:
|
||||
return False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
return urlparse(url).netloc == host
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
is_browser_same_origin = path != "/mcp" and (
|
||||
_same_origin(headers.get("origin", "")) or _same_origin(headers.get("referer", ""))
|
||||
)
|
||||
if is_browser_same_origin:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
expected = _ensure_external_api_key()
|
||||
provided = headers.get("x-api-key", "")
|
||||
if not provided and headers.get("authorization", "").lower().startswith("bearer "):
|
||||
provided = headers["authorization"][7:]
|
||||
if provided and provided == expected:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
import json as _json
|
||||
body = _json.dumps({"detail": "Missing or invalid API key — pass it as X-API-Key. Find/regenerate it in Settings > API Keys > External API Access."}).encode()
|
||||
await send({
|
||||
"type": "http.response.start", "status": 401,
|
||||
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())],
|
||||
})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
|
||||
app.add_middleware(ApiKeyGateMiddleware)
|
||||
|
||||
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(admin.router)
|
||||
|
||||
415
static/dist/main.min.js
vendored
415
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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 & 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>
|
||||
|
||||
@ -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
@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -8,8 +8,15 @@
|
||||
// window.t('English') for JS-generated strings. Dynamic lists can be translated by
|
||||
// calling window.applyI18n(container) after rendering.
|
||||
|
||||
const I18N_LANGS = { en: 'English', de: 'Deutsch' };
|
||||
const I18N_LANGS = {
|
||||
en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español',
|
||||
it: 'Italiano', pt: 'Português', nl: 'Nederlands', pl: 'Polski',
|
||||
};
|
||||
|
||||
// Every language block below covers the exact same key set — the English UI
|
||||
// chrome strings (nav, section titles/subtitles, common buttons/labels).
|
||||
// Adding a new key? Add it to ALL blocks, or it silently falls back to
|
||||
// English for languages that don't have it yet (see window.t / applyI18n).
|
||||
const I18N_DICT = {
|
||||
de: {
|
||||
// Brand / sidebar groups
|
||||
@ -64,15 +71,351 @@ const I18N_DICT = {
|
||||
'All genders': 'Alle Geschlechter', 'Microphone': 'Mikrofon', 'Upload file': 'Datei hochladen',
|
||||
'Sort': 'Sortieren', 'Cards': 'Karten', 'List': 'Liste', 'Develop': 'Ausarbeiten',
|
||||
'Match local': 'Lokal zuordnen', 'Match online': 'Online zuordnen', 'Design all': 'Alle entwerfen',
|
||||
'I play this': 'Ich spiele das', 'Save to library': 'In Bibliothek speichern',
|
||||
'I play this': 'Ich spiele das',
|
||||
'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen',
|
||||
'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript',
|
||||
'Preview': 'Vorschau', 'Trim your sample': 'Probe zuschneiden', 'Language': 'Sprache',
|
||||
'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Tags': 'Tags', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
|
||||
'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
|
||||
// Common placeholders
|
||||
'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...',
|
||||
'Filter name or tag…': 'Name oder Tag filtern…',
|
||||
},
|
||||
|
||||
fr: {
|
||||
'Voice Creator': 'Voice Creator',
|
||||
'Clone · Design · Deploy': 'Cloner · Concevoir · Déployer',
|
||||
'Voices': 'Voix', 'Setup': 'Configuration', 'Tags': 'Étiquettes',
|
||||
'My Voices': 'Mes voix', 'All voices': 'Toutes les voix', 'Cloned': 'Clonées',
|
||||
'Designed': 'Conçues', 'Favorites': 'Favoris', 'Hidden': 'Masquées',
|
||||
'Library tools': 'Outils de bibliothèque',
|
||||
'Clone a Voice': 'Cloner une voix', 'Design a Voice': 'Concevoir une voix',
|
||||
'Get Voices Online': 'Obtenir des voix en ligne', 'Try It Out': 'Essayer',
|
||||
'Read Aloud': 'Lecture à voix haute',
|
||||
'Script Rehearser': 'Répétition de script', 'Library': 'Bibliothèque', 'Cast': 'Distribution',
|
||||
'Stage': 'Scène', 'Summary': 'Résumé', 'Import / Export': 'Import / Export',
|
||||
'Conversation': 'Conversation', 'Benchmark': 'Benchmark', 'Engines': 'Moteurs',
|
||||
'Language Models': 'Modèles de langage', 'Speech to Text': 'Voix vers texte',
|
||||
'Text to Speech': 'Texte vers voix', 'App Routing': "Routage de l'app",
|
||||
'Connect Apps': 'Connecter des apps', 'Settings': 'Paramètres',
|
||||
'Conversation Playground': 'Terrain de jeu conversation',
|
||||
'Pick a voice on the left, edit on the right.': 'Choisissez une voix à gauche, modifiez-la à droite.',
|
||||
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
|
||||
"Enregistrez 3 à 20 secondes de parole claire, découpez-la, nommez-la, puis enregistrez-la comme clone de voix réutilisable.",
|
||||
'Describe a voice in words and let the AI create it. No recording needed.':
|
||||
"Décrivez une voix avec des mots et laissez l'IA la créer. Aucun enregistrement nécessaire.",
|
||||
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
|
||||
"Parcourez des sources publiques d'extraits vocaux, prévisualisez des fichiers audio et importez des voix depuis le web.",
|
||||
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
|
||||
"Générez de la parole à partir de texte avec n'importe quel moteur et voix. Transcrivez aussi l'audio et refaites-le parler.",
|
||||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||||
'Téléversez un script, attribuez des voix TTS ou votre micro aux personnages, puis répétez scène par scène.',
|
||||
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
|
||||
"Importez un PDF ou un document texte, choisissez une voix et une vitesse, puis faites-le lire à voix haute avec surlignage du mot prononcé.",
|
||||
'My books': 'Mes livres', 'Voice consistency': 'Cohérence de la voix',
|
||||
'Normalise loudness': 'Normaliser le volume', 'Export MP3': 'Exporter en MP3',
|
||||
'Select range': 'Sélectionner une plage', 'Synthesise': 'Synthétiser',
|
||||
'Tone / style': 'Ton / style', 'Temperature': 'Température', 'Speed': 'Vitesse',
|
||||
'Saved documents with their synthesised audio — reopen to continue.':
|
||||
'Documents enregistrés avec leur audio synthétisé — rouvrez pour continuer.',
|
||||
'Save changes': 'Enregistrer les modifications', 'Save to library': 'Enregistrer dans la bibliothèque',
|
||||
'Save to Voice Library': 'Enregistrer dans la bibliothèque de voix', 'Delete voice': 'Supprimer la voix',
|
||||
'Cancel': 'Annuler', 'Refresh': 'Actualiser', 'Delete': 'Supprimer', 'Save': 'Enregistrer',
|
||||
'Back': 'Retour', 'Apply →': 'Appliquer →', 'Browse': 'Parcourir', 'Download': 'Télécharger',
|
||||
'Record': 'Enregistrer', 'Stop': 'Arrêter', 'Play': 'Lire', 'Play selection': 'Lire la sélection',
|
||||
'Check level': 'Vérifier le niveau', 'Stop monitor': 'Arrêter le moniteur', 'Auto trim': 'Découpe auto',
|
||||
'Auto-transcribe': 'Transcription auto', 'Active': 'Actif', 'copy ID': "copier l'ID",
|
||||
'edit ID': "modifier l'ID", 'New voice': 'Nouvelle voix', 'All languages': 'Toutes les langues',
|
||||
'All genders': 'Tous les genres', 'Microphone': 'Microphone', 'Upload file': 'Téléverser un fichier',
|
||||
'Sort': 'Trier', 'Cards': 'Cartes', 'List': 'Liste', 'Develop': 'Développer',
|
||||
'Match local': 'Correspondance locale', 'Match online': 'Correspondance en ligne', 'Design all': 'Tout concevoir',
|
||||
'I play this': 'Je joue ce personnage',
|
||||
'Open rehearsal': 'Ouvrir la répétition', 'Fetch voices': 'Récupérer les voix',
|
||||
'Name your voice': 'Nommez votre voix', 'Reference transcript': 'Transcription de référence',
|
||||
'Preview': 'Aperçu', 'Trim your sample': 'Découpez votre échantillon', 'Language': 'Langue',
|
||||
'Gender': 'Genre', 'Voice': 'Voix', 'Speaking style · voice-design prompt': "Style d'élocution · invite de conception vocale",
|
||||
'Search voices…': 'Rechercher des voix…', 'Search voices...': 'Rechercher des voix...',
|
||||
'Filter name or tag…': 'Filtrer par nom ou étiquette…',
|
||||
},
|
||||
|
||||
es: {
|
||||
'Voice Creator': 'Voice Creator',
|
||||
'Clone · Design · Deploy': 'Clonar · Diseñar · Implementar',
|
||||
'Voices': 'Voces', 'Setup': 'Configuración', 'Tags': 'Etiquetas',
|
||||
'My Voices': 'Mis voces', 'All voices': 'Todas las voces', 'Cloned': 'Clonadas',
|
||||
'Designed': 'Diseñadas', 'Favorites': 'Favoritos', 'Hidden': 'Ocultas',
|
||||
'Library tools': 'Herramientas de biblioteca',
|
||||
'Clone a Voice': 'Clonar una voz', 'Design a Voice': 'Diseñar una voz',
|
||||
'Get Voices Online': 'Obtener voces en línea', 'Try It Out': 'Probarlo',
|
||||
'Read Aloud': 'Leer en voz alta',
|
||||
'Script Rehearser': 'Ensayo de guion', 'Library': 'Biblioteca', 'Cast': 'Reparto',
|
||||
'Stage': 'Escenario', 'Summary': 'Resumen', 'Import / Export': 'Importar / Exportar',
|
||||
'Conversation': 'Conversación', 'Benchmark': 'Benchmark', 'Engines': 'Motores',
|
||||
'Language Models': 'Modelos de lenguaje', 'Speech to Text': 'Voz a texto',
|
||||
'Text to Speech': 'Texto a voz', 'App Routing': 'Enrutamiento de la app',
|
||||
'Connect Apps': 'Conectar apps', 'Settings': 'Ajustes',
|
||||
'Conversation Playground': 'Zona de pruebas de conversación',
|
||||
'Pick a voice on the left, edit on the right.': 'Elige una voz a la izquierda, edítala a la derecha.',
|
||||
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
|
||||
'Graba de 3 a 20 segundos de habla limpia, recórtala, nómbrala y guárdala como un clon de voz reutilizable.',
|
||||
'Describe a voice in words and let the AI create it. No recording needed.':
|
||||
'Describe una voz con palabras y deja que la IA la cree. No se necesita grabación.',
|
||||
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
|
||||
'Explora fuentes públicas de clips de voz, previsualiza archivos de audio e importa voces desde la web.',
|
||||
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
|
||||
'Genera voz a partir de texto con cualquier motor y voz. También transcribe audio y vuelve a reproducirlo hablado.',
|
||||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||||
'Sube un guion, asigna voces TTS o tu propio micrófono a los personajes y ensaya escena por escena.',
|
||||
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
|
||||
'Importa un PDF o documento de texto, elige una voz y velocidad, y escúchalo mientras se resalta la palabra que se está leyendo.',
|
||||
'My books': 'Mis libros', 'Voice consistency': 'Consistencia de voz',
|
||||
'Normalise loudness': 'Normalizar volumen', 'Export MP3': 'Exportar MP3',
|
||||
'Select range': 'Seleccionar rango', 'Synthesise': 'Sintetizar',
|
||||
'Tone / style': 'Tono / estilo', 'Temperature': 'Temperatura', 'Speed': 'Velocidad',
|
||||
'Saved documents with their synthesised audio — reopen to continue.':
|
||||
'Documentos guardados con su audio sintetizado — vuelve a abrirlos para continuar.',
|
||||
'Save changes': 'Guardar cambios', 'Save to library': 'Guardar en la biblioteca',
|
||||
'Save to Voice Library': 'Guardar en la biblioteca de voces', 'Delete voice': 'Eliminar voz',
|
||||
'Cancel': 'Cancelar', 'Refresh': 'Actualizar', 'Delete': 'Eliminar', 'Save': 'Guardar',
|
||||
'Back': 'Atrás', 'Apply →': 'Aplicar →', 'Browse': 'Explorar', 'Download': 'Descargar',
|
||||
'Record': 'Grabar', 'Stop': 'Detener', 'Play': 'Reproducir', 'Play selection': 'Reproducir selección',
|
||||
'Check level': 'Comprobar nivel', 'Stop monitor': 'Detener monitor', 'Auto trim': 'Recorte automático',
|
||||
'Auto-transcribe': 'Transcripción automática', 'Active': 'Activo', 'copy ID': 'copiar ID',
|
||||
'edit ID': 'editar ID', 'New voice': 'Nueva voz', 'All languages': 'Todos los idiomas',
|
||||
'All genders': 'Todos los géneros', 'Microphone': 'Micrófono', 'Upload file': 'Subir archivo',
|
||||
'Sort': 'Ordenar', 'Cards': 'Tarjetas', 'List': 'Lista', 'Develop': 'Desarrollar',
|
||||
'Match local': 'Coincidencia local', 'Match online': 'Coincidencia en línea', 'Design all': 'Diseñar todo',
|
||||
'I play this': 'Yo interpreto esto',
|
||||
'Open rehearsal': 'Abrir ensayo', 'Fetch voices': 'Obtener voces',
|
||||
'Name your voice': 'Nombra tu voz', 'Reference transcript': 'Transcripción de referencia',
|
||||
'Preview': 'Vista previa', 'Trim your sample': 'Recorta tu muestra', 'Language': 'Idioma',
|
||||
'Gender': 'Género', 'Voice': 'Voz', 'Speaking style · voice-design prompt': 'Estilo de habla · prompt de diseño de voz',
|
||||
'Search voices…': 'Buscar voces…', 'Search voices...': 'Buscar voces...',
|
||||
'Filter name or tag…': 'Filtrar por nombre o etiqueta…',
|
||||
},
|
||||
|
||||
it: {
|
||||
'Voice Creator': 'Voice Creator',
|
||||
'Clone · Design · Deploy': 'Clona · Progetta · Distribuisci',
|
||||
'Voices': 'Voci', 'Setup': 'Configurazione', 'Tags': 'Tag',
|
||||
'My Voices': 'Le mie voci', 'All voices': 'Tutte le voci', 'Cloned': 'Clonate',
|
||||
'Designed': 'Progettate', 'Favorites': 'Preferiti', 'Hidden': 'Nascoste',
|
||||
'Library tools': 'Strumenti libreria',
|
||||
'Clone a Voice': 'Clona una voce', 'Design a Voice': 'Progetta una voce',
|
||||
'Get Voices Online': 'Ottieni voci online', 'Try It Out': 'Prova',
|
||||
'Read Aloud': 'Leggi ad alta voce',
|
||||
'Script Rehearser': 'Prova script', 'Library': 'Libreria', 'Cast': 'Cast',
|
||||
'Stage': 'Palco', 'Summary': 'Riepilogo', 'Import / Export': 'Importa / Esporta',
|
||||
'Conversation': 'Conversazione', 'Benchmark': 'Benchmark', 'Engines': 'Motori',
|
||||
'Language Models': 'Modelli linguistici', 'Speech to Text': 'Voce in testo',
|
||||
'Text to Speech': 'Testo in voce', 'App Routing': 'Instradamento app',
|
||||
'Connect Apps': 'Connetti app', 'Settings': 'Impostazioni',
|
||||
'Conversation Playground': 'Area di prova conversazione',
|
||||
'Pick a voice on the left, edit on the right.': 'Scegli una voce a sinistra, modificala a destra.',
|
||||
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
|
||||
'Registra 3-20 secondi di voce pulita, taglia, assegna un nome e salva come clone vocale riutilizzabile.',
|
||||
'Describe a voice in words and let the AI create it. No recording needed.':
|
||||
"Descrivi una voce a parole e lascia che l'IA la crei. Nessuna registrazione necessaria.",
|
||||
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
|
||||
'Sfoglia fonti pubbliche di clip vocali, anteprima file audio e importa voci dal web.',
|
||||
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
|
||||
"Genera voce dal testo con qualsiasi backend e voce. Trascrivi anche l'audio e falla riparlare.",
|
||||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||||
'Carica uno script, assegna voci TTS o il tuo microfono ai personaggi, poi prova scena per scena.',
|
||||
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
|
||||
"Importa un PDF o un documento di testo, scegli voce e velocità, e fallo leggere ad alta voce con evidenziazione della parola pronunciata.",
|
||||
'My books': 'I miei libri', 'Voice consistency': 'Coerenza vocale',
|
||||
'Normalise loudness': 'Normalizza volume', 'Export MP3': 'Esporta MP3',
|
||||
'Select range': 'Seleziona intervallo', 'Synthesise': 'Sintetizza',
|
||||
'Tone / style': 'Tono / stile', 'Temperature': 'Temperatura', 'Speed': 'Velocità',
|
||||
'Saved documents with their synthesised audio — reopen to continue.':
|
||||
'Documenti salvati con il loro audio sintetizzato — riaprili per continuare.',
|
||||
'Save changes': 'Salva modifiche', 'Save to library': 'Salva nella libreria',
|
||||
'Save to Voice Library': 'Salva nella libreria vocale', 'Delete voice': 'Elimina voce',
|
||||
'Cancel': 'Annulla', 'Refresh': 'Aggiorna', 'Delete': 'Elimina', 'Save': 'Salva',
|
||||
'Back': 'Indietro', 'Apply →': 'Applica →', 'Browse': 'Sfoglia', 'Download': 'Scarica',
|
||||
'Record': 'Registra', 'Stop': 'Ferma', 'Play': 'Riproduci', 'Play selection': 'Riproduci selezione',
|
||||
'Check level': 'Controlla livello', 'Stop monitor': 'Ferma monitor', 'Auto trim': 'Taglio automatico',
|
||||
'Auto-transcribe': 'Trascrizione automatica', 'Active': 'Attivo', 'copy ID': 'copia ID',
|
||||
'edit ID': 'modifica ID', 'New voice': 'Nuova voce', 'All languages': 'Tutte le lingue',
|
||||
'All genders': 'Tutti i generi', 'Microphone': 'Microfono', 'Upload file': 'Carica file',
|
||||
'Sort': 'Ordina', 'Cards': 'Schede', 'List': 'Elenco', 'Develop': 'Sviluppa',
|
||||
'Match local': 'Abbinamento locale', 'Match online': 'Abbinamento online', 'Design all': 'Progetta tutto',
|
||||
'I play this': 'Interpreto io questo',
|
||||
'Open rehearsal': 'Apri prova', 'Fetch voices': 'Recupera voci',
|
||||
'Name your voice': 'Assegna un nome alla voce', 'Reference transcript': 'Trascrizione di riferimento',
|
||||
'Preview': 'Anteprima', 'Trim your sample': 'Taglia il tuo campione', 'Language': 'Lingua',
|
||||
'Gender': 'Genere', 'Voice': 'Voce', 'Speaking style · voice-design prompt': 'Stile di parlato · prompt di progettazione vocale',
|
||||
'Search voices…': 'Cerca voci…', 'Search voices...': 'Cerca voci...',
|
||||
'Filter name or tag…': 'Filtra per nome o tag…',
|
||||
},
|
||||
|
||||
pt: {
|
||||
'Voice Creator': 'Voice Creator',
|
||||
'Clone · Design · Deploy': 'Clonar · Projetar · Implantar',
|
||||
'Voices': 'Vozes', 'Setup': 'Configuração', 'Tags': 'Etiquetas',
|
||||
'My Voices': 'Minhas vozes', 'All voices': 'Todas as vozes', 'Cloned': 'Clonadas',
|
||||
'Designed': 'Projetadas', 'Favorites': 'Favoritos', 'Hidden': 'Ocultas',
|
||||
'Library tools': 'Ferramentas da biblioteca',
|
||||
'Clone a Voice': 'Clonar uma voz', 'Design a Voice': 'Projetar uma voz',
|
||||
'Get Voices Online': 'Obter vozes online', 'Try It Out': 'Experimentar',
|
||||
'Read Aloud': 'Leitura em voz alta',
|
||||
'Script Rehearser': 'Ensaio de roteiro', 'Library': 'Biblioteca', 'Cast': 'Elenco',
|
||||
'Stage': 'Palco', 'Summary': 'Resumo', 'Import / Export': 'Importar / Exportar',
|
||||
'Conversation': 'Conversa', 'Benchmark': 'Benchmark', 'Engines': 'Motores',
|
||||
'Language Models': 'Modelos de linguagem', 'Speech to Text': 'Voz para texto',
|
||||
'Text to Speech': 'Texto para voz', 'App Routing': 'Roteamento do app',
|
||||
'Connect Apps': 'Conectar apps', 'Settings': 'Configurações',
|
||||
'Conversation Playground': 'Espaço de teste de conversa',
|
||||
'Pick a voice on the left, edit on the right.': 'Escolha uma voz à esquerda, edite à direita.',
|
||||
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
|
||||
'Grave de 3 a 20 segundos de fala limpa, corte, nomeie e salve como um clone de voz reutilizável.',
|
||||
'Describe a voice in words and let the AI create it. No recording needed.':
|
||||
'Descreva uma voz com palavras e deixe a IA criá-la. Não é necessária gravação.',
|
||||
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
|
||||
'Navegue por fontes públicas de clipes de voz, pré-visualize arquivos de áudio e importe vozes da web.',
|
||||
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
|
||||
'Gere fala a partir de texto com qualquer backend e voz. Também transcreva áudio e reproduza-o falado.',
|
||||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||||
'Envie um roteiro, atribua vozes TTS ou seu próprio microfone aos personagens e ensaie cena por cena.',
|
||||
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
|
||||
'Importe um PDF ou documento de texto, escolha uma voz e velocidade, e ouça-o enquanto a palavra falada é destacada.',
|
||||
'My books': 'Meus livros', 'Voice consistency': 'Consistência de voz',
|
||||
'Normalise loudness': 'Normalizar volume', 'Export MP3': 'Exportar MP3',
|
||||
'Select range': 'Selecionar intervalo', 'Synthesise': 'Sintetizar',
|
||||
'Tone / style': 'Tom / estilo', 'Temperature': 'Temperatura', 'Speed': 'Velocidade',
|
||||
'Saved documents with their synthesised audio — reopen to continue.':
|
||||
'Documentos salvos com seu áudio sintetizado — reabra para continuar.',
|
||||
'Save changes': 'Salvar alterações', 'Save to library': 'Salvar na biblioteca',
|
||||
'Save to Voice Library': 'Salvar na biblioteca de vozes', 'Delete voice': 'Excluir voz',
|
||||
'Cancel': 'Cancelar', 'Refresh': 'Atualizar', 'Delete': 'Excluir', 'Save': 'Salvar',
|
||||
'Back': 'Voltar', 'Apply →': 'Aplicar →', 'Browse': 'Procurar', 'Download': 'Baixar',
|
||||
'Record': 'Gravar', 'Stop': 'Parar', 'Play': 'Reproduzir', 'Play selection': 'Reproduzir seleção',
|
||||
'Check level': 'Verificar nível', 'Stop monitor': 'Parar monitor', 'Auto trim': 'Corte automático',
|
||||
'Auto-transcribe': 'Transcrição automática', 'Active': 'Ativo', 'copy ID': 'copiar ID',
|
||||
'edit ID': 'editar ID', 'New voice': 'Nova voz', 'All languages': 'Todos os idiomas',
|
||||
'All genders': 'Todos os gêneros', 'Microphone': 'Microfone', 'Upload file': 'Enviar arquivo',
|
||||
'Sort': 'Ordenar', 'Cards': 'Cartões', 'List': 'Lista', 'Develop': 'Desenvolver',
|
||||
'Match local': 'Correspondência local', 'Match online': 'Correspondência online', 'Design all': 'Projetar tudo',
|
||||
'I play this': 'Eu interpreto isto',
|
||||
'Open rehearsal': 'Abrir ensaio', 'Fetch voices': 'Buscar vozes',
|
||||
'Name your voice': 'Nomeie sua voz', 'Reference transcript': 'Transcrição de referência',
|
||||
'Preview': 'Pré-visualização', 'Trim your sample': 'Corte sua amostra', 'Language': 'Idioma',
|
||||
'Gender': 'Gênero', 'Voice': 'Voz', 'Speaking style · voice-design prompt': 'Estilo de fala · prompt de design de voz',
|
||||
'Search voices…': 'Buscar vozes…', 'Search voices...': 'Buscar vozes...',
|
||||
'Filter name or tag…': 'Filtrar por nome ou etiqueta…',
|
||||
},
|
||||
|
||||
nl: {
|
||||
'Voice Creator': 'Voice Creator',
|
||||
'Clone · Design · Deploy': 'Klonen · Ontwerpen · Implementeren',
|
||||
'Voices': 'Stemmen', 'Setup': 'Instellingen', 'Tags': 'Tags',
|
||||
'My Voices': 'Mijn stemmen', 'All voices': 'Alle stemmen', 'Cloned': 'Gekloond',
|
||||
'Designed': 'Ontworpen', 'Favorites': 'Favorieten', 'Hidden': 'Verborgen',
|
||||
'Library tools': 'Bibliotheektools',
|
||||
'Clone a Voice': 'Een stem klonen', 'Design a Voice': 'Een stem ontwerpen',
|
||||
'Get Voices Online': 'Stemmen online ophalen', 'Try It Out': 'Uitproberen',
|
||||
'Read Aloud': 'Voorlezen',
|
||||
'Script Rehearser': 'Scriptrepetitie', 'Library': 'Bibliotheek', 'Cast': 'Cast',
|
||||
'Stage': 'Podium', 'Summary': 'Samenvatting', 'Import / Export': 'Importeren / Exporteren',
|
||||
'Conversation': 'Gesprek', 'Benchmark': 'Benchmark', 'Engines': 'Engines',
|
||||
'Language Models': 'Taalmodellen', 'Speech to Text': 'Spraak naar tekst',
|
||||
'Text to Speech': 'Tekst naar spraak', 'App Routing': 'App-routering',
|
||||
'Connect Apps': 'Apps verbinden', 'Settings': 'Instellingen',
|
||||
'Conversation Playground': 'Gesprek-speeltuin',
|
||||
'Pick a voice on the left, edit on the right.': 'Kies links een stem, bewerk rechts.',
|
||||
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
|
||||
'Neem 3–20 seconden heldere spraak op, knip bij, geef een naam en sla op als herbruikbare stemkloon.',
|
||||
'Describe a voice in words and let the AI create it. No recording needed.':
|
||||
'Beschrijf een stem in woorden en laat de AI hem maken. Geen opname nodig.',
|
||||
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
|
||||
'Blader door openbare stemclipbronnen, bekijk audiobestanden vooraf en importeer stemmen van het web.',
|
||||
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
|
||||
'Genereer spraak uit tekst met elke backend en stem. Transcribeer ook audio en spreek het opnieuw uit.',
|
||||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||||
'Upload een script, wijs TTS-stemmen of je eigen microfoon toe aan personages en repeteer scène voor scène.',
|
||||
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
|
||||
'Importeer een PDF of tekstdocument, kies een stem en snelheid, en laat het voorlezen terwijl het gesproken woord wordt gemarkeerd.',
|
||||
'My books': 'Mijn boeken', 'Voice consistency': 'Stemconsistentie',
|
||||
'Normalise loudness': 'Volume normaliseren', 'Export MP3': 'MP3 exporteren',
|
||||
'Select range': 'Bereik selecteren', 'Synthesise': 'Synthetiseren',
|
||||
'Tone / style': 'Toon / stijl', 'Temperature': 'Temperatuur', 'Speed': 'Snelheid',
|
||||
'Saved documents with their synthesised audio — reopen to continue.':
|
||||
'Opgeslagen documenten met hun gesynthetiseerde audio — heropen om verder te gaan.',
|
||||
'Save changes': 'Wijzigingen opslaan', 'Save to library': 'Opslaan in bibliotheek',
|
||||
'Save to Voice Library': 'Opslaan in stembibliotheek', 'Delete voice': 'Stem verwijderen',
|
||||
'Cancel': 'Annuleren', 'Refresh': 'Vernieuwen', 'Delete': 'Verwijderen', 'Save': 'Opslaan',
|
||||
'Back': 'Terug', 'Apply →': 'Toepassen →', 'Browse': 'Bladeren', 'Download': 'Downloaden',
|
||||
'Record': 'Opnemen', 'Stop': 'Stoppen', 'Play': 'Afspelen', 'Play selection': 'Selectie afspelen',
|
||||
'Check level': 'Niveau controleren', 'Stop monitor': 'Monitor stoppen', 'Auto trim': 'Automatisch bijsnijden',
|
||||
'Auto-transcribe': 'Automatisch transcriberen', 'Active': 'Actief', 'copy ID': 'ID kopiëren',
|
||||
'edit ID': 'ID bewerken', 'New voice': 'Nieuwe stem', 'All languages': 'Alle talen',
|
||||
'All genders': 'Alle geslachten', 'Microphone': 'Microfoon', 'Upload file': 'Bestand uploaden',
|
||||
'Sort': 'Sorteren', 'Cards': 'Kaarten', 'List': 'Lijst', 'Develop': 'Ontwikkelen',
|
||||
'Match local': 'Lokaal matchen', 'Match online': 'Online matchen', 'Design all': 'Alles ontwerpen',
|
||||
'I play this': 'Ik speel dit',
|
||||
'Open rehearsal': 'Repetitie openen', 'Fetch voices': 'Stemmen ophalen',
|
||||
'Name your voice': 'Geef je stem een naam', 'Reference transcript': 'Referentietranscript',
|
||||
'Preview': 'Voorbeeld', 'Trim your sample': 'Knip je sample bij', 'Language': 'Taal',
|
||||
'Gender': 'Geslacht', 'Voice': 'Stem', 'Speaking style · voice-design prompt': 'Spreekstijl · stemontwerp-prompt',
|
||||
'Search voices…': 'Stemmen zoeken…', 'Search voices...': 'Stemmen zoeken...',
|
||||
'Filter name or tag…': 'Filter op naam of tag…',
|
||||
},
|
||||
|
||||
pl: {
|
||||
'Voice Creator': 'Voice Creator',
|
||||
'Clone · Design · Deploy': 'Klonuj · Projektuj · Wdrażaj',
|
||||
'Voices': 'Głosy', 'Setup': 'Konfiguracja', 'Tags': 'Tagi',
|
||||
'My Voices': 'Moje głosy', 'All voices': 'Wszystkie głosy', 'Cloned': 'Sklonowane',
|
||||
'Designed': 'Zaprojektowane', 'Favorites': 'Ulubione', 'Hidden': 'Ukryte',
|
||||
'Library tools': 'Narzędzia biblioteki',
|
||||
'Clone a Voice': 'Sklonuj głos', 'Design a Voice': 'Zaprojektuj głos',
|
||||
'Get Voices Online': 'Pobierz głosy online', 'Try It Out': 'Wypróbuj',
|
||||
'Read Aloud': 'Czytanie na głos',
|
||||
'Script Rehearser': 'Próba scenariusza', 'Library': 'Biblioteka', 'Cast': 'Obsada',
|
||||
'Stage': 'Scena', 'Summary': 'Podsumowanie', 'Import / Export': 'Import / Eksport',
|
||||
'Conversation': 'Rozmowa', 'Benchmark': 'Benchmark', 'Engines': 'Silniki',
|
||||
'Language Models': 'Modele językowe', 'Speech to Text': 'Mowa na tekst',
|
||||
'Text to Speech': 'Tekst na mowę', 'App Routing': 'Routing aplikacji',
|
||||
'Connect Apps': 'Połącz aplikacje', 'Settings': 'Ustawienia',
|
||||
'Conversation Playground': 'Plac zabaw rozmów',
|
||||
'Pick a voice on the left, edit on the right.': 'Wybierz głos po lewej, edytuj po prawej.',
|
||||
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
|
||||
'Nagraj 3–20 sekund czystej mowy, przytnij, nazwij, a następnie zapisz jako wielokrotnego użytku klon głosu.',
|
||||
'Describe a voice in words and let the AI create it. No recording needed.':
|
||||
'Opisz głos słowami i pozwól AI go stworzyć. Nagrywanie nie jest potrzebne.',
|
||||
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
|
||||
'Przeglądaj publiczne źródła klipów głosowych, podglądaj pliki audio i importuj głosy z sieci.',
|
||||
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
|
||||
'Generuj mowę z tekstu za pomocą dowolnego silnika i głosu. Transkrybuj też audio i wypowiedz je ponownie.',
|
||||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||||
'Prześlij scenariusz, przypisz głosy TTS lub własny mikrofon do postaci, a następnie ćwicz scenę po scenie.',
|
||||
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
|
||||
'Zaimportuj PDF lub dokument tekstowy, wybierz głos i prędkość, a następnie posłuchaj czytania z podświetlaniem wypowiadanego słowa.',
|
||||
'My books': 'Moje książki', 'Voice consistency': 'Spójność głosu',
|
||||
'Normalise loudness': 'Normalizuj głośność', 'Export MP3': 'Eksportuj MP3',
|
||||
'Select range': 'Wybierz zakres', 'Synthesise': 'Syntetyzuj',
|
||||
'Tone / style': 'Ton / styl', 'Temperature': 'Temperatura', 'Speed': 'Prędkość',
|
||||
'Saved documents with their synthesised audio — reopen to continue.':
|
||||
'Zapisane dokumenty z zsyntetyzowanym audio — otwórz ponownie, aby kontynuować.',
|
||||
'Save changes': 'Zapisz zmiany', 'Save to library': 'Zapisz w bibliotece',
|
||||
'Save to Voice Library': 'Zapisz w bibliotece głosów', 'Delete voice': 'Usuń głos',
|
||||
'Cancel': 'Anuluj', 'Refresh': 'Odśwież', 'Delete': 'Usuń', 'Save': 'Zapisz',
|
||||
'Back': 'Wstecz', 'Apply →': 'Zastosuj →', 'Browse': 'Przeglądaj', 'Download': 'Pobierz',
|
||||
'Record': 'Nagraj', 'Stop': 'Zatrzymaj', 'Play': 'Odtwórz', 'Play selection': 'Odtwórz zaznaczenie',
|
||||
'Check level': 'Sprawdź poziom', 'Stop monitor': 'Zatrzymaj monitor', 'Auto trim': 'Automatyczne przycinanie',
|
||||
'Auto-transcribe': 'Automatyczna transkrypcja', 'Active': 'Aktywny', 'copy ID': 'kopiuj ID',
|
||||
'edit ID': 'edytuj ID', 'New voice': 'Nowy głos', 'All languages': 'Wszystkie języki',
|
||||
'All genders': 'Wszystkie płcie', 'Microphone': 'Mikrofon', 'Upload file': 'Prześlij plik',
|
||||
'Sort': 'Sortuj', 'Cards': 'Karty', 'List': 'Lista', 'Develop': 'Rozwiń',
|
||||
'Match local': 'Dopasuj lokalnie', 'Match online': 'Dopasuj online', 'Design all': 'Zaprojektuj wszystko',
|
||||
'I play this': 'Ja to gram',
|
||||
'Open rehearsal': 'Otwórz próbę', 'Fetch voices': 'Pobierz głosy',
|
||||
'Name your voice': 'Nazwij swój głos', 'Reference transcript': 'Transkrypcja referencyjna',
|
||||
'Preview': 'Podgląd', 'Trim your sample': 'Przytnij próbkę', 'Language': 'Język',
|
||||
'Gender': 'Płeć', 'Voice': 'Głos', 'Speaking style · voice-design prompt': 'Styl mówienia · prompt projektowania głosu',
|
||||
'Search voices…': 'Szukaj głosów…', 'Search voices...': 'Szukaj głosów...',
|
||||
'Filter name or tag…': 'Filtruj po nazwie lub tagu…',
|
||||
},
|
||||
};
|
||||
|
||||
let _appLang = 'en';
|
||||
|
||||
@ -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
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.")
|
||||
) {
|
||||
|
||||
@ -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 & skip this character’s lines" aria-label="Ignore this character’s lines"><span class="mdi mdi-eye-off-outline"></span></button>
|
||||
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this character’s lines from the script" aria-label="Hide this character’s 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 & 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 & skip this character’s lines" aria-label="Ignore this character’s lines"><span class="mdi mdi-eye-off-outline"></span></button>
|
||||
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this character’s lines from the script" aria-label="Hide this character’s 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 & 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 & 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 & 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} | ||||