Compare commits

...

10 Commits

Author SHA1 Message Date
a325f53e62 feat: Implement sortable Table View and Inspector drag-and-drop support
- Added a new 'Table View' button to the library sort bar.
- Implemented a CSS grid layout to display voice properties in a high-density table.
- Added sortable column headers (Name, Lang, Gender, Speed, dBFS, Length, Rating, Source, Seed, Note, Tags, Active).
- Aligned CSS grid to account for the bulk edit checkbox injection.
- Added drag-and-drop profile image support to the Inspector's large avatar.
- Ensured picture updates instantly synchronize across the List, Table, and Inspector views.
2026-06-22 14:40:39 +02:00
5becb4341f Release v1.8.0 2026-06-22 10:44:02 +02:00
88c74ab826 feat: persistent seed pinning and batch seeds 2026-06-22 10:43:56 +02:00
36437478a6 feat: Read Aloud reader, multi-voice audiobook casting, character sheets (v1.7.0)
Read Aloud (new "Vorlesen" tab):
- PDF (real page render + overlay highlight) / TXT reader with live word
  highlighting, voice + speed, per-sentence synthesis-state colours, zoom
  (fit-width/height, two-page, ±), resume, and a server-side book library
  (syncs across devices; per-unit MP3 audio fetched on demand).

Book -> multi-speaker audiobook:
- "Cast as audiobook" attributes dialogue to characters via the LLM
  (guillemet/quote-style aware, turn-taking, recent-context), with a
  deterministic speech-tag fallback. Editable preview, non-blocking live
  casting panel, then auto-saved as a reopenable Script Rehearser play.
- Audiobook export: synthesise every cast line -> one MP3 per chapter.

Character sheets:
- LLM-extracted, self-filling RPG-style sheets (with page+quote sources)
  in both Read Aloud and the Rehearser.

Also: MP3 storage + per-page/sentence export, voice-library "Precompute
embeddings" pre-warm, German "Vorlesen" i18n + flag language toggle,
large-PDF performance (lazy raster, buffer/canvas eviction, yielded parse),
and the Seed Finder changelog entry.

New: routes/reader.py, POST /api/attribute-dialogue, POST /api/character-sheets,
static/js/{reader,audiobook,character-sheets}.js, static/sections/s-reader.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:08:46 +02:00
fff6e7e096 feat: add Seed Finder panel to voice inspector
Adds a collapsible "🎲 Seed Finder" panel to the voice inspector pane.
Select a voice, expand the panel, set a seed range (default 1–15), and
click Run to generate audio samples with different seeds.

Each result row has Play and "Use this seed" buttons. "Use this seed"
saves the chosen seed to the faster-qwen3-tts voices.json via the new
/api/tts-voice-seed proxy endpoint (which calls /voice-seed on the TTS
server). The saved row gets a ✓ marker.

Test sentence auto-selects DE/EN mixed or EN-only based on voice name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 14:53:51 +02:00
d4b176e4d1 Bake VERSION into Docker image so About page shows correct version
Without COPY VERSION, _read_version() fell back to "0.0.0". The volume
mount is a runtime override; the baked-in copy is the reliable baseline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 04:03:10 +02:00
1af02f8d1b Update CHANGELOG for chunked TTS fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 03:58:31 +02:00
345bec8a05 Fix chunked TTS failing on newline-delimited text (e.g. German bullet lists)
splitTextIntoChunks only split on sentence terminators (.!?), so structured
text separated by newlines was never chunked — the full page went as one
request, causing a TCP timeout that the browser reported as "Failed to fetch".

Fix: process each line individually before applying the sentence regex so
newline-separated segments become their own split points. Also move
generation.js from deferred batch E into the main feature batch C so
generateChunkedTts is always defined before the user can click Generate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 03:41:31 +02:00
e6cadd26a3 Add pedalboard to requirements for audio effects
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 03:41:30 +02:00
8ce84e5523 Fix voice_clone/streaming backends and POST /v1/audio/voices (port from -1 repo)
- voice_clone and streaming now return active library voices instead of
  probing upstream /v1/models (which only exposed 8 model entries)
- Accept POST on /v1/audio/voices for clients that probe with POST
- Fall back to first active voice when voice field missing in /v1/audio/speech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 02:23:43 +02:00
31 changed files with 4013 additions and 75 deletions

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ APP_ERKLÄRUNG.md
config/settings.json config/settings.json
config/voice_design_presets.json config/voice_design_presets.json
config/tts_routes.json config/tts_routes.json
config/reader_library/
logs/*.log logs/*.log
logs/*.log.* logs/*.log.*

View File

@ -9,6 +9,61 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.8.0] — 2026-06-22
### Added
- **Seed Finder — pin a specific seed**: a direct *“fix this voice to a specific seed”* control (type a seed → **Pin**, or **Unpin** to go back to random) that saves straight to the TTS server without generating anything — handy when you already know the seed you want.
- **Seed Finder — persistent pinned seed indicator**: the pinned seed is now saved locally in the voice's metadata and displayed prominently in the voice inspector header. It automatically restores the "Pin seed" input when reopening the voice.
- **Seed Finder — batch all voices**: a **Batch seeds** button in the voice toolbar pre-generates and caches Seed Finder samples for every active voice. An **in-app dialog** (no browser pop-ups) lets you set the seed range with a live sample-count estimate, then shows live progress (current voice/seed) with cancel. Runs sequentially, **skips already-cached seeds** (resumable), and feeds the same cache the per-voice Seed Finder reads.
### Changed
- **Seed Finder — samples are cached**: generated seed WAVs are saved in the browser (IndexedDB) keyed by voice + test sentence + backend, so reopening a voice shows previous results instantly and re-running only generates the **missing/failed** seeds instead of all of them. Added a **Clear saved** button to drop a voice's cache.
- **Seed Finder — better test sentence**: a shorter default sentence that exercises all German umlauts (ä ö ü ß), numbers, and a few English words — quicker to generate and more revealing of a voice's character.
### Fixed
- **Couldn't change a voice's displayed name**: the big name in the voice inspector was just the **last segment of the voice ID** (so `DE_F_Privat_Laura_01` showed as `01`), and double-clicking it renamed the *ID*, not the shown name. Double-clicking the header now edits a real **display name** (saved to the voice's metadata via `/api/voice/meta`, persists across reloads) and pre-fills with the current name; the separate **edit ID** button still renames the underlying voice ID. (Designed voices without a reference file don't support a stored display name yet.)
- **About page showed `v0.0.0` and a stale changelog**: the deployment stack wasn't mounting `VERSION`/`CHANGELOG.md`, so the container had no live version file (fell back to `0.0.0`) and served the image's baked changelog. Both files are now bind-mounted in `docker-compose.yml` and `portainer-stack.yml`, so the About page reflects the running version and changelog.
- **Seed Finder — “Failed to fetch” seeds**: long per-seed generations that intermittently dropped now **auto-retry** (2 attempts), each failed seed gets its own **Retry** button, and because successful seeds are cached, a second Run fills only the gaps rather than redoing everything.
## [1.7.0] — 2026-06-21
### Added
- **Seed Finder** — in **My Voices**, each voice's inspector has a 🎲 **Seed Finder** panel: generate a sample for a range of seeds (the TTS engine produces a slightly different take per seed), play each result, and click **★ Use seed N** to pin your favourite — saved immediately to the TTS server's `voices.json`. New `/api/tts-voice-seed` proxy, module `static/js/seed-finder.js`.
- **Voice library — Precompute embeddings**: a **Precompute** button warms all active voices so the TTS engine builds and caches each voice's speaker embedding (`.pt`) ahead of time, making the *first* playback of a voice instant (better Time-To-First-Audio) instead of paying the one-time analysis cost on first use. Runs with bounded concurrency, progress, and cancel. (The faster-qwen3-tts engine already prefers a cached `.pt` and auto-creates it from the reference wav when missing — the wav stays the source of truth; this just pre-warms the cache.)
- **Character sheets** — a **Character sheets** button in both **Read Aloud** and the **Script Rehearser** uses your configured LLM to extract actor-facing, RPG-style profiles for every character: Archetype, Physical Stats (metric-only), Alignment & Ethos, Core Attributes (highest/lowest), Trained Skills, Signature Inventory, Dark Secret / Fatal Flaw, Conflict Style, and Win Condition. Deduced details are marked with `*`, characters are grouped **main vs supporting**, and each sheet cites its **sources** (page number from the PDF + a short verbatim quote). Long texts are processed in chunks and merged per character (fields filled in, inventory/sources de-duplicated). Results render as scannable cards in an overlay with **Copy as Markdown**, and are cached so re-opening is instant. New endpoint `POST /api/character-sheets`, module `static/js/character-sheets.js`.
- **Book → multi-speaker audiobook** — a **Cast as audiobook** button in Read Aloud turns a novel into a cast-able script. An LLM scans the current scope (selection / page range / whole book, chunked with a running character roster so the same speaker keeps one name throughout) and attributes every segment to a **Narrator** or a **character**, with a per-line **emotion**. New endpoint `POST /api/attribute-dialogue` and module `static/js/audiobook.js`; reuses the rehearser's casting, per-line tone, and synthesis. Failed chunks fall back to narration so a book always casts; progress is shown and cancellable.
- **Live casting view**: while attributing, a **wide, non-blocking, minimisable panel** shows a scrolling feed of each line with its assigned speaker (and emotion) plus a **character roster that fills up** with per-character line counts — instead of a bare modal progress bar. You can **minimise it and keep using the app** (any tab), then re-open to watch progress; when it finishes it parks as a **"✓ Review & cast"** panel rather than auto-popping, so it waits for you if you wandered off.
- **Calmer messaging**: a passage the LLM can't attribute is no longer shown as a red "attribution failed: Error" toast — it's a quiet "read by the narrator" note in the feed, with a single neutral summary ("N passages had no detected dialogue") in the review step.
- **Editable preview**: before handing off, a review overlay lists every segment with an editable **speaker** (autocompletes from detected characters) and **emotion** so mis-attributions are fixed in seconds; "Open in Rehearser" applies the edits and lands you at the Cast phase.
- **Audiobook export** (rehearser): an **Audiobook** button synthesises every cast line as MP3 (bounded concurrency, progress, cancel) and downloads **one MP3 per chapter** (split on Chapter/Kapitel/Part/Prologue… headings or act/scene markers), or a single file when no chapters are detected.
- **Saved as a reopenable rehearsal**: handing the cast off to the Rehearser now also **saves it to the Script Rehearser library (Bibliothek)** automatically, so the attributed script + cast + per-line emotions persist — reopen it anytime to change speakers/voices/lines and synthesise or export the audiobook.
- **Read Aloud** — a new sidebar tab that turns the app into a text-to-speech document reader. Import a **PDF** (rendered to its real page layout via pdf.js) or a **.txt / .md** file, pick any voice + backend and a **reading speed** (0.5×2×), then press play: the document is read sentence-by-sentence while the **word being spoken is highlighted** in place (overlay box on the PDF page, inline highlight in text mode), with the view auto-scrolling to follow. Click any word to jump there. Reuses the rehearser's word-timing + pdf.js loader and the existing `/api/tts-preview` pipeline — no backend changes. New files `static/sections/s-reader.html` and `static/js/reader.js`.
- **Read Aloud — synthesis-state overlay**: every sentence is colour-coded by state — **red** (not synthesised), **yellow** (synthesising), **green** (ready/cached), **blue** (currently reading) — shown as a translucent overlay on the PDF page and as a tint in text mode, with a legend.
- **Read Aloud — PDF zoom controls**: Fit width, Fit height, Two-page spread, and zoom in/out with a live percentage. Word geometry is stored scale-independently so zoom re-renders instantly and highlights stay aligned; fit modes track window resizes.
- **Read Aloud — resume**: the last reading position is remembered per document, so re-opening the same file resumes where you left off.
- **Read Aloud — book library**: **Save to library** stores the original PDF/text together with its **synthesised audio** and reading position in the browser (IndexedDB). A "My books" shelf lists saved documents with audio- and read-progress bars; reopen one to continue right where you left off with the already-synthesised pages intact — handy for working through long books. Reading position auto-saves on pause / stop / leaving the tab.
- **Read Aloud — MP3 storage & export**: audio is now synthesised and stored as **MP3** (far smaller than WAV, so books fit comfortably in the browser library). An **Export MP3** control downloads the synthesised audio as **one file per page** (sections combined) or **one file per sentence**, with meaningful filenames like `Title - p01 - 03.mp3` / `Title - p01.mp3`. Any not-yet-synthesised sentences in scope are rendered first.
- **Read Aloud — voice consistency**: addresses the slight timbre/prosody drift you hear when each sentence is generated separately. A **"Voice consistency"** selector synthesises in larger continuous chunks — *per sentence* (responsive), *per paragraph* (steadier), or *per page* (steadiest) — so a whole passage is one generation. Optional **Seed** and **Temperature** inputs pin the generation (forwarded to backends that support them, with graceful fallback), **Normalise loudness** evens out volume between chunks on playback, and a backend hint flags cloned/zero-shot engines that re-sample per request and suggests remedies. Chunk mode + seed/temperature/normalise are saved with library books.
- **Read Aloud — synthesise ahead**: a **Synthesise** button pre-renders audio for gap-free reading, scoped to **all**, a **page range** (PDF), or a **click-selected sentence range**. Select mode is a guided, persistent step flow — click a start sentence (it pulses as the anchor), then the end; the mode stays active with step hints so you can keep refining, and you leave it with the **Done** button or **Esc**. Synthesis runs with bounded concurrency, shows progress, and can be cancelled; the synthesis-state colours fill in green as each sentence completes.
### Changed
- **Audiobook casting — smarter speaker attribution**: the LLM prompt now reasons about **conversational turn-taking** (in a two-person exchange speakers alternate, so untagged lines are attributed by context rather than dumped as “Unknown”), and each passage is given the **recent dialogue** from the previous one so a conversation continues correctly across passage boundaries. The deterministic fallback (used when the LLM is unavailable) also got a conservative two-person turn-taking fill and a stop-list that rejects common German non-name words (Sofort, Stimme, Frage, Plötzlich…), so it no longer invents bogus characters.
- **Character sheets — self-filling across the book**: sheets now build up progressively — each passage receives the **sheet-so-far** (with which fields each character still needs) and the model fills gaps and refines instead of starting from scratch, so details accumulate as more of the book is read.
- **Read Aloud — library now lives on the server (syncs across devices)**: saved books, their synthesised audio, and reading position were previously stored only in the browser (IndexedDB), so a book saved on the laptop never appeared on the desktop. The library now persists under the server's config volume (`reader_library/<id>/` with `meta.json`, the source document, and per-unit MP3s) via new `/api/reader/docs…` endpoints. Any device pointed at the same server sees the same "My books" shelf; opening a book is instant and its audio streams **per chunk on demand** (nothing is bulk-downloaded), and saves stay incremental (only new chunks upload).
- **Language switcher** — replaced the sidebar language dropdown with a **flag toggle** next to the "Voice Creator" headline (click to switch interface language). Added German strings for **Read Aloud** ("Vorlesen") and its UI.
### Performance
- **Read Aloud — memory & smoothness for long books**: decoded audio (uncompressed PCM) is now kept only for a small window around the playhead and re-decoded from the cached MP3 on demand; off-screen PDF page canvases are released and re-rastered on return — together these bound memory on big books (previously both grew unbounded and could crash long sessions). The next chunk is **pre-decoded** during playback for gapless transitions, transport actions no longer scan every unit (single tracked "reading" index), PDF sentences/units are built **incrementally per page** (no end-of-parse spike), and library **saves are incremental** — only newly-synthesised chunks are written (a separate per-unit audio store), instead of rewriting the whole book each save.
### Fixed
- **Audiobook casting — German (and other) quote styles not recognised**: dialogue marked with German guillemets `»…«` / `„…“` / `›…‹`, French `«…»`, curly `“…”`, CJK `「…」`, or em-dash speech was treated as narration, so books like German novels cast everything to the narrator. The LLM prompt now explicitly handles all these styles (with guillemets called out), passages with **no quotation marks skip the LLM entirely** (so genuine narration isn't shown as a failure), PDF line-break **hyphenation is mended** (`Schwer- tes` → `Schwertes`) for clean speech, and if the LLM call fails on a passage that *does* contain quotes, a **deterministic fallback** splits out the dialogue **and attributes speakers from speech tags** (`»…«, sagte Riskan` → Riskan; pronouns rejected) so the book stays castable with real names even when the LLM is offline.
- **Read Aloud — auto-scroll**: while reading, the view now scrolls only the document pane instead of the whole window, so the currently-spoken line no longer slides up under the app header out of view.
- **Read Aloud — backend dropdown stuck on "Checking…"**: the reader's TTS-backend select is now populated by the shared backend refresh and fetched on demand when the section opens, so it fills reliably even if backends finish loading after you're already on the tab.
- **Read Aloud — large PDFs froze the page** ("this page is not responding"): the page-parse loop now yields to the browser periodically (with a "Reading PDF… page x / n" indicator), and per-sentence status overlays are created lazily per page instead of all at once. A 60-page book now imports with a max main-thread stall of ~40 ms (was multi-second), creating only the visible pages' overlays.
- **Chunked TTS — "Failed to fetch" on long text**: `splitTextIntoChunks` only split on sentence terminators (`.!?`), so newline-delimited text (e.g. German bullet lists or care-plan notes) was never split — the full page was sent as one request, causing a TCP timeout that the browser surfaced as "Failed to fetch". Fixed by processing each line individually before applying the sentence regex. Also moved `generation.js` from deferred batch E into the main feature batch so `generateChunkedTts` is always defined before the user can click Generate.
---
## [1.6.0] — 2026-06-03 ## [1.6.0] — 2026-06-03
### Added ### Added
@ -338,7 +393,8 @@ Initial feature-complete release.
--- ---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.2.0...HEAD [Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...HEAD
[1.8.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.7.0...v1.8.0
[1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0 [1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.0.0...v1.1.0 [1.1.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/releases/tag/v1.0.0 [1.0.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/releases/tag/v1.0.0

View File

@ -24,6 +24,7 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt
WORKDIR /app WORKDIR /app
COPY server.py . COPY server.py .
COPY CHANGELOG.md . COPY CHANGELOG.md .
COPY VERSION .
COPY core/ core/ COPY core/ core/
COPY routes/ routes/ COPY routes/ routes/
COPY static/ static/ COPY static/ static/

View File

@ -1 +1 @@
1.6.0 1.8.0

View File

@ -38,7 +38,7 @@ _SETTINGS_KEYS = {
"llm_url", "llm_model", "llm_url", "llm_model",
# Browser-persistent UI state # Browser-persistent UI state
"engine_local_urls", "engine_container_names", "custom_engine_cards", "engine_local_urls", "engine_container_names", "custom_engine_cards",
"refine_llm_url", "conv_llm_url", "refine_llm_url", "conv_llm_url", "seed_finder_text",
} }
# ── TTS stability defaults ──────────────────────────────────────────────────── # ── TTS stability defaults ────────────────────────────────────────────────────
@ -275,6 +275,7 @@ def _load_settings() -> dict:
"custom_engine_cards": [], "custom_engine_cards": [],
"refine_llm_url": "", "refine_llm_url": "",
"conv_llm_url": "", "conv_llm_url": "",
"seed_finder_text": "",
} }
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
try: try:

View File

@ -15,7 +15,7 @@ services:
- "${DOCKER_GID:-988}" - "${DOCKER_GID:-988}"
volumes: volumes:
- ${VOICE_HOST_DIR:-./voices}:/voices:rw - ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes - ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
- ./logs:/logs:rw # application logs - ./logs:/logs:rw # application logs
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
@ -24,6 +24,7 @@ services:
- ./core:/app/core:ro - ./core:/app/core:ro
- ./routes:/app/routes:ro - ./routes:/app/routes:ro
- ./VERSION:/app/VERSION:ro - ./VERSION:/app/VERSION:ro
- ./CHANGELOG.md:/app/CHANGELOG.md:ro
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1

View File

@ -30,7 +30,7 @@ services:
- "988" - "988"
volumes: volumes:
- /home/sparky/Projekte/TTS_Voices:/voices:rw - /home/sparky/Media/_Sounds/TTS_Voices:/voices:rw
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/config:/home/app/.config/tts-voice-creator:rw - /home/sparky/Docker/tts-voice-creator-clone-and-design-2/config:/home/app/.config/tts-voice-creator:rw
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/logs:/logs:rw - /home/sparky/Docker/tts-voice-creator-clone-and-design-2/logs:/logs:rw
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
@ -39,6 +39,8 @@ services:
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/core:/app/core:ro - /home/sparky/Docker/tts-voice-creator-clone-and-design-2/core:/app/core:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/routes:/app/routes:ro - /home/sparky/Docker/tts-voice-creator-clone-and-design-2/routes:/app/routes:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/static:/app/static:ro - /home/sparky/Docker/tts-voice-creator-clone-and-design-2/static:/app/static:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/VERSION:/app/VERSION:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/CHANGELOG.md:/app/CHANGELOG.md:ro
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1

View File

@ -3,6 +3,7 @@ uvicorn[standard]>=0.29.0
python-multipart>=0.0.9 python-multipart>=0.0.9
pydub>=0.25.1 pydub>=0.25.1
numpy>=1.26.0 numpy>=1.26.0
pedalboard>=0.9.0
scipy>=1.12.0 scipy>=1.12.0
requests>=2.31.0 requests>=2.31.0
yt-dlp>=2024.3.10 yt-dlp>=2024.3.10

View File

@ -435,6 +435,227 @@ def _extract_json_block(text: str) -> str:
return "" return ""
@router.post("/api/character-sheets")
async def character_sheets(request: Request):
"""Extract actor-facing RPG-style character sheets from a passage.
Body: {text, known_characters:[...], language, llm_url, model}
The text may contain "[p.N]" page markers so the model can cite sources.
Returns: {sheets:[{name, aliases, archetype, physical, alignment,
attribute_high, attribute_low, skills, inventory:[...], secret,
conflict_style, win_condition, tier:"main"|"supporting",
sources:[{page, quote}]}], characters:[names]}
Deduced (not explicit) values are marked with a trailing " *".
"""
data = await request.json()
text: str = (data.get("text") or "").strip()
known: list = data.get("known_characters") or []
existing: str = (data.get("existing") or "").strip() # partial sheets so far (progressive fill)
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not text:
raise HTTPException(400, "No text provided")
lang_hint = f" The text language is {language}; write the sheet in that language." if language else ""
system = (
"You are an expert dramaturge and tabletop RPG game master building character sheets that FILL UP "
"as a book is read passage by passage. Read this passage and extract playable, action-oriented "
"character sheets — sheets an actor can use to immediately know how to PLAY the character.\n"
"PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any "
"NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or "
"blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty "
"if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue "
"and actions when reasonable, and mark any deduced value with a trailing ' *'.\n"
f"{lang_hint}\n"
"For each character output these fields:\n"
"- name, aliases\n"
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
"- physical: age, build, vocal quality, posture. Use ONLY the metric system for any height/weight.\n"
"- alignment: strict moral code + the one line they will never cross\n"
"- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility…)\n"
"- skills: what they are demonstrably good at in the story\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"
"- tier: 'main' or 'supporting'\n"
"- sources: array of {page, quote} — the page number from the nearest [p.N] marker and a short "
"verbatim quote that supports the sheet (1-3 entries). Use null page if unknown.\n"
"Reuse the EXACT names from the known-characters list for returning characters. "
"Respond with STRICT JSON only:\n"
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","alignment":"",'
'"attribute_high":"","attribute_low":"","skills":"","inventory":[],"secret":"",'
'"conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":""}]}]}\n/no-think'
)
user = (
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
+ ("Sheets so far (fill gaps / refine; keep solid facts):\n" + existing + "\n\n" if existing else "")
+ "Passage:\n" + text
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.4,
"max_tokens": 3500,
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=240,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
except Exception as e:
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
sheets = []
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("sheets"), list):
sheets = parsed["sheets"]
break
except Exception:
continue
clean, names = [], []
for s in sheets:
if not isinstance(s, dict):
continue
name = str(s.get("name") or "").strip()
if not name:
continue
inv = s.get("inventory")
if isinstance(inv, str):
inv = [x.strip() for x in inv.split(",") if x.strip()]
elif not isinstance(inv, list):
inv = []
src = s.get("sources") if isinstance(s.get("sources"), list) else []
s.update({"name": name, "inventory": inv[:3],
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
"sources": src[:3]})
clean.append(s)
names.append(name)
return {"sheets": clean, "characters": names}
@router.post("/api/attribute-dialogue")
async def attribute_dialogue(request: Request):
"""Split a prose passage into attributed segments for a multi-voice audiobook.
Body: {text, known_characters:[...], language, llm_url, model}
Returns: {segments:[{speaker, type:"narration"|"dialogue", text, emotion}], characters:[names]}
The frontend calls this per chunk, passing the running character roster so the
same speaker keeps the same name across the whole book.
"""
data = await request.json()
text: str = (data.get("text") or "").strip()
known: list = data.get("known_characters") or []
recent: str = (data.get("recent") or "").strip() # last few attributed lines, for continuity
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not text:
raise HTTPException(400, "No text provided")
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
system = (
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
"Split the passage into consecutive segments in reading order. For each segment output:\n"
"- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. "
"Use 'Unknown' ONLY as an absolute last resort.\n"
"- type: 'narration' or 'dialogue'\n"
"- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration\n"
"- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration\n"
f"{lang_hint}\n"
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\n"
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
"French «...» (pointing outward); single ...; CJK 「...」 『...』; and em-dash speech where a line "
"starts with — or (Spanish/French/Polish style).\n"
"German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n"
"ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n"
"1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns "
"(er/sie/he/she) to the actual name from nearby context.\n"
"2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES "
"every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.\n"
"3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the "
"'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the "
"known-characters list. Reuse the EXACT known names.\n"
"4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and "
"context — this should be rare. Prefer the most likely named character over 'Unknown'.\n"
"RULES:\n"
"- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.\n"
"- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts "
"into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.\n"
"- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.\n"
"Respond with STRICT JSON only:\n"
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}\n/no-think'
)
user = (
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
+ ("Recent dialogue (the immediately preceding lines — continue the same conversation/turn-taking):\n" + recent + "\n\n" if recent else "")
+ "Passage:\n" + text
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": min(4096, max(700, len(text) // 2 + 800)),
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=180,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
except Exception as e:
raise HTTPException(502, f"LLM attribution failed: {e}")
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
segments = []
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("segments"), list):
segments = parsed["segments"]
break
except Exception:
continue
# Normalise + collect speaker roster
clean, chars = [], []
for seg in segments:
if not isinstance(seg, dict):
continue
t = str(seg.get("text") or "").strip()
if not t:
continue
sp = str(seg.get("speaker") or "Narrator").strip() or "Narrator"
typ = "dialogue" if str(seg.get("type") or "").lower().startswith("dial") else "narration"
if typ == "narration":
sp = "Narrator"
emo = str(seg.get("emotion") or "").strip()
clean.append({"speaker": sp, "type": typ, "text": t, "emotion": emo})
if typ == "dialogue" and sp.lower() != "narrator" and sp not in chars:
chars.append(sp)
return {"segments": clean, "characters": chars}
# ── Audio effects ───────────────────────────────────────────────────────────── # ── Audio effects ─────────────────────────────────────────────────────────────
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes: def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:

172
routes/reader.py Normal file
View File

@ -0,0 +1,172 @@
"""Server-side Read Aloud library so saved books sync across devices.
Stores each document under the writable config volume:
<CONFIG_DIR>/reader_library/<id>/
meta.json metadata (title, kind, idx, voice, counts, )
source.pdf | source.txt the original document (for re-rendering)
audio/<idx>.mp3 per-unit synthesised audio (incremental)
Endpoints are deliberately small (file I/O) and mirror the previous IndexedDB
shape so the frontend swap is mechanical.
"""
import json
import shutil
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from core.constants import CONFIG_DIR
router = APIRouter()
_LIB = CONFIG_DIR / "reader_library"
def _lib() -> Path:
_LIB.mkdir(parents=True, exist_ok=True)
return _LIB
def _doc_dir(doc_id: str) -> Path:
# ids are server-generated uuid hex; reject anything else (path safety)
if not doc_id or not doc_id.isalnum() or len(doc_id) > 40:
raise HTTPException(400, "Bad document id")
return _lib() / doc_id
def _read_meta(d: Path) -> dict:
try:
return json.loads((d / "meta.json").read_text("utf-8"))
except Exception:
return {}
def _audio_indices(d: Path) -> list[int]:
ad = d / "audio"
if not ad.is_dir():
return []
out = []
for f in ad.glob("*.mp3"):
try:
out.append(int(f.stem))
except ValueError:
pass
return sorted(out)
@router.get("/api/reader/docs")
async def reader_list_docs():
lib = _lib()
docs = []
for d in lib.iterdir():
if not d.is_dir():
continue
meta = _read_meta(d)
if not meta:
continue
meta["id"] = d.name
meta["synthCount"] = len(_audio_indices(d))
docs.append(meta)
docs.sort(key=lambda m: m.get("updated") or "", reverse=True)
return {"docs": docs}
@router.post("/api/reader/docs")
async def reader_save_doc(request: Request):
meta = await request.json()
doc_id = str(meta.get("id") or "").strip()
if doc_id:
d = _doc_dir(doc_id)
if not d.is_dir():
raise HTTPException(404, "Document not found")
else:
doc_id = uuid.uuid4().hex
d = _lib() / doc_id
d.mkdir(parents=True, exist_ok=True)
meta["created"] = meta.get("created")
meta.pop("id", None)
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), "utf-8")
return {"id": doc_id}
@router.get("/api/reader/docs/{doc_id}")
async def reader_get_doc(doc_id: str):
d = _doc_dir(doc_id)
meta = _read_meta(d)
if not meta:
raise HTTPException(404, "Document not found")
meta["id"] = doc_id
meta["audioIdx"] = _audio_indices(d)
return meta
@router.delete("/api/reader/docs/{doc_id}")
async def reader_delete_doc(doc_id: str):
d = _doc_dir(doc_id)
if d.is_dir():
shutil.rmtree(d, ignore_errors=True)
return {"ok": True}
@router.patch("/api/reader/docs/{doc_id}/progress")
async def reader_progress(doc_id: str, request: Request):
d = _doc_dir(doc_id)
meta = _read_meta(d)
if not meta:
raise HTTPException(404, "Document not found")
body = await request.json()
if "idx" in body:
meta["idx"] = body["idx"]
if "updated" in body:
meta["updated"] = body["updated"]
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), "utf-8")
return {"ok": True}
@router.put("/api/reader/docs/{doc_id}/source")
async def reader_put_source(doc_id: str, request: Request, ext: str = "pdf"):
d = _doc_dir(doc_id)
if not d.is_dir():
raise HTTPException(404, "Document not found")
ext = "txt" if ext.lower() == "txt" else "pdf"
body = await request.body()
# clear any existing source of the other type
for old in d.glob("source.*"):
old.unlink()
(d / f"source.{ext}").write_bytes(body)
return {"ok": True, "bytes": len(body)}
@router.get("/api/reader/docs/{doc_id}/source")
async def reader_get_source(doc_id: str):
d = _doc_dir(doc_id)
for ext, media in (("pdf", "application/pdf"), ("txt", "text/plain")):
f = d / f"source.{ext}"
if f.is_file():
return FileResponse(str(f), media_type=media)
raise HTTPException(404, "Source not found")
@router.get("/api/reader/docs/{doc_id}/audio")
async def reader_list_audio(doc_id: str):
return {"idx": _audio_indices(_doc_dir(doc_id))}
@router.put("/api/reader/docs/{doc_id}/audio/{idx}")
async def reader_put_audio(doc_id: str, idx: int, request: Request):
d = _doc_dir(doc_id)
if not d.is_dir():
raise HTTPException(404, "Document not found")
(d / "audio").mkdir(exist_ok=True)
(d / "audio" / f"{idx}.mp3").write_bytes(await request.body())
return {"ok": True}
@router.get("/api/reader/docs/{doc_id}/audio/{idx}")
async def reader_get_audio(doc_id: str, idx: int):
f = _doc_dir(doc_id) / "audio" / f"{idx}.mp3"
if not f.is_file():
raise HTTPException(404, "Audio not found")
return FileResponse(str(f), media_type="audio/mpeg")

View File

@ -248,13 +248,14 @@ def _active_library_voice_options(settings: dict) -> list[dict]:
"duration": dur, "duration": dur,
"has_ref": has_ref, "has_ref": has_ref,
"has_transcript": bool(transcript), "has_transcript": bool(transcript),
"seed": meta.get("seed"),
}) })
return voices return voices
def _fetch_backend_voices(settings: dict, backend: str) -> list: def _fetch_backend_voices(settings: dict, backend: str) -> list:
backend = _clean_preview_backend(backend) backend = _clean_preview_backend(backend)
if backend in {"nvidia_zeroshot", "nvidia_flow"}: if backend in {"voice_clone", "streaming", "nvidia_zeroshot", "nvidia_flow"}:
return _active_library_voice_options(settings) return _active_library_voice_options(settings)
tts_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") tts_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
key = (settings.get("voice_design_api_key") if backend == "voice_design" else settings.get("tts_api_key")) or "" key = (settings.get("voice_design_api_key") if backend == "voice_design" else settings.get("tts_api_key")) or ""
@ -458,6 +459,27 @@ async def tts_preview(request: Request):
backend = _clean_preview_backend(data.get("backend", "voice_clone")) backend = _clean_preview_backend(data.get("backend", "voice_clone"))
settings = _load_settings() settings = _load_settings()
# Per-request generation overrides (seed / temperature / top_p). When provided,
# force them onto the stability params for this backend so _apply_tts_extra_params
# forwards them to the engine (backends that reject them fall back gracefully).
_overrides = {}
for _k in ("seed", "temperature", "top_p"):
_v = data.get(_k)
if _v is not None and _v != "":
try:
_overrides[_k] = int(_v) if _k == "seed" else float(_v)
except (TypeError, ValueError):
pass
if _overrides:
from core.config import _tts_extra_params
settings = dict(settings)
eff = dict(_tts_extra_params(settings, backend) or {})
eff.update(_overrides)
settings["tts_stability_enabled"] = True
by_backend = dict(settings.get("tts_extra_params_by_backend") or {})
by_backend[backend] = eff
settings["tts_extra_params_by_backend"] = by_backend
if data.get("apply_persona"): if data.get("apply_persona"):
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
wav = _find_voice_audio(voice, scan_dir) wav = _find_voice_audio(voice, scan_dir)
@ -683,7 +705,7 @@ async def openai_audio_models_proxy():
return await openai_models_proxy() return await openai_models_proxy()
@router.get("/v1/audio/voices") @router.api_route("/v1/audio/voices", methods=["GET", "POST"])
async def openai_audio_voices_proxy(): async def openai_audio_voices_proxy():
models = await openai_models_proxy() models = await openai_models_proxy()
return [m["id"] for m in models["data"]] return [m["id"] for m in models["data"]]
@ -706,15 +728,17 @@ async def openai_speech_proxy(request: Request):
text=text, error="input is required", text=text, error="input is required",
) )
raise HTTPException(400, "input is required") raise HTTPException(400, "input is required")
settings = _load_settings()
if not voice: if not voice:
fallback = next((v["id"] for v in _active_library_voice_options(settings)), "")
if not fallback:
_routing_log_request( _routing_log_request(
request, status="error", app=request_app, requested_voice=original_voice, request, status="error", app=request_app, requested_voice=original_voice,
routed_voice=voice, backend="", route=None, response_format=response_format, routed_voice=voice, backend="", route=None, response_format=response_format,
text=text, error="voice is required", text=text, error="voice is required",
) )
raise HTTPException(400, "voice is required") raise HTTPException(400, "voice is required")
voice = fallback
settings = _load_settings()
voice, route = _resolve_tts_route(request_app, voice, text) voice, route = _resolve_tts_route(request_app, voice, text)
backend = _route_backend(route, voice) backend = _route_backend(route, voice)
style_instruction = str(data.get("instruct") or data.get("style_instruction") or "") style_instruction = str(data.get("instruct") or data.get("style_instruction") or "")
@ -852,3 +876,77 @@ async def openai_speech_proxy(request: Request):
@router.post("/v1") @router.post("/v1")
async def openai_speech_proxy_v1_shortcut(request: Request): async def openai_speech_proxy_v1_shortcut(request: Request):
return await openai_speech_proxy(request) return await openai_speech_proxy(request)
@router.post("/api/tts-voice-seed")
async def tts_voice_seed(request: Request):
"""Proxy to the faster-qwen3-tts /voice-seed endpoint.
Writes or clears the seed for a voice in the TTS server's voices.json.
Body: {"voice": "EN_F_NatashaNeural", "seed": 7} (seed: null to remove)
"""
data = await request.json()
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.post(
f"{tts_base}/voice-seed",
json=data,
timeout=10,
)
resp.raise_for_status()
voice_name = data.get("voice")
seed = data.get("seed")
if voice_name:
from core.voice import _find_voice_audio, _load_meta, _save_meta
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio = _find_voice_audio(voice_name, scan_dir)
if audio:
meta = _load_meta(audio)
if seed is None:
meta.pop("seed", None)
else:
meta["seed"] = seed
_save_meta(audio, meta)
return resp.json()
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
detail = ""
try:
detail = e.response.json().get("detail", "")
except Exception:
pass
raise HTTPException(e.response.status_code, detail or str(e))
@router.get("/api/seed-samples/{voice_name}")
async def list_seed_samples(voice_name: str):
"""List pre-generated seed sample numbers for a voice (batch script output)."""
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.get(f"{tts_base}/seed-samples/{voice_name}", timeout=5)
resp.raise_for_status()
return resp.json()
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
raise HTTPException(e.response.status_code, str(e))
@router.get("/api/seed-sample/{voice_name}/{seed}")
async def get_seed_sample(voice_name: str, seed: int):
"""Serve a pre-generated seed WAV file (batch script output)."""
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.get(f"{tts_base}/seed-sample/{voice_name}/{seed}", timeout=30, stream=True)
resp.raise_for_status()
return Response(content=resp.content, media_type="audio/wav")
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
raise HTTPException(e.response.status_code, str(e))

View File

@ -20,9 +20,9 @@ mkdirSync(outDir, { recursive: true });
// Must match loader.js batch C order exactly. // Must match loader.js batch C order exactly.
const MAIN = [ const MAIN = [
'voice-picker', 'voice-inspector', 'voice-sources', 'fishaudio-browser', 'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview', 'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
].map(n => join(jsDir, n + '.js')); ].map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n'); const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');

View File

@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from core.constants import STATIC_DIR, _BufferHandler from core.constants import STATIC_DIR, _BufferHandler
from routes import admin, settings, library, stt, sources, docker, tts, conversation from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@ -84,6 +84,7 @@ app.include_router(sources.router)
app.include_router(docker.router) app.include_router(docker.router)
app.include_router(tts.router) app.include_router(tts.router)
app.include_router(conversation.router) app.include_router(conversation.router)
app.include_router(reader.router)
# ── Static files ────────────────────────────────────────────────────────────── # ── Static files ──────────────────────────────────────────────────────────────

View File

@ -8027,16 +8027,25 @@ async function mergeWavBlobs(blobs) {
function splitTextIntoChunks(text, maxLen = 800) { function splitTextIntoChunks(text, maxLen = 800) {
const abbrev = /\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|e\.g|i\.e)\.\s/g; const abbrev = /\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|e\.g|i\.e)\.\s/g;
const safe = text.replace(abbrev, m => m.replace('.', '\x00')); const safe = text.replace(abbrev, m => m.replace('.', '\x00'));
const parts = safe.match(/[^.!?]+[.!?]+\s*/g) || [];
const last = safe.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
if (last) parts.push(last);
const restore = s => s.replace(/\x00/g, '.'); const restore = s => s.replace(/\x00/g, '.');
if (!parts.length) return [text];
// Gather segments from each line: sentences first, then any remaining line text.
// This ensures newline-delimited text (e.g. German bullet lists) gets split too.
const segments = [];
for (const line of safe.split('\n')) {
const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || [];
const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
segments.push(...sentences);
if (rest) segments.push(rest);
}
if (!segments.length) return [text];
const chunks = []; const chunks = [];
let cur = ''; let cur = '';
for (const p of parts) { for (const seg of segments) {
if ((cur + p).length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = p; } const joined = cur ? cur + ' ' + seg.trim() : seg.trim();
else cur += p; if (joined.length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = seg.trim(); }
else cur = joined;
} }
if (cur.trim()) chunks.push(restore(cur.trim())); if (cur.trim()) chunks.push(restore(cur.trim()));
return chunks.length ? chunks : [text]; return chunks.length ? chunks : [text];

View File

@ -26,7 +26,8 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css?v=1.8.0-3">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
<link rel="preload" as="style" <link rel="preload" as="style"
@ -146,6 +147,7 @@
<div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div> <div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div> <div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
</div> </div>
<div class="nav-item" data-nav-section="s-reader" onclick="navTo('s-reader')"> <span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span> Read Aloud</div>
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div> <div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div> <div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
@ -259,6 +261,7 @@
<section class="page-section" id="s-settings" style="display:none"></section> <section class="page-section" id="s-settings" style="display:none"></section>
<section class="page-section" id="s-llms" style="display:none"></section> <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-rehearser" style="display:none"></section>
<section class="page-section" id="s-reader" style="display:none"></section>
<section class="page-section" id="s-conversation" style="display:none"></section> <section class="page-section" id="s-conversation" style="display:none"></section>
</main> </main>
@ -272,7 +275,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js"></script> <script src="/static/loader.js?v=1.8.0-2"></script>
</body> </body>
</html> </html>

468
static/js/audiobook.js Normal file
View File

@ -0,0 +1,468 @@
// ── Book → multi-speaker audiobook ──────────────────────────────────────────
//
// Bridges the Read Aloud reader and the Script Rehearser: an LLM scans the
// document (in the current scope — selection / page range / whole book),
// attributes every segment to a speaker ("Narrator" or a character) with an
// emotion, then hands the result to the Script Rehearser as a cast-able script
// so each character gets its own voice. The rehearser is the editable preview:
// you fix any mis-attribution, cast voices, and synthesise there.
//
// Reuses: /api/attribute-dialogue (LLM), readerState + readerScopeIndices()
// (reader.js), parseScript / detectCharacters / rehState / rehDefaultLlmUrl
// (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js).
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
const _audiobook = { running: false, cancel: false };
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
// German »...«/„...“, French «...», single .../..., CJK 「...」『...』, em-dash speech.
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「]|(?:^|\n)\s*[—–]\s/;
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
// so the audiobook reads cleanly and speaker tags aren't split.
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
const AB_NOTNAME = new Set([
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
// common sentence-initial adverbs / interjections / abstractions that are NOT characters
'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort',
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich',
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
function audiobookGuessSpeaker(after, before) {
let m;
const ok = n => (n && !AB_NOTNAME.has(n)) ? n : null;
// NOTE: no 'i' flag — names must be genuinely capitalized; German speech verbs
// after a quote are lowercase, so this rejects pronouns like "sagte er".
// after the quote: ", sagte Riskan" / "sagte Riskan" (verb → name)
if ((m = new RegExp('^[\\s,;-]*' + AB_SPEECH_VERBS + '\\s+(?:der|die|das|ein|eine)?\\s*' + _AB_NAME).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
// after the quote: ", Riskan sagte" (name → verb)
if ((m = new RegExp('^[\\s,;-]*' + _AB_NAME + '\\s+' + AB_SPEECH_VERBS).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
// before the quote: "Riskan sagte:" / "Riskan fragte"
if ((m = new RegExp(_AB_NAME + '\\s+' + AB_SPEECH_VERBS + '[\\s:,-]*$').exec(before || ''))) { const r = ok(m[1]); if (r) return r; }
return null;
}
// Deterministic fallback: split a passage into narration + dialogue by quotation
// spans and attribute speakers from the surrounding speech tags. Used when the LLM
// is unavailable so dialogue — and as many speakers as possible — are never lost.
const AB_QUOTE_SPAN = /»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|"([^"]+)"|「([^」]+)」|『([^』]+)』/g;
function audiobookSplitByQuotes(text) {
const spans = []; let m;
AB_QUOTE_SPAN.lastIndex = 0;
while ((m = AB_QUOTE_SPAN.exec(text))) {
spans.push({ start: m.index, end: AB_QUOTE_SPAN.lastIndex, quote: (m[1] || m[2] || m[3] || m[4] || m[5] || m[6] || m[7] || '').trim() });
}
if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }];
const out = []; let last = 0;
for (let k = 0; k < spans.length; k++) {
const sp = spans[k];
const pre = text.slice(last, sp.start);
if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' });
if (sp.quote) {
const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length);
const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown';
out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' });
}
last = sp.end;
}
const tail = text.slice(last);
if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' });
return audiobookTurnTaking(out);
}
// Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO
// distinct named speakers are established nearby (conservative: won't guess in a
// monologue, so it rarely invents a wrong name).
function audiobookTurnTaking(segs) {
let a = null, b = null; // two most recent distinct named speakers (b = latest)
for (const s of segs) {
if (s.type !== 'dialogue') continue;
if (s.speaker && s.speaker !== 'Unknown') {
if (s.speaker !== b) { a = b; b = s.speaker; }
} else if (a && b && a !== b) {
s.speaker = a; // the other of the two → alternate
const t = a; a = b; b = t; // rotate so the next Unknown alternates back
}
}
return segs;
}
function audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
function audiobookLlmModel() { return $('reh-llm-model')?.value || ''; }
function audiobookLang() { return $('reh-design-lang')?.value || ''; }
// Gather the plain text of the current reader scope (selection > page range > all).
function audiobookScopeText() {
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
const raw = readerScopeIndices().map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim();
return audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
}
// ── Progress overlay ─────────────────────────────────────────────────────────
function audiobookProgress(total) {
let ov = document.getElementById('audiobook-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'audiobook-overlay';
ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
<div class="audiobook-msg" id="audiobook-msg">Analysing</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="audiobook-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="audiobook-cancel">Cancel</button></div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; });
}
ov.hidden = false;
const fill = ov.querySelector('#audiobook-fill');
const msg = ov.querySelector('#audiobook-msg');
return {
update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; },
done() { ov.hidden = true; },
};
}
const _AB_PALETTE = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#14b8a6', '#6366f1', '#d946ef'];
// Live casting view: a scrolling feed of attributed lines + a character roster
// that fills up as speakers are discovered. Far clearer than a bare bar.
function audiobookCastView(total) {
document.getElementById('audiobook-overlay')?.remove();
// A floating, NON-blocking, minimisable panel — keep working in the app and
// come back to watch progress (no backdrop, no modal lock).
const ov = document.createElement('div');
ov.id = 'audiobook-overlay'; ov.className = 'ab-castpanel';
ov.innerHTML = `
<div class="ab-castpanel-head" id="ab-cv-head">
<span class="mdi mdi-drama-masks"></span>
<span class="ab-castpanel-title">Casting audiobook</span>
<span class="ab-castpanel-count" id="ab-cv-count">passage 0 / ${total}</span>
<span style="flex:1"></span>
<button class="ab-castpanel-btn" id="ab-cv-min" title="Minimise — keep working, come back later"><span class="mdi mdi-window-minimize"></span></button>
<button class="ab-castpanel-btn" id="ab-cv-x" title="Cancel"><span class="mdi mdi-close"></span></button>
</div>
<div class="reader-synth-track ab-castpanel-bar"><div class="reader-synth-fill" id="ab-cv-fill"></div></div>
<div class="ab-cv-body">
<div class="ab-cv-feed" id="ab-cv-feed"></div>
<div class="ab-cv-side">
<div class="ab-cv-side-head">Characters found</div>
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">listening</span></div>
</div>
</div>
<div class="ab-castpanel-foot" id="ab-cv-foot" hidden></div>`;
document.body.appendChild(ov);
const setMin = min => {
ov.classList.toggle('minimized', min);
const ic = ov.querySelector('#ab-cv-min .mdi');
if (ic) ic.className = 'mdi ' + (min ? 'mdi-window-maximize' : 'mdi-window-minimize');
};
ov.querySelector('#ab-cv-min').addEventListener('click', () => setMin(!ov.classList.contains('minimized')));
ov.querySelector('#ab-cv-x').addEventListener('click', () => { if (_audiobook.running) _audiobook.cancel = true; else ov.remove(); });
ov.querySelector('#ab-cv-head').addEventListener('click', e => { if (ov.classList.contains('minimized') && !e.target.closest('button')) setMin(false); });
const fill = ov.querySelector('#ab-cv-fill'), count = ov.querySelector('#ab-cv-count');
const feed = ov.querySelector('#ab-cv-feed'), chars = ov.querySelector('#ab-cv-chars');
const roster = new Map(); // name -> { count, color }
const colorFor = name => {
if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] });
return roster.get(name).color;
};
const renderRoster = () => {
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
chars.innerHTML = items.length
? items.map(([n, info]) => `<span class="ab-chip" style="--c:${info.color}"><span class="ab-chip-dot"></span>${escHtml(n)}<b>${info.count}</b></span>`).join('')
: '<span class="ab-cv-empty">listening…</span>';
};
const MAXROWS = 80;
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
return {
update(done) { if (fill) fill.style.width = (done / total * 100) + '%'; if (count) count.textContent = `passage ${done} / ${total}`; },
addSegments(segs) {
const frag = document.createDocumentFragment();
for (const s of segs) {
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
const row = document.createElement('div');
row.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
if (dialog) { const c = colorFor(s.speaker); roster.get(s.speaker).count++;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
} else {
row.innerHTML = `<span class="ab-cv-spk">Narrator</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
}
frag.appendChild(row);
}
feed.appendChild(frag); trim(); renderRoster();
},
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
// Park the panel in a "done" state with a Review button instead of auto-popping
// the preview — so it waits for you if you wandered off to do something else.
complete(summary, onOpen) {
if (count) count.textContent = 'done';
if (fill) fill.style.width = '100%';
ov.querySelector('#ab-cv-min').hidden = true;
ov.querySelector('#ab-cv-x').title = 'Dismiss';
const foot = ov.querySelector('#ab-cv-foot');
foot.hidden = false;
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review &amp; cast</button>`;
foot.querySelector('#ab-cv-review').addEventListener('click', () => { ov.remove(); onOpen(); });
// gently nudge the panel open if it was minimised while you were away
ov.classList.add('ab-castpanel-done');
},
done() { ov.remove(); },
};
}
// ── Attribution → script handoff ─────────────────────────────────────────────
async function audiobookCast() {
if (_audiobook.running) return;
const text = audiobookScopeText();
if (!text) { toast('Import a document first', 'error'); return; }
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
const chunks = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
: [text];
_audiobook.running = true; _audiobook.cancel = false;
const view = audiobookCastView(chunks.length);
const llm_url = audiobookLlmUrl(), model = audiobookLlmModel(), language = audiobookLang();
const allSegments = [];
const roster = [];
let narrationOnly = 0; // passages with no quotes at all — legitimately all narration
let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
try {
for (let i = 0; i < chunks.length; i++) {
if (_audiobook.cancel) break;
view.update(i);
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
if (!audiobookHasDialogue(chunks[i])) {
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' };
allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
continue;
}
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown')
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
let data = null;
try {
const r = await fetch('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), recent, language, llm_url, model }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch (_) { data = null; }
let segs = data && Array.isArray(data.segments) ? data.segments : [];
if (!segs.length) {
// LLM unavailable or returned nothing, but this passage HAS quotes —
// extract dialogue + attribute speakers from speech tags so it isn't lost.
segs = audiobookSplitByQuotes(chunks[i]);
degraded++;
const named = segs.filter(s => s.type === 'dialogue' && s.speaker !== 'Unknown').length;
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
}
// harvest speaker names (from LLM or tag heuristic) into the running roster
(data && data.characters || []).forEach(n => { if (n && n !== 'Unknown' && !roster.includes(n)) roster.push(n); });
segs.forEach(s => { if (s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown' && !roster.includes(s.speaker)) roster.push(s.speaker); });
segs.forEach(s => allSegments.push(s));
view.addSegments(segs);
}
view.update(chunks.length);
} finally {
_audiobook.running = false;
}
if (_audiobook.cancel) { view.done(); toast('Casting cancelled', 'error'); return; }
if (!allSegments.length) { view.done(); toast('No segments produced', 'error'); return; }
_audiobook.segments = allSegments;
_audiobook.roster = roster;
_audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded;
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
view.complete(summary, audiobookShowPreview);
}
// ── Editable attribution preview ─────────────────────────────────────────────
function audiobookShowPreview() {
const segs = _audiobook.segments || [];
const roster = _audiobook.roster || [];
document.getElementById('audiobook-preview')?.remove();
const ov = document.createElement('div');
ov.id = 'audiobook-preview';
ov.className = 'audiobook-overlay';
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
const charCount = speakerSet.filter(n => n !== 'Narrator' && n !== 'Unknown').length;
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast &amp; lines
<span class="audiobook-count">${segs.length} segments · ${charCount} character${charCount !== 1 ? 's' : ''}</span></div>
<div class="audiobook-msg">Fix any wrong speaker or emotion, then open in the Script Rehearser to assign voices.${_audiobook.narratedPassages ? ` <span class="audiobook-narr-note">${_audiobook.narratedPassages} passage${_audiobook.narratedPassages !== 1 ? 's' : ''} had no dialogue (narrator).</span>` : ''}${_audiobook.degraded ? ` <span class="audiobook-narr-note">${_audiobook.degraded} passage${_audiobook.degraded !== 1 ? 's' : ''} used quick detection — set the “Unknown” speakers.</span>` : ''}</div>
<datalist id="audiobook-roster">${opts}</datalist>
<div class="audiobook-seglist" id="audiobook-seglist"></div>
<div class="audiobook-actions">
<button class="btn-secondary btn-sm" id="audiobook-preview-cancel">Cancel</button>
<button class="btn-primary btn-sm" id="audiobook-preview-open"><span class="mdi mdi-account-music-outline"></span> Open in Rehearser</button>
</div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
<div class="audiobook-seg-text">${escHtml(s.text)}</div>
</div>`).join('');
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
}
function audiobookApplyPreviewAndOpen() {
const segs = _audiobook.segments;
document.querySelectorAll('#audiobook-seglist .audiobook-seg-sp').forEach(inp => {
const i = +inp.dataset.i; const v = inp.value.trim() || 'Narrator';
segs[i].speaker = v;
segs[i].type = (v.toLowerCase() === 'narrator') ? 'narration' : 'dialogue';
});
document.querySelectorAll('#audiobook-seglist .audiobook-seg-emo').forEach(inp => {
const i = +inp.dataset.i; segs[i].emotion = inp.value.trim();
});
const { script, emotions } = audiobookBuildScript(segs);
audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
}
// Build a rehearser script (CAPS speaker + line; narration as plain paragraphs)
// and a parallel list of per-dialogue-line emotions (same order as dialog lines).
function audiobookBuildScript(segments) {
let script = '';
const emotions = [];
for (const s of segments) {
const t = (s.text || '').trim(); if (!t) continue;
const isDialogue = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
if (isDialogue) {
script += '\n' + s.speaker.toUpperCase() + '\n' + t + '\n';
emotions.push(s.emotion || '');
} else {
script += '\n' + t + '\n'; // narration → narrator reads these
}
}
return { script: script.trim(), emotions };
}
async function audiobookOpenInRehearser(script, title, dialogueEmotions) {
if ($('reh-script-text')) $('reh-script-text').value = script;
if ($('reh-script-title')) $('reh-script-title').value = title;
if (typeof navTo === 'function') navTo('s-rehearser');
// Reuse the rehearser's own parse flow (builds lines, cast, jumps to Cast phase)
const btn = $('reh-parse-btn');
if (btn) btn.click();
else if (typeof parseScript === 'function') { rehState.lines = parseScript(script); }
// Apply per-line emotions to dialog lines in order (Phase 3: emotion-aware narration)
let speakers = 0, lines = 0;
if (window.rehState && Array.isArray(rehState.lines)) {
let k = 0;
rehState.lines.forEach(l => { if (l.type === 'dialog') { const e = dialogueEmotions[k++]; if (e) l.emotion = e; lines++; } });
speakers = Object.keys(rehState.cast || {}).filter(s => !String(s).includes('NARRATOR')).length;
}
// Persist as a reopenable rehearsal so the cast/lines aren't lost — find it under
// Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise.
let saved = false;
if (typeof saveToLibrary === 'function') {
try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) {}
}
toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success');
}
// ── Audiobook export (rehearser): synthesise every line → MP3 per chapter ────
function audiobookIsChapter(line) {
if (line.type === 'act' || line.type === 'scene') return true;
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
if (!t || t.length > 60) return false;
return /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t);
}
function audiobookLineVoice(l) {
if (l.type === 'dialog') {
const c = rehState.cast[l.speaker] || {};
return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion) : '') };
}
return { voice: rehState.narratorVoice, instruct: '' };
}
async function audiobookExport() {
if (_audiobook.running) return;
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
if (!rehState.backend) { toast('Select a TTS backend in the rehearser first', 'error'); return; }
if (typeof _ensureNarrator === 'function') _ensureNarrator();
const speakable = i => {
const l = rehState.lines[i];
if (!l || l.ignored || l.hidden) return false;
if (l.type === 'dialog') { const c = rehState.cast[l.speaker]; return !!(c && c.voice && c.voice !== 'me'); }
return !!(rehState.narratorVoice && (l.text || '').trim());
};
// Bucket speakable lines into chapters (by chapter headings / act / scene)
const buckets = [];
let cur = null;
rehState.lines.forEach((l, i) => {
if (audiobookIsChapter(l)) { cur = { title: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text).trim().slice(0, 50), idx: [] }; buckets.push(cur); }
if (speakable(i)) { if (!cur) { cur = { title: '', idx: [] }; buckets.push(cur); } cur.idx.push(i); }
});
const allIdx = buckets.flatMap(b => b.idx);
if (!allIdx.length) { toast('Nothing to synthesise — cast voices first', 'error'); return; }
_audiobook.running = true; _audiobook.cancel = false;
const prog = audiobookProgress(allIdx.length);
const mp3 = new Map();
let done = 0;
const queue = allIdx.slice();
const worker = async () => {
while (queue.length && !_audiobook.cancel) {
const i = queue.shift();
const l = rehState.lines[i];
const { voice, instruct } = audiobookLineVoice(l);
const text = (typeof _rehInlineTone === 'function')
? _rehInlineTone(stripMarkdown(l.text), l.emotion)
: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text);
try { mp3.set(i, await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, rehState.backend)); } catch (_) {}
prog.update(++done, `Synthesising line ${done} / ${allIdx.length}`);
}
};
try { await Promise.all(Array.from({ length: Math.min(2, allIdx.length) }, worker)); }
finally { prog.done(); _audiobook.running = false; }
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
const title = (typeof readerSafeName === 'function' ? readerSafeName($('reh-script-title')?.value || 'Audiobook') : ($('reh-script-title')?.value || 'Audiobook'));
const realChapters = buckets.filter(b => b.title).length > 0;
let files = 0;
for (let c = 0; c < buckets.length; c++) {
const blobs = buckets[c].idx.map(i => mp3.get(i)).filter(Boolean);
if (!blobs.length) continue;
const blob = new Blob(blobs, { type: 'audio/mpeg' });
const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';
const name = (realChapters || buckets.length > 1)
? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.mp3`
: `${title}.mp3`;
if (typeof readerDownload === 'function') readerDownload(blob, name);
files++;
await new Promise(r => setTimeout(r, 400));
}
toast('Exported audiobook · ' + files + (realChapters ? ' chapter MP3 file(s)' : ' MP3 file(s)'), 'success');
}
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-audiobook-btn')?.addEventListener('click', audiobookCast);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);

View File

@ -0,0 +1,232 @@
// ── Character sheets ────────────────────────────────────────────────────────
//
// Actor-facing, RPG-style character sheets extracted by the user's LLM from a
// document (Read Aloud) or a script (Script Rehearser). Each claim cites a
// source (page + short quote). Shared by both sections via one overlay.
//
// Reuses: /api/character-sheets (LLM), readerState/readerScopeIndices (reader.js),
// rehState/stripMarkdown/rehDefaultLlmUrl (rehearser.js), splitTextIntoChunks
// (generation.js), $ / escHtml / toast (utils.js).
const CS_CHUNK_CHARS = 4000;
const _cs = { running: false, cancel: false, cache: {} };
function csLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
function csLlmModel() { return $('reh-llm-model')?.value || ''; }
function csLang() { return $('reh-design-lang')?.value || ''; }
// Build page-annotated text from the current reader scope ([p.N] at page changes).
function csReaderText() {
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
let out = '', lastPage = -1;
for (const i of readerScopeIndices()) {
const u = readerState.sentences[i];
const pg = u.words?.[0]?.page;
if (readerState.mode === 'pdf' && pg != null && pg !== lastPage) { out += `\n[p.${pg + 1}] `; lastPage = pg; }
out += u.text + ' ';
}
return out.trim();
}
// Build text from the rehearser script (narration + "SPEAKER: line"), page markers at page breaks.
function csRehearserText() {
if (!window.rehState || !(rehState.lines || []).length) return '';
let out = '', page = 1, started = false;
for (const l of rehState.lines) {
if (l.type === 'pagebreak') { page++; out += `\n[p.${page}] `; continue; }
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(l.text || '') : (l.text || '')).trim();
if (!t) continue;
if (!started) { out += '[p.1] '; started = true; }
out += (l.type === 'dialog' && l.speaker ? l.speaker + ': ' : '') + t + '\n';
}
return out.trim();
}
// ── Generation (chunked + merged by character) ───────────────────────────────
// Compact summary of the sheets built so far → tells the LLM what's known and
// which fields each character still needs, so it fills gaps instead of restarting.
function csExistingSummary(map) {
if (!map.size) return '';
const FIELDS = ['archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
return [...map.values()].slice(0, 30).map(s => {
const missing = FIELDS.filter(f => !(s[f] || '').trim());
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? `${s.archetype}` : ''}`
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
}).join('\n');
}
function csMerge(map, sheets) {
const SCALARS = ['aliases', 'archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
for (const s of sheets) {
const name = (s.name || '').trim(); if (!name) continue;
const key = name.toLowerCase();
if (!map.has(key)) { map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }); continue; }
const e = map.get(key);
SCALARS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
if (s.tier === 'main') e.tier = 'main';
(s.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
(s.sources || []).forEach(src => { if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); });
}
}
async function csGenerate(text, cacheKey) {
if (_cs.running) return null;
if (!text) { toast('Nothing to analyse', 'error'); return null; }
const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text];
_cs.running = true; _cs.cancel = false;
const prog = csProgress(chunks.length);
const llm_url = csLlmUrl(), model = csLlmModel(), language = csLang();
const map = new Map();
const roster = [];
try {
for (let i = 0; i < chunks.length; i++) {
if (_cs.cancel) break;
prog.update(i, `Reading characters · passage ${i + 1} / ${chunks.length}`);
try {
const r = await fetch('/api/character-sheets', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), existing: csExistingSummary(map), language, llm_url, model }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const data = await r.json();
csMerge(map, data.sheets || []);
(data.characters || []).forEach(n => { if (!roster.includes(n)) roster.push(n); });
} catch (e) {
toast('Passage ' + (i + 1) + ' failed: ' + (e.message || e), 'error');
}
}
} finally { prog.done(); _cs.running = false; }
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
const sheets = [...map.values()];
if (cacheKey) _cs.cache[cacheKey] = sheets;
return sheets;
}
function csProgress(total) {
let ov = document.getElementById('cs-progress');
if (!ov) {
ov = document.createElement('div');
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>
<div class="audiobook-msg" id="cs-progress-msg">Analysing</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#cs-progress-cancel').addEventListener('click', () => { _cs.cancel = true; });
}
ov.hidden = false;
const fill = ov.querySelector('#cs-progress-fill'), msg = ov.querySelector('#cs-progress-msg');
return {
update(d, label) { if (fill) fill.style.width = (d / total * 100) + '%'; if (msg && label) msg.textContent = label; },
done() { ov.hidden = true; },
};
}
// ── Rendering ────────────────────────────────────────────────────────────────
function csField(label, value) {
if (!value) return '';
return `<div class="cs-row"><dt>${label}</dt><dd>${escHtml(String(value))}</dd></div>`;
}
function csCardHtml(s) {
const inv = (s.inventory || []).filter(Boolean);
const invHtml = inv.length ? `<div class="cs-row"><dt>Signature Inventory</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>` : '';
const attrs = (s.attribute_high || s.attribute_low)
? `<div class="cs-row"><dt>Core Attributes</dt><dd>▲ ${escHtml(s.attribute_high || '—')} &nbsp;·&nbsp; ▼ ${escHtml(s.attribute_low || '—')}</dd></div>` : '';
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
const srcHtml = sources.length
? `<div class="cs-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${sources.map(x => `${x.page != null ? '<b>p.' + escHtml(String(x.page)) + '</b> ' : ''}${x.quote ? '“' + escHtml(x.quote) + '”' : ''}`).join(' · ')}</div>` : '';
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}">
<div class="cs-head">
<span class="cs-name">${escHtml(s.name)}</span>
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</span>
</div>
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
<dl class="cs-fields">
${csField('Physical', s.physical)}
${csField('Alignment &amp; Ethos', s.alignment)}
${attrs}
${csField('Trained Skills', s.skills)}
${invHtml}
${csField('Dark Secret / Fatal Flaw', s.secret)}
${csField('Conflict Style', s.conflict_style)}
${csField('Win Condition', s.win_condition)}
</dl>
${srcHtml}
</div>`;
}
function csToMarkdown(sheets) {
const sec = t => `\n## ${t}\n`;
let md = '# Character Sheets\n';
for (const tier of ['main', 'supp']) {
const list = sheets.filter(s => (s.tier === 'main') === (tier === 'main'));
if (!list.length) continue;
md += sec(tier === 'main' ? 'Main characters' : 'Supporting characters');
for (const s of list) {
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
if (s.aliases) md += `*aka ${s.aliases}*\n`;
const f = (l, v) => v ? `- **${l}:** ${v}\n` : '';
md += f('Physical', s.physical) + f('Alignment & Ethos', s.alignment)
+ f('Core Attributes', [s.attribute_high && '▲ ' + s.attribute_high, s.attribute_low && '▼ ' + s.attribute_low].filter(Boolean).join(' · '))
+ f('Trained Skills', s.skills) + f('Signature Inventory', (s.inventory || []).join(', '))
+ f('Dark Secret / Fatal Flaw', s.secret) + f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
const src = (s.sources || []).filter(x => x && x.quote).map(x => `${x.page != null ? 'p.' + x.page + ' ' : ''}${x.quote}`).join('; ');
if (src) md += `- *Sources:* ${src}\n`;
}
}
return md.trim();
}
function csShow(sheets, title) {
document.getElementById('cs-overlay')?.remove();
const ov = document.createElement('div');
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
const main = sheets.filter(s => s.tier === 'main');
const supp = sheets.filter(s => s.tier !== 'main');
const group = (label, list) => list.length ? `<div class="cs-group-label">${label}</div>` + list.map(csCardHtml).join('') : '';
ov.innerHTML = `<div class="audiobook-box cs-box">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> ${escHtml(title || 'Character sheets')} <span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span></span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
</div>
<div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
ov.querySelector('#cs-copy').addEventListener('click', () => {
navigator.clipboard?.writeText(csToMarkdown(sheets)).then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
});
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
}
// ── Entry points (reader + rehearser) ────────────────────────────────────────
async function csForReader() {
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets'); return; }
const sheets = await csGenerate(csReaderText(), key);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
csShow(sheets, readerState.title || 'Character sheets');
}
async function csForRehearser() {
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
if (_cs.cache[key]) { csShow(_cs.cache[key], title); return; }
const sheets = await csGenerate(csRehearserText(), key);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
csShow(sheets, title);
}
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);

View File

@ -57,16 +57,25 @@ async function mergeWavBlobs(blobs) {
function splitTextIntoChunks(text, maxLen = 800) { function splitTextIntoChunks(text, maxLen = 800) {
const abbrev = /\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|e\.g|i\.e)\.\s/g; const abbrev = /\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|e\.g|i\.e)\.\s/g;
const safe = text.replace(abbrev, m => m.replace('.', '\x00')); const safe = text.replace(abbrev, m => m.replace('.', '\x00'));
const parts = safe.match(/[^.!?]+[.!?]+\s*/g) || [];
const last = safe.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
if (last) parts.push(last);
const restore = s => s.replace(/\x00/g, '.'); const restore = s => s.replace(/\x00/g, '.');
if (!parts.length) return [text];
// Gather segments from each line: sentences first, then any remaining line text.
// This ensures newline-delimited text (e.g. German bullet lists) gets split too.
const segments = [];
for (const line of safe.split('\n')) {
const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || [];
const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
segments.push(...sentences);
if (rest) segments.push(rest);
}
if (!segments.length) return [text];
const chunks = []; const chunks = [];
let cur = ''; let cur = '';
for (const p of parts) { for (const seg of segments) {
if ((cur + p).length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = p; } const joined = cur ? cur + ' ' + seg.trim() : seg.trim();
else cur += p; if (joined.length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = seg.trim(); }
else cur = joined;
} }
if (cur.trim()) chunks.push(restore(cur.trim())); if (cur.trim()) chunks.push(restore(cur.trim()));
return chunks.length ? chunks : [text]; return chunks.length ? chunks : [text];

View File

@ -22,6 +22,7 @@ const I18N_DICT = {
'Library tools': 'Bibliothekswerkzeuge', 'Library tools': 'Bibliothekswerkzeuge',
'Clone a Voice': 'Stimme klonen', 'Design a Voice': 'Stimme entwerfen', 'Clone a Voice': 'Stimme klonen', 'Design a Voice': 'Stimme entwerfen',
'Get Voices Online': 'Stimmen online holen', 'Try It Out': 'Ausprobieren', 'Get Voices Online': 'Stimmen online holen', 'Try It Out': 'Ausprobieren',
'Read Aloud': 'Vorlesen',
'Script Rehearser': 'Skript-Probe', 'Library': 'Bibliothek', 'Cast': 'Besetzung', 'Script Rehearser': 'Skript-Probe', 'Library': 'Bibliothek', 'Cast': 'Besetzung',
'Stage': 'Bühne', 'Summary': 'Zusammenfassung', 'Import / Export': 'Import / Export', 'Stage': 'Bühne', 'Summary': 'Zusammenfassung', 'Import / Export': 'Import / Export',
'Conversation': 'Unterhaltung', 'Benchmark': 'Benchmark', 'Engines': 'Engines', 'Conversation': 'Unterhaltung', 'Benchmark': 'Benchmark', 'Engines': 'Engines',
@ -42,6 +43,15 @@ const I18N_DICT = {
'Erzeuge Sprache aus Text mit beliebigem Backend und Stimme. Transkribiere Audio und sprich es neu ein.', 'Erzeuge Sprache aus Text mit beliebigem Backend und Stimme. Transkribiere Audio und sprich es neu ein.',
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.': 'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Lade ein Skript hoch, besetze Figuren mit TTS-Stimmen oder deinem Mikrofon und probe Szene für Szene.', 'Lade ein Skript hoch, besetze Figuren mit TTS-Stimmen oder deinem Mikrofon und probe Szene für Szene.',
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
'Importiere ein PDF- oder Textdokument, wähle Stimme und Tempo und lass es dir vorlesen, während das gerade gesprochene Wort hervorgehoben wird.',
// Read Aloud UI
'My books': 'Meine Bücher', 'Voice consistency': 'Stimmkonstanz',
'Normalise loudness': 'Lautstärke angleichen', 'Export MP3': 'MP3 exportieren',
'Select range': 'Bereich wählen', 'Synthesise': 'Synthetisieren',
'Tone / style': 'Ton / Stil', 'Temperature': 'Temperatur', 'Speed': 'Tempo',
'Saved documents with their synthesised audio — reopen to continue.':
'Gespeicherte Dokumente mit ihrem synthetisierten Audio — zum Fortsetzen erneut öffnen.',
// Common buttons / labels // Common buttons / labels
'Save changes': 'Änderungen speichern', 'Save to library': 'In Bibliothek speichern', 'Save changes': 'Änderungen speichern', 'Save to library': 'In Bibliothek speichern',
'Save to Voice Library': 'In Stimmbibliothek speichern', 'Delete voice': 'Stimme löschen', 'Save to Voice Library': 'In Stimmbibliothek speichern', 'Delete voice': 'Stimme löschen',
@ -111,16 +121,27 @@ window.setAppLang = function (lang) {
location.reload(); location.reload();
}; };
// Inject the picker into the sidebar brand once the DOM is ready. // Flag of a language code (flag-icons classes; English → GB).
function _langFlag(code) { return code === 'en' ? 'gb' : code; }
// Inject a flag toggle next to the "Voice Creator" headline. Clicking cycles to
// the next language (a simple toggle while there are two).
window.initLangPicker = function () { window.initLangPicker = function () {
const brand = document.querySelector('.sidebar-brand'); const brand = document.querySelector('.sidebar-brand');
if (!brand || document.getElementById('app-lang-picker')) return; const head = brand && brand.querySelector('h1');
const sel = document.createElement('select'); if (!head || document.getElementById('app-lang-toggle')) return;
sel.id = 'app-lang-picker'; const codes = Object.keys(I18N_LANGS);
sel.className = 'app-lang-picker'; const btn = document.createElement('button');
sel.setAttribute('aria-label', 'Interface language'); btn.id = 'app-lang-toggle';
sel.innerHTML = Object.entries(I18N_LANGS) btn.className = 'app-lang-toggle';
.map(([code, name]) => `<option value="${code}"${code === _appLang ? ' selected' : ''}>${name}</option>`).join(''); btn.type = 'button';
sel.addEventListener('change', () => window.setAppLang(sel.value)); const nextOf = () => codes[(codes.indexOf(_appLang) + 1) % codes.length];
brand.appendChild(sel); const render = () => {
btn.innerHTML = `<span class="fi fi-${_langFlag(_appLang)}"></span>`;
const label = 'Language: ' + I18N_LANGS[_appLang] + ' — switch to ' + I18N_LANGS[nextOf()];
btn.title = label; btn.setAttribute('aria-label', label);
};
render();
btn.addEventListener('click', () => window.setAppLang(nextOf()));
head.appendChild(btn);
}; };

1324
static/js/reader.js Normal file

File diff suppressed because it is too large Load Diff

513
static/js/seed-finder.js Normal file
View File

@ -0,0 +1,513 @@
// ── Seed Finder — find the best RNG seed for a voice ─────────────────────────
//
// Appended to the voice inspector body by voice-inspector.js.
// Generates audio samples for a voice across a range of seeds so the user
// can listen and pick the most natural-sounding one, then save it to the
// TTS server's voices.json with one click.
// Test sentence: umlauts (ä ö ü ß), dates/numbers, and English words — reveals a
// voice's character per seed. Used for every voice (incl. the batch run).
const SEED_FINDER_TEXT_DE = 'Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Schöne Grüße! — Madonna - Träume beginnt mit einem positiven Mindset.';
const SEED_FINDER_TEXT_EN = SEED_FINDER_TEXT_DE;
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE;
function _seedFinderDefaultText(voiceId) {
const lc = (voiceId || '').toLowerCase();
if (lc.startsWith('de_')) return SEED_FINDER_TEXT_MIXED;
if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN;
return SEED_FINDER_TEXT_MIXED;
}
// ── Sample cache (IndexedDB) — generated WAVs persist so reopening a voice or
// re-running doesn't regenerate seeds that already succeeded ──────────────────
const SF_DB = 'seed-finder', SF_STORE = 'samples';
function _sfHash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h.toString(36); }
function _sfDbOpen() {
return new Promise((res, rej) => {
const r = indexedDB.open(SF_DB, 1);
r.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains(SF_STORE)) db.createObjectStore(SF_STORE, { keyPath: 'key' }); };
r.onsuccess = e => res(e.target.result); r.onerror = e => rej(e.target.error);
});
}
async function _sfDbGet(key) {
try { const db = await _sfDbOpen(); return await new Promise((res, rej) => { const r = db.transaction(SF_STORE, 'readonly').objectStore(SF_STORE).get(key); r.onsuccess = e => res(e.target.result || null); r.onerror = e => rej(e.target.error); }); }
catch (_) { return null; }
}
async function _sfDbPut(rec) {
try { const db = await _sfDbOpen(); await new Promise((res, rej) => { const r = db.transaction(SF_STORE, 'readwrite').objectStore(SF_STORE).put(rec); r.onsuccess = () => res(); r.onerror = e => rej(e.target.error); }); } catch (_) {}
}
async function _sfDbAllForVoice(voiceId) {
try {
const db = await _sfDbOpen();
return await new Promise((res, rej) => {
const out = []; const cur = db.transaction(SF_STORE, 'readonly').objectStore(SF_STORE).openCursor();
cur.onsuccess = e => { const c = e.target.result; if (c) { if (c.value.voiceId === voiceId) out.push(c.value); c.continue(); } else res(out); };
cur.onerror = e => rej(e.target.error);
});
} catch (_) { return []; }
}
async function _sfDbClearVoice(voiceId) {
try {
const db = await _sfDbOpen();
await new Promise((res, rej) => {
const store = db.transaction(SF_STORE, 'readwrite').objectStore(SF_STORE);
const cur = store.openCursor();
cur.onsuccess = e => { const c = e.target.result; if (c) { if (c.value.voiceId === voiceId) store.delete(c.primaryKey); c.continue(); } else res(); };
cur.onerror = e => rej(e.target.error);
});
} catch (_) {}
}
// Called from voice-inspector.js after the persona panel is appended.
function attachSeedFinder(voiceId, body) {
const panel = document.createElement('div');
panel.className = 'opt-group seed-finder-group';
panel.innerHTML = `
<div class="opt-group-title">
<span class="opt-chevron"></span>
<span class="opt-title-text">🎲 Seed Finder</span>
<span class="opt-group-meta">Find the most natural-sounding voice</span>
</div>
<div class="opt-group-subtitle">
Generates audio for each seed so you can listen and pick the best one.
Click "Use this seed" to save it to the TTS server.
</div>
<div class="opt-group-body seed-finder-body">
<div class="seed-finder-config">
<div class="seed-finder-row">
<label class="seed-finder-label">Test sentence</label>
<textarea class="seed-finder-text" rows="3" spellcheck="false"></textarea>
</div>
<div class="seed-finder-row seed-finder-params">
<div class="seed-finder-field">
<label>Seeds: from</label>
<input class="seed-finder-from" type="number" min="0" max="9999" value="1" step="1">
<label>to</label>
<input class="seed-finder-to" type="number" min="1" max="9999" value="15" step="1">
</div>
<div class="seed-finder-field">
<label>Backend</label>
<select class="seed-finder-backend">
<option value="voice_clone" selected>Voice Clone</option>
<option value="streaming">Streaming</option>
</select>
</div>
</div>
<div class="seed-finder-actions">
<button class="btn-primary seed-finder-run-btn"> Run Seed Finder</button>
<button class="btn-secondary seed-finder-cancel-btn" style="display:none"> Cancel</button>
<button class="btn-secondary seed-finder-clear-btn" style="display:none" title="Delete the saved samples cached for this voice">🗑 Clear saved</button>
<span class="seed-finder-status"></span>
</div>
<div class="seed-finder-row seed-finder-pin">
<label class="seed-finder-label">Or fix this voice to a specific seed</label>
<div class="seed-finder-pin-controls">
<input class="seed-finder-pin-input" type="number" min="0" max="9999" step="1" placeholder="seed #">
<button class="btn-secondary seed-finder-pin-btn"> Pin seed</button>
<button class="btn-secondary seed-finder-pin-clear" title="Use a random seed each time (unpin)">Unpin</button>
<span class="seed-finder-pin-status"></span>
</div>
</div>
</div>
<div class="seed-finder-progress" style="display:none">
<div class="seed-finder-progress-head">
<span class="seed-finder-progress-label">Generating</span>
<span class="seed-finder-progress-count">0 / 0</span>
</div>
<div class="benchmark-progress-track">
<div class="seed-finder-bar" style="width:0%"></div>
</div>
</div>
<div class="seed-finder-results"></div>
</div>
`;
body.appendChild(panel);
// Toggle open/closed; load batch samples the first time the panel opens
let _batchLoaded = false;
panel.querySelector('.opt-group-title').addEventListener('click', () => {
panel.classList.toggle('open');
if (panel.classList.contains('open') && !_batchLoaded) {
_batchLoaded = true;
_loadBatchSamples();
}
});
const textEl = panel.querySelector('.seed-finder-text');
const fromEl = panel.querySelector('.seed-finder-from');
const toEl = panel.querySelector('.seed-finder-to');
const backendEl = panel.querySelector('.seed-finder-backend');
const runBtn = panel.querySelector('.seed-finder-run-btn');
const cancelBtn = panel.querySelector('.seed-finder-cancel-btn');
const clearBtn = panel.querySelector('.seed-finder-clear-btn');
const statusEl = panel.querySelector('.seed-finder-status');
const progressEl = panel.querySelector('.seed-finder-progress');
const progLabel = panel.querySelector('.seed-finder-progress-label');
const progCount = panel.querySelector('.seed-finder-progress-count');
const progBar = panel.querySelector('.seed-finder-bar');
const resultsEl = panel.querySelector('.seed-finder-results');
textEl.value = _seedFinderDefaultText(voiceId);
const pinInput = panel.querySelector('.seed-finder-pin-input');
const pinBtn = panel.querySelector('.seed-finder-pin-btn');
const pinClear = panel.querySelector('.seed-finder-pin-clear');
const pinStatus = panel.querySelector('.seed-finder-pin-status');
const voiceObj = (window._voices || []).find(v => v.id === voiceId) || {};
if (voiceObj.seed !== undefined && voiceObj.seed !== null) {
pinInput.value = voiceObj.seed;
pinStatus.textContent = `✓ Pinned seed ${voiceObj.seed}`;
}
let _cancelled = false;
let _currentAudio = null;
// ── Load pre-generated batch samples on panel open ────────────────────────
async function _loadBatchSamples() {
try {
const resp = await fetch(`/api/seed-samples/${encodeURIComponent(voiceId)}`);
if (!resp.ok) return;
const { seeds } = await resp.json();
if (!seeds || seeds.length === 0) return;
resultsEl.innerHTML = '';
const header = document.createElement('div');
header.className = 'sfr-batch-header';
header.textContent = `${seeds.length} pre-generated sample${seeds.length !== 1 ? 's' : ''} from batch run — click Play to listen`;
resultsEl.appendChild(header);
for (const seed of seeds) {
const row = document.createElement('div');
row.className = 'seed-finder-result-row sfr-batch';
row.dataset.seed = seed;
row.innerHTML = `
<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-dur sfr-batch-tag">pre-generated</span>
<button class="btn-secondary sfr-play-btn"><span class="mdi mdi-play"></span> Play</button>
<button class="btn-primary sfr-use-btn"> Use seed ${seed}</button>
<span class="sfr-saved"></span>
`;
let _cachedBlob = null;
row.querySelector('.sfr-play-btn').addEventListener('click', async () => {
if (!_cachedBlob) {
const r = await fetch(`/api/seed-sample/${encodeURIComponent(voiceId)}/${seed}`);
if (!r.ok) { if (typeof toast === 'function') toast('Failed to load sample', 'error'); return; }
_cachedBlob = await r.blob();
}
_playBlob(_cachedBlob);
});
row.querySelector('.sfr-use-btn').addEventListener('click', async () => {
try {
await _saveSeed(seed);
resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = '');
resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active'));
row.classList.add('sfr-active');
row.querySelector('.sfr-saved').textContent = '✓ Saved';
if (pinInput) pinInput.value = seed;
if (typeof toast === 'function') toast(`Seed ${seed} saved for ${voiceId}`, 'success');
} catch (e) {
if (typeof toast === 'function') toast('Save failed: ' + e.message, 'error');
}
});
resultsEl.appendChild(row);
}
} catch (_) {}
}
// Save (or clear, with seed=null) the voice's seed in the TTS server's voices.json.
async function _saveSeed(seed) {
const resp = await fetch('/api/tts-voice-seed', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: voiceId, seed }),
});
if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.detail || resp.statusText); }
const v = (window._voices || []).find(vv => vv.id === voiceId);
if (v) v.seed = seed;
const pinnedSeedEl = document.querySelector('.insp-pinned-seed');
if (pinnedSeedEl) {
if (seed !== null && seed !== undefined) {
pinnedSeedEl.textContent = `Pin Seed # ${seed}`;
pinnedSeedEl.style.display = '';
} else {
pinnedSeedEl.style.display = 'none';
pinnedSeedEl.textContent = '';
}
}
}
// Retry transient failures (e.g. "Failed to fetch" when a long generation drops).
async function _fetchWithRetry(fn, tries = 2, delay = 1200) {
let lastErr;
for (let t = 0; t <= tries; t++) {
if (_cancelled) throw new Error('cancelled');
try { return await fn(); }
catch (e) { lastErr = e; if (t < tries) await new Promise(r => setTimeout(r, delay)); }
}
throw lastErr;
}
function _playBlob(blob) {
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
_currentAudio = audio;
audio.play().catch(() => {});
audio.addEventListener('ended', () => { URL.revokeObjectURL(url); _currentAudio = null; });
}
const seedRows = new Map(); // seed → row element
const _keyFor = (seed, text, backend) => `${voiceId}|${backend}|${_sfHash(text)}|${seed}`;
async function _useSeed(seed, row) {
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
try {
await _saveSeed(seed);
resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = '');
resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active'));
row.classList.add('sfr-active');
row.querySelector('.sfr-saved').textContent = '✓ Saved';
if (pinInput) pinInput.value = seed;
if (typeof toast === 'function') toast(`Seed ${seed} saved for ${voiceId}`, 'success');
} catch (e) {
if (typeof toast === 'function') toast('Save failed: ' + e.message, 'error');
}
}
function _renderSeed(seed, data) {
const row = document.createElement('div');
row.className = 'seed-finder-result-row' + (data.err ? ' seed-finder-result-error' : '');
row.dataset.seed = seed;
if (data.err) {
row.innerHTML = `<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-error">Failed: ${escHtml(String(data.err))}</span>
<button class="btn-secondary sfr-retry-btn"><span class="mdi mdi-refresh"></span> Retry</button>`;
row.querySelector('.sfr-retry-btn').addEventListener('click', () => _retrySeed(seed));
} else {
const durStr = data.dur ? `${data.dur.toFixed(1)}s` : '';
row.innerHTML = `<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-dur">${durStr}${data.cached ? ' · saved' : ''}</span>
<button class="btn-secondary sfr-play-btn"><span class="mdi mdi-play"></span> Play</button>
<button class="btn-primary sfr-use-btn"> Use seed ${seed}</button>
<span class="sfr-saved"></span>`;
row.querySelector('.sfr-play-btn').addEventListener('click', () => _playBlob(data.blob));
row.querySelector('.sfr-use-btn').addEventListener('click', () => _useSeed(seed, row));
}
const existing = seedRows.get(seed);
if (existing) existing.replaceWith(row); else resultsEl.appendChild(row);
seedRows.set(seed, row);
}
// Generate one seed — reuses the cached WAV unless force, and caches new ones.
async function _generateSeed(seed, text, backend, force) {
const key = _keyFor(seed, text, backend);
if (!force) { const c = await _sfDbGet(key); if (c && c.blob) return { blob: c.blob, dur: c.dur || 0, cached: true }; }
const blob = await _fetchWithRetry(() => fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed }));
const dur = Math.max(0, (blob.size - 44) / (24000 * 2)); // WAV bytes → seconds (24 kHz, 16-bit)
_sfDbPut({ key, voiceId, backend, textHash: _sfHash(text), seed, blob, dur, ts: Date.now() });
return { blob, dur, cached: false };
}
async function _retrySeed(seed) {
const text = textEl.value.trim(), backend = backendEl.value;
const row = seedRows.get(seed); const err = row && row.querySelector('.sfr-error'); if (err) err.textContent = 'Retrying…';
try { _renderSeed(seed, await _generateSeed(seed, text, backend, true)); }
catch (e) { _renderSeed(seed, { err: e.message || String(e) }); }
}
// Show any cached samples for the current text+backend without regenerating.
async function _loadCached() {
const text = textEl.value.trim(), backend = backendEl.value, th = _sfHash(text);
const all = (await _sfDbAllForVoice(voiceId)).filter(r => r.backend === backend && r.textHash === th && r.blob);
if (!all.length) { clearBtn.style.display = 'none'; return; }
all.sort((a, b) => a.seed - b.seed);
resultsEl.innerHTML = ''; seedRows.clear();
all.forEach(r => _renderSeed(r.seed, { blob: r.blob, dur: r.dur || 0, cached: true }));
clearBtn.style.display = '';
statusEl.textContent = `${all.length} saved sample${all.length !== 1 ? 's' : ''} loaded — Run to fill the rest.`;
}
async function _runFinder() {
const text = textEl.value.trim();
if (!text) { statusEl.textContent = 'Enter a test sentence.'; return; }
const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1);
const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 15);
const backend = backendEl.value;
const seeds = [];
for (let s = seedFrom; s <= seedTo; s++) seeds.push(s);
const total = seeds.length;
_cancelled = false;
resultsEl.innerHTML = ''; seedRows.clear();
progressEl.style.display = '';
runBtn.style.display = 'none';
cancelBtn.style.display = ''; clearBtn.style.display = 'none';
statusEl.textContent = '';
progBar.style.width = '0%';
progCount.textContent = `0 / ${total}`;
progLabel.textContent = 'Generating…';
let i = 0, cached = 0;
for (const seed of seeds) {
if (_cancelled) break;
progLabel.textContent = `Seed ${seed}`;
progCount.textContent = `${i} / ${total}`;
progBar.style.width = `${Math.round(i / total * 100)}%`;
try {
const d = await _generateSeed(seed, text, backend, false);
if (d.cached) cached++;
_renderSeed(seed, d);
} catch (e) {
if (_cancelled) break;
_renderSeed(seed, { err: e.message || String(e) });
}
i++;
}
progBar.style.width = '100%';
progCount.textContent = `${i} / ${total}`;
progLabel.textContent = _cancelled ? 'Cancelled.' : `Done — listen and pick your seed.${cached ? ` (${cached} from cache)` : ''}`;
runBtn.style.display = '';
cancelBtn.style.display = 'none';
if (seedRows.size) clearBtn.style.display = '';
}
runBtn.addEventListener('click', _runFinder);
cancelBtn.addEventListener('click', () => { _cancelled = true; });
clearBtn.addEventListener('click', async () => {
if (!confirm('Delete the cached seed samples for this voice?')) return;
await _sfDbClearVoice(voiceId);
resultsEl.innerHTML = ''; seedRows.clear(); clearBtn.style.display = 'none';
statusEl.textContent = 'Saved samples cleared.';
});
_loadCached(); // show previously generated samples on open (no regeneration)
// Fix the voice to a specific seed directly (no generation needed)
pinBtn.addEventListener('click', async () => {
const seed = parseInt(pinInput.value, 10);
if (isNaN(seed) || seed < 0) { pinStatus.textContent = 'Enter a seed number.'; return; }
pinBtn.disabled = true; pinStatus.textContent = 'Saving…';
try {
await _saveSeed(seed);
pinStatus.textContent = `✓ Pinned seed ${seed}`;
if (typeof toast === 'function') toast(`Voice ${voiceId} fixed to seed ${seed}`, 'success');
} catch (e) {
pinStatus.textContent = '';
if (typeof toast === 'function') toast('Pin failed: ' + e.message, 'error');
} finally { pinBtn.disabled = false; }
});
// Unpin → random seed each generation
pinClear.addEventListener('click', async () => {
pinClear.disabled = true; pinStatus.textContent = 'Clearing…';
try {
await _saveSeed(null);
pinInput.value = '';
pinStatus.textContent = '✓ Unpinned (random seed)';
resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = '');
resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active'));
if (typeof toast === 'function') toast(`Voice ${voiceId} unpinned — random seed`, 'success');
} catch (e) {
pinStatus.textContent = '';
if (typeof toast === 'function') toast('Unpin failed: ' + e.message, 'error');
} finally { pinClear.disabled = false; }
});
}
// ── Batch: pre-generate & cache Seed Finder samples for ALL active voices ─────
// Sequential (single-GPU friendly), skips already-cached seeds (resumable), and
// writes to the same cache the per-voice Seed Finder reads, so opening any voice
// later shows its samples instantly.
function seedFinderBatchAll() {
const ids = (typeof activeVoiceIds === 'function')
? activeVoiceIds()
: (window._voices || []).filter(v => v.enabled !== false).map(v => v.id);
if (!ids.length) { if (typeof toast === 'function') toast('No active voices', 'error'); return; }
// In-app config dialog (no browser prompts): pick the seed range, then Start.
document.getElementById('seedbatch-overlay')?.remove();
const ov = document.createElement('div');
ov.className = 'audiobook-overlay'; ov.id = 'seedbatch-overlay';
ov.innerHTML = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-dice-multiple-outline"></span> Batch seed generation</div>
<div class="audiobook-msg">Generate &amp; cache Seed Finder samples for all <b>${ids.length}</b> active voices. Already-cached seeds are skipped, so it's resumable; you can cancel anytime.</div>
<div class="seed-finder-row seed-finder-params" style="margin:12px 0">
<div class="seed-finder-field">
<label>Seeds: from</label>
<input class="sb-from" type="number" min="0" max="9999" value="1" step="1">
<label>to</label>
<input class="sb-to" type="number" min="1" max="9999" value="8" step="1">
</div>
<span class="sb-estimate seed-finder-status"></span>
</div>
<div class="audiobook-actions">
<button class="btn-secondary btn-sm" id="sb-cancel-cfg">Cancel</button>
<button class="btn-primary btn-sm" id="sb-start"><span class="mdi mdi-play"></span> Start</button>
</div>
</div>`;
document.body.appendChild(ov);
const box = ov.querySelector('.audiobook-box');
const fromI = ov.querySelector('.sb-from'), toI = ov.querySelector('.sb-to'), est = ov.querySelector('.sb-estimate');
const updEst = () => {
const f = Math.max(0, parseInt(fromI.value, 10) || 0), t = Math.max(f, parseInt(toI.value, 10) || f);
est.textContent = `${ids.length} voices × ${t - f + 1} seeds = up to ${ids.length * (t - f + 1)} samples`;
};
fromI.addEventListener('input', updEst); toI.addEventListener('input', updEst); updEst();
ov.querySelector('#sb-cancel-cfg').addEventListener('click', () => ov.remove());
ov.querySelector('#sb-start').addEventListener('click', () => {
const from = Math.max(0, parseInt(fromI.value, 10) || 1);
const to = Math.max(from, parseInt(toI.value, 10) || 8);
_seedBatchRun(ids, from, to, box, ov);
});
}
// Progress phase of the batch (swaps the dialog body to a live progress view).
async function _seedBatchRun(ids, from, to, box, ov) {
const backend = 'voice_clone';
const totalJobs = ids.length * (to - from + 1);
box.innerHTML = `<div class="audiobook-title"><span class="mdi mdi-dice-multiple-outline"></span> Batch seed generation</div>
<div class="audiobook-msg" id="sb-msg">Starting</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="sb-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="sb-cancel">Cancel</button></div>`;
let cancel = false;
box.querySelector('#sb-cancel').addEventListener('click', () => { cancel = true; });
const fill = box.querySelector('#sb-fill'), msg = box.querySelector('#sb-msg');
let done = 0, made = 0, cached = 0, failed = 0;
for (let vi = 0; vi < ids.length && !cancel; vi++) {
const voiceId = ids[vi];
const text = _seedFinderDefaultText(voiceId);
const th = _sfHash(text);
for (let seed = from; seed <= to && !cancel; seed++) {
msg.textContent = `Voice ${vi + 1}/${ids.length} · seed ${seed}${voiceId}`;
const key = `${voiceId}|${backend}|${th}|${seed}`;
const existing = await _sfDbGet(key);
if (existing && existing.blob) { cached++; done++; fill.style.width = (done / totalJobs * 100) + '%'; continue; }
try {
const blob = await fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed });
const dur = Math.max(0, (blob.size - 44) / (24000 * 2));
await _sfDbPut({ key, voiceId, backend, textHash: th, seed, blob, dur, ts: Date.now() });
made++;
} catch (_) { failed++; }
done++; fill.style.width = (done / totalJobs * 100) + '%';
}
}
ov.remove();
if (typeof toast === 'function') {
toast(cancel
? `Stopped — ${made} generated, ${cached} already cached`
: `Batch done — ${made} generated, ${cached} cached${failed ? `, ${failed} failed` : ''}`,
cancel ? 'error' : 'success');
}
}
if (typeof $ === 'function') $('seed-batch-all-btn')?.addEventListener('click', seedFinderBatchAll);

View File

@ -171,6 +171,13 @@ async function refreshTtsBackendAvailability(selected = '') {
libraryTts.innerHTML = ttsBackendOptions(prev); libraryTts.innerHTML = ttsBackendOptions(prev);
libraryTts.disabled = !availableTtsBackends().length; libraryTts.disabled = !availableTtsBackends().length;
} }
const readerBackend = $('reader-backend-select');
if (readerBackend) {
const prev = selected || readerBackend.value;
readerBackend.innerHTML = ttsBackendOptions(prev);
readerBackend.disabled = !availableTtsBackends().length;
if (typeof readerUpdateBackendHint === 'function') readerUpdateBackendHint();
}
document.querySelectorAll('.opt-style-backend').forEach(sel => { document.querySelectorAll('.opt-style-backend').forEach(sel => {
const prev = sel.value; const prev = sel.value;
sel.innerHTML = styleBackendOptions(prev); sel.innerHTML = styleBackendOptions(prev);

View File

@ -211,9 +211,10 @@ async function createTtsStreamUrl(voice, text, instruct = '') {
const data = await r.json(); const data = await r.json();
return data.url; return data.url;
} }
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false) { async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false, extra = null) {
const body = {text, voice, response_format: responseFormat, instruct, backend}; const body = {text, voice, response_format: responseFormat, instruct, backend};
if (applyPersona) body.apply_persona = true; if (applyPersona) body.apply_persona = true;
if (extra && typeof extra === 'object') Object.assign(body, extra); // e.g. {seed, temperature}
const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
return await r.blob(); return await r.blob();

View File

@ -89,7 +89,7 @@ function selectVoice(wrap) {
<div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div> <div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div>
<div class="insp-title-stack"> <div class="insp-title-stack">
<div class="insp-hd-row1"> <div class="insp-hd-row1">
<h3 class="insp-disp-name" title="Double-click to rename">${escHtml(dispName)}</h3> <h3 class="insp-disp-name" title="Double-click to rename the display name">${escHtml(dispName)}</h3>
<input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID"> <input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID">
<div class="insp-actions-save"></div> <div class="insp-actions-save"></div>
</div> </div>
@ -99,7 +99,10 @@ function selectVoice(wrap) {
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button> <button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button> <button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
</div> </div>
<div class="insp-hd-row2-right" style="display:flex; flex-direction:column; align-items:flex-end; gap:4px;">
<div class="insp-actions-active"></div> <div class="insp-actions-active"></div>
${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; display:none;"></div>`}
</div>
</div> </div>
<div class="insp-hd-divider"></div> <div class="insp-hd-divider"></div>
<div class="insp-subtitle"> <div class="insp-subtitle">
@ -161,42 +164,120 @@ function selectVoice(wrap) {
} }
// ── Avatar click → photo upload ─────────────────────────────────────────── // ── Avatar click → photo upload ───────────────────────────────────────────
inspector.querySelector('.inspector-avatar').addEventListener('click', () => { const inspAvatar = inspector.querySelector('.inspector-avatar');
inspAvatar.addEventListener('click', () => {
body.querySelector('.photo-input')?.click(); body.querySelector('.photo-input')?.click();
}); });
inspAvatar.addEventListener('dragenter', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
inspAvatar.addEventListener('dragover', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
inspAvatar.addEventListener('dragleave', () => inspAvatar.classList.remove('drag-over'));
inspAvatar.addEventListener('drop', async e => {
e.preventDefault();
inspAvatar.classList.remove('drag-over');
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const photoInput = body.querySelector('.photo-input');
if (photoInput) {
photoInput.files = e.dataTransfer.files;
photoInput.dispatchEvent(new Event('change'));
}
return;
}
let url = e.dataTransfer.getData('text/uri-list');
if (!url) {
const html = e.dataTransfer.getData('text/html');
if (html) {
const match = html.match(/src=["'](.*?)["']/);
if (match) url = match[1];
}
}
if (!url) url = e.dataTransfer.getData('text/plain');
if (url && /^https?:\/\//i.test(url)) {
if (typeof status === 'function') status('Downloading picture from URL...');
try {
const r = await fetch('/api/voice/picture-url', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: voiceId, image_url: url})
});
if (!r.ok) throw new Error((await r.json()).detail);
// Trigger the photo update in the row
const photoCell = body.querySelector('.vr-photo');
if (photoCell) photoCell.dispatchEvent(new Event('update-photo'));
if (typeof toast === 'function') toast('Photo saved from URL', 'success');
if (typeof status === 'function') status('Photo saved successfully');
} catch(err) {
if (typeof toast === 'function') toast('Photo URL download failed: ' + err.message, 'error');
if (typeof status === 'function') status('Photo URL download failed');
}
}
});
// ── Copy ID button ──────────────────────────────────────────────────────── // ── Copy ID button ────────────────────────────────────────────────────────
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => { inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
copyText(voiceId).then(() => toast('Copied: ' + voiceId)); copyText(voiceId).then(() => toast('Copied: ' + voiceId));
}); });
// ── Double-click name/ID → inline rename ────────────────────────────────── // ── Editing: the big header is the DISPLAY NAME (v.name); "edit ID" / the
// full id renames the underlying voice ID. ───────────────────────────────
const dispNameEl = inspector.querySelector('.insp-disp-name'); const dispNameEl = inspector.querySelector('.insp-disp-name');
const nameEditEl = inspector.querySelector('.insp-name-edit'); const nameEditEl = inspector.querySelector('.insp-name-edit');
const fullIdEl = inspector.querySelector('.insp-full-id'); const fullIdEl = inspector.querySelector('.insp-full-id');
let _editMode = null; // 'name' | 'id'
let _editCancelled = false;
const startInspRename = () => { const showEditor = (mode, val) => {
_editMode = mode; _editCancelled = false;
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none'; dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
nameEditEl.style.display = 'block'; nameEditEl.style.display = 'block';
nameEditEl.value = voiceId; nameEditEl.focus(); nameEditEl.select(); nameEditEl.placeholder = mode === 'name' ? 'Display name' : 'Voice ID';
nameEditEl.value = val; nameEditEl.focus(); nameEditEl.select();
}; };
const commitInspRename = () => { const startNameEdit = () => showEditor('name', dispName); // edit display name
const startInspRename = () => showEditor('id', voiceId); // rename voice ID
const commitEdit = async () => {
const mode = _editMode; _editMode = null;
dispNameEl.style.display = ''; fullIdEl.style.display = ''; dispNameEl.style.display = ''; fullIdEl.style.display = '';
nameEditEl.style.display = 'none'; nameEditEl.style.display = 'none';
const newId = nameEditEl.value.trim(); if (_editCancelled || !mode) return;
if (!newId || newId === voiceId) return; const val = nameEditEl.value.trim();
if (mode === 'id') {
if (!val || val === voiceId) return;
const rowInput = wrap.querySelector('.vr-name-input'); const rowInput = wrap.querySelector('.vr-name-input');
const rowOk = wrap.querySelector('.rename-ok'); const rowOk = wrap.querySelector('.rename-ok');
if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); } if (rowInput && rowOk) { rowInput.value = val; rowOk.click(); }
return;
}
// mode === 'name' → save a display name to the voice's metadata
if (val === dispName) return;
try {
const r = await fetch('/api/voice/meta', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: voiceId, name: val }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
v.name = val;
dispNameEl.textContent = val || (voiceId.split('_').pop());
if (typeof toast === 'function') toast('Display name updated', 'success');
if (typeof renderVoiceList === 'function') renderVoiceList();
} catch (e) {
if (typeof toast === 'function') toast('Rename failed: ' + (e.message || e), 'error');
}
}; };
dispNameEl.addEventListener('dblclick', startInspRename);
dispNameEl.addEventListener('dblclick', startNameEdit);
fullIdEl.addEventListener('dblclick', startInspRename); fullIdEl.addEventListener('dblclick', startInspRename);
fullIdEl.addEventListener('click', startInspRename); fullIdEl.addEventListener('click', startInspRename);
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename); inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
nameEditEl.addEventListener('blur', commitInspRename); nameEditEl.addEventListener('blur', commitEdit);
nameEditEl.addEventListener('keydown', e => { nameEditEl.addEventListener('keydown', e => {
if (e.key === 'Enter') nameEditEl.blur(); if (e.key === 'Enter') nameEditEl.blur();
if (e.key === 'Escape') { nameEditEl.value = voiceId; nameEditEl.blur(); } if (e.key === 'Escape') { _editCancelled = true; nameEditEl.blur(); }
}); });
// ── Flag (accent/country) — decoupled from language ────────────────────── // ── Flag (accent/country) — decoupled from language ──────────────────────
@ -410,6 +491,11 @@ function selectVoice(wrap) {
}); });
}); });
// ── Seed Finder panel ────────────────────────────────────────────────────
if (typeof attachSeedFinder === 'function') {
attachSeedFinder(voiceId, body);
}
// Track extracted elements for restoreToRow // Track extracted elements for restoreToRow
wrap._extracted = [ wrap._extracted = [
activeEl ? {el: activeEl, target: detailRow} : null, activeEl ? {el: activeEl, target: detailRow} : null,

View File

@ -84,6 +84,9 @@ function getSortValue(v, field) {
case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999; case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999;
case 'transcript': return (v.transcript || '').toLowerCase(); case 'transcript': return (v.transcript || '').toLowerCase();
case 'note': return (v.note || '').toLowerCase(); case 'note': return (v.note || '').toLowerCase();
case 'source': return (v.origin || '').toLowerCase();
case 'seed': return v.seed != null ? v.seed : 9999999;
case 'tag': return (v.tag || '').toLowerCase();
case 'rating': return v.rating || 0; case 'rating': return v.rating || 0;
case 'enabled': return v.enabled === false ? 0 : 1; case 'enabled': return v.enabled === false ? 0 : 1;
default: return ''; default: return '';
@ -132,9 +135,42 @@ document.addEventListener('click', e => {
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline'; if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
renderVoiceList(); renderVoiceList();
} }
if (e.target.closest('#voice-table-view-btn')) {
window._voiceTableView = !window._voiceTableView;
try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {}
const btn = document.getElementById('voice-table-view-btn');
btn?.classList.toggle('active', window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView);
// Auto-close any open inspector row when toggling table view
if (window._voiceTableView) {
document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open'));
const inspector = document.getElementById('voices-inspector');
if (inspector) inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-table"></span></span><p>Table View Mode<br>Click a row to exit table view and edit.</p></div>';
}
}
}); });
// Restore the grouped-by-tag preference on load // Restore the preferences on load
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {} try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
try { window._voiceTableView = localStorage.getItem('vl-table-view') === '1'; } catch (_) {}
document.addEventListener('click', e => {
const th = e.target.closest('.vl-th-sortable');
if (th) {
const field = th.dataset.sort;
if (field) {
const sel = document.getElementById('voice-sort-field');
if (sel && sel.value === field) {
window._voiceSortDir *= -1; // toggle dir
} else if (sel) {
sel.value = field;
window._voiceSortDir = 1; // default to desc for new field (or asc if you want)
}
readLibrarySortConfig();
renderVoiceList();
}
}
});
document.addEventListener('change', e => { document.addEventListener('change', e => {
if (e.target.id === 'voice-sort-field') setSort(e.target.value); if (e.target.id === 'voice-sort-field') setSort(e.target.value);
}); });
@ -260,7 +296,10 @@ function fmtDbfs(v) {
} }
function voiceBenchmark(v) { function voiceBenchmark(v) {
return v.benchmark && typeof v.benchmark === 'object' ? v.benchmark : null; if (v.benchmark && typeof v.benchmark === 'object' && Object.keys(v.benchmark).length > 0) {
return v.benchmark;
}
return null;
} }
function voiceBenchmarkElapsed(v) { function voiceBenchmarkElapsed(v) {
@ -799,6 +838,44 @@ $('copy-active-voices-btn').addEventListener('click', async () => {
status('Copied active voices to clipboard'); status('Copied active voices to clipboard');
}); });
// Precompute speaker embeddings: fire a tiny synth per active voice so the TTS
// engine computes + caches each voice's .pt (speaker fingerprint) ahead of time,
// making first real playback instant. The engine prefers the cached .pt and only
// (re)builds it from the wav when missing — so this is purely a warm-up.
$('precompute-embeddings-btn')?.addEventListener('click', async () => {
const backend = libraryTtsBackend();
const ids = activeVoiceIds();
if (!ids.length) { toast('No active voices to precompute', 'error'); return; }
if (!confirm(`Precompute speaker embeddings for ${ids.length} active voice(s) via “${backend}”?\n\nThis warms each voice so the engine caches its .pt and first playback is instant.`)) return;
const btn = $('precompute-embeddings-btn'); if (btn) btn.disabled = true;
const ov = document.createElement('div');
ov.className = 'audiobook-overlay'; ov.id = 'precompute-overlay';
ov.innerHTML = `<div class="audiobook-box"><div class="audiobook-title"><span class="mdi mdi-flash-outline"></span> Precomputing embeddings</div>
<div class="audiobook-msg" id="pc-msg">0 / ${ids.length}</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="pc-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="pc-cancel">Cancel</button></div></div>`;
document.body.appendChild(ov);
let cancel = false;
ov.querySelector('#pc-cancel').addEventListener('click', () => { cancel = true; });
const fill = ov.querySelector('#pc-fill'), msg = ov.querySelector('#pc-msg');
let done = 0, ok = 0, failed = 0;
const queue = ids.slice();
const worker = async () => {
while (queue.length && !cancel) {
const id = queue.shift();
if (msg) msg.textContent = `${done} / ${ids.length} · ${id}`;
try { await fetchTtsPreviewBlob(id, 'Hallo.', 'wav', '', backend); ok++; }
catch (_) { failed++; }
done++; if (fill) fill.style.width = (done / ids.length * 100) + '%';
}
};
try { await Promise.all(Array.from({ length: Math.min(2, ids.length) }, worker)); }
finally { ov.remove(); if (btn) btn.disabled = false; }
toast(cancel ? `Cancelled — ${ok} warmed` : `Precomputed ${ok} embedding(s)${failed ? `, ${failed} skipped/failed` : ''}`,
(!ok && failed) ? 'error' : 'success');
});
const LIB_ADD_SAMPLE_TEXTS = { const LIB_ADD_SAMPLE_TEXTS = {
EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.', EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.',
DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.', DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.',
@ -1374,6 +1451,12 @@ function renderVoiceList() {
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline'; if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
} }
const _tvBtn = $('voice-table-view-btn');
if (_tvBtn) {
_tvBtn.classList.toggle('active', !!window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!window._voiceTableView);
}
// Apply sidebar category filter // Apply sidebar category filter
const cat = window._voiceSidebarCat || 'all'; const cat = window._voiceSidebarCat || 'all';
const enabledOk = v => showDisabled || v.enabled !== false; const enabledOk = v => showDisabled || v.enabled !== false;
@ -1758,6 +1841,18 @@ function makeVoiceRow(v) {
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div> <div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div> <div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
</div> </div>
<div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">${flagEmoji} ${escHtml(langCode)}</div>
<div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}</div>
<div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
<div class="vl-tbl-bench">${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : '-'}</div>
<div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
<div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
<div class="vl-tbl-rating">${starsHtml}</div>
<div class="vl-tbl-source">${escHtml(v.origin || '-')}</div>
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
<div class="vl-tbl-note" title="${escHtml(v.note||'')}">${escHtml(v.note || '')}</div>
<div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">${escHtml(v.tag || '')}</div>
<div class="vl-tbl-active"><span class="mdi ${v.enabled !== false ? 'mdi-check-circle' : 'mdi-close-circle'}" style="color:${v.enabled !== false ? 'var(--green)' : 'var(--subtext)'}"></span></div>
</div> </div>
<div class="vr-main-row"> <div class="vr-main-row">
@ -1963,6 +2058,37 @@ function makeVoiceRow(v) {
// Photo upload // Photo upload
const photoCell = wrap.querySelector('.vr-photo'); const photoCell = wrap.querySelector('.vr-photo');
const photoInput = wrap.querySelector('.photo-input'); const photoInput = wrap.querySelector('.photo-input');
const updatePhotoImg = () => {
const ts = Date.now();
const imgSrc = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`;
// Update main photo cell
const img = document.createElement('img');
img.src = imgSrc;
img.alt = '';
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
// Update compact row avatar
const compactAvatar = wrap.querySelector('.vl-avatar');
if (compactAvatar) {
compactAvatar.className = 'vl-avatar vl-avatar-photo';
compactAvatar.style.background = '';
compactAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="vl-avatar-img">`;
}
// Update inspector avatar if this voice is currently open
const inspectorAvatar = document.querySelector('.inspector-avatar');
if (inspectorAvatar && wrap.classList.contains('vr-selected')) {
inspectorAvatar.classList.add('insp-avatar-photo');
inspectorAvatar.style.background = '';
inspectorAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="insp-avatar-img">`;
}
v.has_picture = true;
};
photoCell.addEventListener('update-photo', updatePhotoImg);
photoCell.addEventListener('click', () => photoInput.click()); photoCell.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', async () => { photoInput.addEventListener('change', async () => {
if (!photoInput.files.length) return; if (!photoInput.files.length) return;
@ -1972,14 +2098,53 @@ function makeVoiceRow(v) {
try { try {
const r = await fetch('/api/voice/picture', { method:'POST', body:fd }); const r = await fetch('/api/voice/picture', { method:'POST', body:fd });
if (!r.ok) throw new Error((await r.json()).detail); if (!r.ok) throw new Error((await r.json()).detail);
const img = document.createElement('img'); updatePhotoImg();
img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`; toast('Photo uploaded','success');
img.alt = '';
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
v.has_picture = true; toast('Photo uploaded','success');
} catch(e) { toast('Photo upload failed: '+e.message,'error'); } } catch(e) { toast('Photo upload failed: '+e.message,'error'); }
}); });
photoCell.addEventListener('dragenter', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
photoCell.addEventListener('dragover', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
photoCell.addEventListener('dragleave', () => photoCell.classList.remove('drag-over'));
photoCell.addEventListener('drop', async e => {
e.preventDefault();
photoCell.classList.remove('drag-over');
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
photoInput.files = e.dataTransfer.files;
photoInput.dispatchEvent(new Event('change'));
return;
}
let url = e.dataTransfer.getData('text/uri-list');
if (!url) {
const html = e.dataTransfer.getData('text/html');
if (html) {
const match = html.match(/src=["'](.*?)["']/);
if (match) url = match[1];
}
}
if (!url) url = e.dataTransfer.getData('text/plain');
if (url && /^https?:\/\//i.test(url)) {
status('Downloading picture from URL...');
try {
const r = await fetch('/api/voice/picture-url', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: v.id, image_url: url})
});
if (!r.ok) throw new Error((await r.json()).detail);
updatePhotoImg();
toast('Photo saved from URL', 'success');
status('Photo saved successfully');
} catch(err) {
toast('Photo URL download failed: ' + err.message, 'error');
status('Photo URL download failed');
}
}
});
// Per-voice loudness normalization // Per-voice loudness normalization
const normalizeBtn = wrap.querySelector('.normalize-voice-btn'); const normalizeBtn = wrap.querySelector('.normalize-voice-btn');
const dbValue = wrap.querySelector('.vr-db-value'); const dbValue = wrap.querySelector('.vr-db-value');

View File

@ -3,7 +3,7 @@
const SECTIONS = [ const SECTIONS = [
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
's-performance', 's-routing', 's-connect', 's-settings', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
's-llms', 's-conversation', 's-llms', 's-conversation',
]; ];
@ -110,6 +110,7 @@
'/static/js/voice-picker.js', '/static/js/voice-picker.js',
'/static/js/benchmark-voice-picker.js', '/static/js/benchmark-voice-picker.js',
'/static/js/voice-inspector.js', '/static/js/voice-inspector.js',
'/static/js/seed-finder.js',
'/static/js/voice-sources.js', '/static/js/voice-sources.js',
'/static/js/fishaudio-browser.js', '/static/js/fishaudio-browser.js',
'/static/js/integrations.js', '/static/js/integrations.js',
@ -117,10 +118,14 @@
'/static/js/voice-clone.js', '/static/js/voice-clone.js',
'/static/js/voice-library.js', '/static/js/voice-library.js',
'/static/js/tts-preview.js', '/static/js/tts-preview.js',
'/static/js/generation.js',
'/static/js/benchmark.js', '/static/js/benchmark.js',
'/static/js/stt.js', '/static/js/stt.js',
'/static/js/rehearser-parse.js', '/static/js/rehearser-parse.js',
'/static/js/rehearser.js', '/static/js/rehearser.js',
'/static/js/reader.js',
'/static/js/audiobook.js',
'/static/js/character-sheets.js',
]; ];
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1; var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
var _bundleOk = false; var _bundleOk = false;
@ -152,12 +157,10 @@
if (window.initAccessibilityEnhancements) window.initAccessibilityEnhancements(); if (window.initAccessibilityEnhancements) window.initAccessibilityEnhancements();
// E — post-init modules load in background AFTER the UI is already visible. // E — post-init modules load in background AFTER the UI is already visible.
// Engines, AI backends, generation, and conversation sections load while // Engines, AI backends, and conversation load while the user browses other tabs.
// the user browses Voices / Clone / Design, not before.
_loadBatch([ _loadBatch([
'/static/js/engines.js', '/static/js/engines.js',
'/static/js/ai-backends.js', '/static/js/ai-backends.js',
'/static/js/generation.js',
'/static/js/conversation.js', '/static/js/conversation.js',
]); ]);
})(); })();

View File

@ -18,7 +18,7 @@
llms: 's-llms' llms: 's-llms'
}; };
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation']; const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
function storedSection() { function storedSection() {
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; } try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
@ -94,6 +94,9 @@
function showSection(sectionId) { function showSection(sectionId) {
if (!SECTIONS.includes(sectionId)) sectionId = 's-voices'; if (!SECTIONS.includes(sectionId)) sectionId = 's-voices';
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop();
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
setStoredSection(sectionId); setStoredSection(sectionId);
setSectionHash(sectionId); setSectionHash(sectionId);
SECTIONS.forEach(function (id) { SECTIONS.forEach(function (id) {

View File

@ -0,0 +1,147 @@
<div class="section-head">
<span class="section-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span>
<div class="section-title">
<h2>Read Aloud</h2>
<p>Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.</p>
</div>
</div>
<div class="tab-content" id="tab-reader">
<!-- ① Import + controls ─────────────────────────────────────────── -->
<div class="card">
<div class="reader-controls">
<div class="field reader-ctl">
<label>Backend</label>
<select id="reader-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
</div>
<div class="field reader-ctl">
<label>Voice</label>
<div class="reader-voice-pick">
<select id="reader-voice-select" aria-label="Voice"><option value="">— fetch or pick a voice —</option></select>
<button class="btn-secondary btn-sm" id="reader-fetch-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
</div>
</div>
<div class="field reader-ctl">
<label>Speed <span id="reader-speed-label" class="reader-speed-label">1.0×</span></label>
<input type="range" id="reader-speed" min="0.5" max="2" step="0.05" value="1" aria-label="Reading speed">
</div>
<div class="field reader-ctl">
<label>Voice consistency</label>
<select id="reader-chunk-mode" title="Synthesise larger chunks so the voice stays steady across a passage">
<option value="sentence">Per sentence (responsive)</option>
<option value="paragraph">Per paragraph (steadier)</option>
<option value="page">Per page (steadiest)</option>
</select>
</div>
<div class="field reader-ctl reader-ctl-grow">
<label>Tone / style <span class="note">(optional)</span></label>
<input type="text" id="reader-instruct" placeholder="e.g. calm, warm narration" autocomplete="off">
</div>
<div class="field reader-ctl reader-ctl-sm">
<label>Seed <span class="note">(blank = auto)</span></label>
<input type="number" id="reader-seed" placeholder="auto" autocomplete="off">
</div>
<div class="field reader-ctl reader-ctl-sm">
<label>Temperature <span class="note">(01)</span></label>
<input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off">
</div>
<label class="reader-ctl reader-norm-toggle" title="Even out loudness differences between chunks during playback">
<input type="checkbox" id="reader-normalize" checked> Normalise loudness
</label>
<label class="btn-primary reader-import-btn" title="Import a PDF or text file">
<span class="mdi mdi-file-upload-outline"></span> Import
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
</label>
</div>
<div class="reader-backend-hint" id="reader-backend-hint"></div>
</div>
<!-- Saved books library ─────────────────────────────────────────── -->
<div class="card reader-library-card" id="reader-library-card" hidden>
<div class="reader-lib-head">
<h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> My books</h2>
<span class="note">Saved documents with their synthesised audio — reopen to continue.</span>
</div>
<div class="reader-lib-list" id="reader-lib-list"></div>
</div>
<!-- ② Drag & drop / empty state ─────────────────────────────────── -->
<div class="reader-dropzone" id="reader-dropzone">
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon"></span>
<strong>Drag &amp; drop a document here</strong>
<span class="note">.pdf · .txt · .md — or use the Import button above</span>
</div>
<!-- ③ Transport ─────────────────────────────────────────────────── -->
<div class="reader-transport" id="reader-transport" hidden>
<div class="reader-tb-group">
<button class="reh-tb-btn" id="reader-prev" title="Previous sentence"><span class="mdi mdi-skip-previous"></span></button>
<button class="reh-tb-btn reh-tb-play" id="reader-play" title="Play / pause"><span class="mdi mdi-play"></span></button>
<button class="reh-tb-btn" id="reader-stop" title="Stop"><span class="mdi mdi-stop"></span></button>
<button class="reh-tb-btn" id="reader-next" title="Next sentence"><span class="mdi mdi-skip-next"></span></button>
</div>
<div class="reader-progress-wrap">
<div class="reader-progress-track"><div class="reader-progress-fill" id="reader-progress-fill"></div></div>
<span class="reader-progress-label" id="reader-progress-label">0 / 0</span>
</div>
<span class="reader-doc-title" id="reader-doc-title"></span>
</div>
<!-- ④ Synthesise-ahead bar ──────────────────────────────────────── -->
<div class="reader-synthbar" id="reader-synthbar" hidden>
<button class="btn-primary btn-sm" id="reader-synth-all" title="Pre-synthesise the chosen range for gap-free reading">
<span class="mdi mdi-lightning-bolt"></span> Synthesise <span id="reader-synth-scope">all</span>
</button>
<span class="reader-page-range" id="reader-page-range" hidden>
<span class="note">pages</span>
<input type="number" id="reader-page-from" min="1" value="1" class="reader-page-inp" aria-label="From page">
<span class="note"></span>
<input type="number" id="reader-page-to" min="1" value="1" class="reader-page-inp" aria-label="To page">
</span>
<button class="btn-secondary btn-sm" id="reader-select-toggle" title="Click sentences to mark a start and an end"><span class="mdi mdi-cursor-default-click-outline" id="reader-select-icon"></span> <span id="reader-select-label">Select range</span></button>
<span class="reader-sel-hint" id="reader-sel-hint" hidden></span>
<span class="reader-sel-info" id="reader-sel-info" hidden>
<span id="reader-sel-text">selection</span>
<button class="reader-sel-clear" id="reader-sel-clear" title="Clear selection"></button>
</span>
<button class="btn-secondary btn-sm" id="reader-save-lib" title="Save this document + its synthesised audio to your library"><span class="mdi mdi-content-save-outline"></span> Save to library</button>
<button class="btn-secondary btn-sm" id="reader-audiobook-btn" title="Let an LLM detect characters &amp; dialogue in this scope, then open it in the Script Rehearser to cast a voice per character"><span class="mdi mdi-drama-masks"></span> Cast as audiobook</button>
<button class="btn-secondary btn-sm" id="reader-charsheets-btn" title="Extract actor-facing RPG-style character sheets (with page + quote sources) from this scope"><span class="mdi mdi-account-details-outline"></span> Character sheets</button>
<span class="reader-export">
<select id="reader-export-mode" class="reader-page-inp" style="width:auto" aria-label="Export granularity" title="Choose how to group the exported MP3 files">
<option value="page">1 MP3 / page</option>
<option value="sentence">1 MP3 / sentence</option>
</select>
<button class="btn-secondary btn-sm" id="reader-export-btn" title="Download the synthesised audio as MP3 (synthesises any missing sentences first)"><span class="mdi mdi-download"></span> Export MP3</button>
</span>
<div class="reader-synth-prog" id="reader-synth-prog" hidden>
<div class="reader-synth-track"><div class="reader-synth-fill" id="reader-synth-fill"></div></div>
<span class="reader-synth-label" id="reader-synth-label">0 / 0</span>
<button class="btn-secondary btn-sm" id="reader-synth-cancel">Cancel</button>
</div>
</div>
<!-- ⑤ Zoom toolbar + status legend (PDF) ────────────────────────── -->
<div class="reader-toolbar" id="reader-toolbar" hidden>
<div class="reader-zoom" id="reader-zoom">
<button class="btn-secondary btn-sm" id="reader-zoom-fitw" title="Fit width"><span class="mdi mdi-arrow-expand-horizontal"></span> Fit width</button>
<button class="btn-secondary btn-sm" id="reader-zoom-fith" title="Fit height"><span class="mdi mdi-arrow-expand-vertical"></span> Fit height</button>
<button class="btn-secondary btn-sm" id="reader-zoom-two" title="Two pages side by side"><span class="mdi mdi-book-open-outline"></span> Two pages</button>
<span class="reader-zoom-sep"></span>
<button class="btn-secondary btn-sm" id="reader-zoom-out" title="Zoom out"><span class="mdi mdi-minus"></span></button>
<span class="reader-zoom-pct" id="reader-zoom-pct">100%</span>
<button class="btn-secondary btn-sm" id="reader-zoom-in" title="Zoom in"><span class="mdi mdi-plus"></span></button>
</div>
<div class="reader-legend">
<span class="reader-leg reader-leg-pending">Not synthesised</span>
<span class="reader-leg reader-leg-synth">Synthesising</span>
<span class="reader-leg reader-leg-ready">Ready</span>
<span class="reader-leg reader-leg-reading">Reading</span>
</div>
</div>
<!-- ⑥ Document viewport ─────────────────────────────────────────── -->
<div class="reader-doc" id="reader-doc"></div>
</div><!-- /tab-reader -->

View File

@ -311,6 +311,12 @@
<button class="btn-secondary btn-sm" id="reh-tb-synth-all" title="Pre-synthesize all TTS lines for instant playback"> <button class="btn-secondary btn-sm" id="reh-tb-synth-all" title="Pre-synthesize all TTS lines for instant playback">
<span class="mdi mdi-lightning-bolt"></span> Synth all <span class="mdi mdi-lightning-bolt"></span> Synth all
</button> </button>
<button class="btn-secondary btn-sm" id="reh-tb-audiobook" title="Synthesise every line and download the audiobook as MP3 (one file per chapter)">
<span class="mdi mdi-book-music-outline"></span> Audiobook
</button>
<button class="btn-secondary btn-sm" id="reh-charsheets-btn" title="Extract actor-facing RPG-style character sheets (with sources) for this script">
<span class="mdi mdi-account-details-outline"></span> Character sheets
</button>
<button class="btn-secondary btn-sm" id="reh-tb-resynth-stale" title="Re-synthesize only lines whose tone changed" hidden> <button class="btn-secondary btn-sm" id="reh-tb-resynth-stale" title="Re-synthesize only lines whose tone changed" hidden>
<span class="mdi mdi-refresh" style="color:var(--red)"></span> Re-synth stale <span class="mdi mdi-refresh" style="color:var(--red)"></span> Re-synth stale
</button> </button>

View File

@ -65,10 +65,15 @@
<option value="rating">Rating</option> <option value="rating">Rating</option>
<option value="duration">Duration</option> <option value="duration">Duration</option>
<option value="dbfs">Volume dB</option> <option value="dbfs">Volume dB</option>
<option value="source">Source</option>
<option value="seed">Seed</option>
<option value="note">Note</option>
<option value="tag">Tags</option>
<option value="enabled">Active</option> <option value="enabled">Active</option>
</select> </select>
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button> <button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button> <button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button>
<button id="voice-table-view-btn" class="vl-sort-dir-btn" title="Toggle full-width table view"><span class="mdi mdi-table"></span></button>
<button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button> <button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button>
</div> </div>
@ -95,6 +100,24 @@
<button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button> <button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button>
</div> </div>
<div class="vl-table-header" id="vl-table-header">
<div></div>
<div>Img</div>
<div>Play</div>
<div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="flag">Lang <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="gender">Gender <span class="mdi mdi-sort"></span></div>
<div>Type</div>
<div class="vl-th-sortable" data-sort="benchmark">Speed <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="dbfs">dBFS <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="duration">Length <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="rating">Rating <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="source">Source <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="seed">Seed <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="note">Note <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="tag">Tags <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="enabled">Active <span class="mdi mdi-sort"></span></div>
</div>
<div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div> <div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div>
<div class="vl-toolbar"> <div class="vl-toolbar">
<button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button> <button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button>
@ -102,6 +125,8 @@
<button class="btn-secondary vl-tb-btn" id="calculate-db-btn" title="Calculate dBFS">Calc dB</button> <button class="btn-secondary vl-tb-btn" id="calculate-db-btn" title="Calculate dBFS">Calc dB</button>
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button> <button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button>
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy active voice names">Copy active</button> <button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy active voice names">Copy active</button>
<button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes &amp; caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button>
<button class="btn-secondary vl-tb-btn" id="seed-batch-all-btn" title="Generate &amp; cache Seed Finder samples for every active voice (skips already-cached; resumable)"><span class="mdi mdi-dice-multiple-outline"></span> Batch seeds</button>
</div> </div>
<div class="vl-footer"> <div class="vl-footer">

View File

@ -60,8 +60,15 @@ body {
} }
/* Language picker in the sidebar brand */ /* Language picker in the sidebar brand */
.app-lang-picker { margin-top: 8px; font-size: 12px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); font-family: var(--font); cursor: pointer; width: 100%; } .app-lang-toggle {
.app-lang-picker:focus { outline: none; border-color: var(--accent); } margin-left: auto; padding: 2px; border: 1px solid var(--border); border-radius: 5px;
background: var(--panel); cursor: pointer; line-height: 0; display: inline-flex;
align-items: center; transition: border-color .15s, transform .1s;
}
.app-lang-toggle:hover { border-color: var(--accent); }
.app-lang-toggle:active { transform: scale(.94); }
.app-lang-toggle:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.18); }
.app-lang-toggle .fi { width: 20px; height: 14px; border-radius: 2px; }
/* ── App shell ───────────────────────────────────────────────────────────── */ /* ── App shell ───────────────────────────────────────────────────────────── */
#app-shell { #app-shell {
@ -82,6 +89,7 @@ body {
.sidebar-brand h1 { .sidebar-brand h1 {
font-size: 15px; font-weight: 800; color: var(--accent); font-size: 15px; font-weight: 800; color: var(--accent);
letter-spacing: -0.01em; line-height: 1.2; letter-spacing: -0.01em; line-height: 1.2;
display: flex; align-items: center; gap: 6px;
} }
.sidebar-brand p { .sidebar-brand p {
font-size: 14px; color: var(--subtext); margin-top: 3px; font-size: 14px; color: var(--subtext); margin-top: 3px;
@ -603,6 +611,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vr-photo img { width: 100%; height: 100%; object-fit: cover; } .vr-photo img { width: 100%; height: 100%; object-fit: cover; }
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 64px; color: white; font-weight: 700; } .vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 64px; color: white; font-weight: 700; }
.vr-photo:hover::after { content: ''; } .vr-photo:hover::after { content: ''; }
.vr-photo.drag-over { opacity: 0.8; outline: 3px dashed var(--accent); outline-offset: -4px; transition: all 0.2s; }
.vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; width: 100%; } .vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; width: 100%; }
.vr-flag { display: none; } .vr-flag { display: none; }
.vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; } .vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; }
@ -1352,6 +1361,56 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-bench-chip.bench-bad { background: rgba(239,68,68,.1); } .vl-bench-chip.bench-bad { background: rgba(239,68,68,.1); }
.vl-dbfs { display: none; } .vl-dbfs { display: none; }
/* Table View mode */
.vl-tbl-lang, .vl-tbl-gender, .vl-tbl-type, .vl-tbl-bench, .vl-tbl-dbfs, .vl-tbl-len, .vl-tbl-rating, .vl-tbl-source, .vl-tbl-seed, .vl-tbl-note, .vl-tbl-tag, .vl-tbl-active { display: none; }
.vl-table-header { display: none; }
.voices-workbench.table-view .voices-list-pane { width: 100% !important; max-width: 100%; }
.voices-workbench.table-view .voices-inspector-pane { display: none !important; }
.voices-workbench.table-view .vl-table-header {
display: grid;
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
gap: 10px;
align-items: center;
padding: 8px 10px;
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky; top: 0; z-index: 5;
}
.vl-th-sortable { cursor: pointer; display: flex; align-items: center; gap: 3px; transition: color .15s; }
.vl-th-sortable:hover { color: var(--text); }
.vl-th-sortable .mdi { font-size: 12px; opacity: .6; }
.vl-th-sortable:hover .mdi { opacity: 1; color: var(--accent); }
.voices-workbench.table-view .vl-compact {
display: grid;
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
gap: 10px;
align-items: center;
}
.voices-workbench.table-view .vl-bulk-cb { order: 0; margin: 0; justify-self: center; }
.voices-workbench.table-view .vl-info { display: contents; }
.voices-workbench.table-view .vl-meta { display: none; }
.voices-workbench.table-view .vl-avatar { order: 1; width: 32px; height: 32px; }
.voices-workbench.table-view .vr-play-group { order: 2; flex-shrink: 0; }
.voices-workbench.table-view .vl-name { order: 3; display: block; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.voices-workbench.table-view .vl-tbl-lang { display: block; order: 4; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-gender { display: block; order: 5; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-type { display: block; order: 6; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-bench { display: block; order: 7; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-dbfs { display: block; order: 8; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-len { display: block; order: 9; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-rating { display: flex; order: 10; font-size: 14px; color: var(--subtext); }
.voices-workbench.table-view .vl-tbl-rating .star.on { color: #f59e0b; }
.voices-workbench.table-view .vl-tbl-source { display: block; order: 11; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
.voices-workbench.table-view .vl-tbl-seed { display: block; order: 12; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-note { display: block; order: 13; font-size: 12px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; }
.voices-workbench.table-view .vl-tbl-tag { display: block; order: 14; font-size: 12px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.voices-workbench.table-view .vl-tbl-active { display: block; order: 15; font-size: 16px; text-align: center; }
/* Synth mode panel (segmented control + preview input) */ /* Synth mode panel (segmented control + preview input) */
.vl-synth-panel { background: var(--surface); } .vl-synth-panel { background: var(--surface); }
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; } .vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }
@ -2707,6 +2766,65 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.preview-persona-btn:hover { background: rgba(13,148,136,.12); } .preview-persona-btn:hover { background: rgba(13,148,136,.12); }
.preview-persona-btn:disabled { opacity: .5; cursor: default; } .preview-persona-btn:disabled { opacity: .5; cursor: default; }
/* ── Seed Finder ─────────────────────────────────────────────────────────── */
.seed-finder-group { margin-top: 12px; }
.seed-finder-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
.seed-finder-config { display: flex; flex-direction: column; gap: 10px; }
.seed-finder-row { display: flex; flex-direction: column; gap: 5px; }
.seed-finder-label { font-size: 12px; font-weight: 600; color: var(--subtext); }
.seed-finder-text {
width: 100%; font-size: 13px; padding: 8px 10px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text);
font-family: var(--font); resize: vertical; min-height: 68px;
transition: border-color .15s;
}
.seed-finder-text:focus { border-color: var(--accent); outline: none; }
.seed-finder-params { flex-direction: row; flex-wrap: wrap; align-items: flex-end; gap: 14px; }
.seed-finder-field { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text); }
.seed-finder-field input[type="number"] {
width: 64px; padding: 5px 7px; font-size: 13px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text); text-align: center;
}
.seed-finder-field select {
padding: 5px 8px; font-size: 13px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text);
}
.seed-finder-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.seed-finder-status { font-size: 12px; color: var(--subtext); }
.seed-finder-pin { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border); }
.seed-finder-pin-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.seed-finder-pin-input { width: 90px; padding: 5px 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); font-family: var(--font); }
.seed-finder-pin-status { font-size: 12px; color: var(--accent); font-weight: 600; }
.seed-finder-progress { display: flex; flex-direction: column; gap: 6px; }
.seed-finder-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); }
.seed-finder-bar { height: 100%; background: var(--accent); transition: width .2s ease; border-radius: 999px; }
.seed-finder-results { display: flex; flex-direction: column; gap: 6px; }
.seed-finder-result-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 7px 10px; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--surface);
font-size: 13px;
}
.seed-finder-result-row.sfr-active { border-color: var(--accent); background: rgba(var(--accent-rgb, 124,77,255),.07); }
.seed-finder-result-row.seed-finder-result-error { opacity: .6; }
.sfr-seed { font-weight: 700; min-width: 58px; color: var(--text); }
.sfr-dur { color: var(--subtext); font-size: 12px; min-width: 36px; }
.sfr-play-btn { font-size: 12px; padding: 4px 10px; }
.sfr-use-btn { font-size: 12px; padding: 4px 10px; }
.sfr-saved { font-size: 12px; color: var(--green); font-weight: 600; }
.sfr-error { font-size: 12px; color: var(--red); }
.sfr-batch-header {
font-size: 12px; color: var(--subtext); font-style: italic;
padding: 4px 2px 2px;
}
.sfr-batch-tag { color: var(--accent); font-style: italic; }
.seed-finder-result-row.sfr-batch { background: rgba(var(--accent-rgb, 124,77,255),.03); }
/* ── Conversation Playground ─────────────────────────────────────────────── */ /* ── Conversation Playground ─────────────────────────────────────────────── */
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; } .conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
.conv-config-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; } .conv-config-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
@ -3771,3 +3889,246 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
min-height: 46px; min-height: 46px;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* ── Read Aloud reader ─────────────────────────────────────────────────── */
.reader-controls {
display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end;
}
.reader-ctl { margin: 0; min-width: 150px; }
.reader-ctl-grow { flex: 1 1 200px; }
.reader-ctl-sm { min-width: 96px; max-width: 130px; }
.reader-ctl-sm input { width: 100%; }
.reader-norm-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--subtext); white-space: nowrap; align-self: center; cursor: pointer; }
.reader-backend-hint { margin-top: 10px; font-size: 12px; color: var(--subtext); display: flex; align-items: flex-start; gap: 6px; }
.reader-backend-hint .mdi { color: var(--accent); flex-shrink: 0; }
.reader-backend-hint b { color: var(--text); }
.reader-backend-hint.reader-hint-warn { color: var(--text); }
.reader-backend-hint:empty { display: none; }
.reader-voice-pick { display: flex; gap: 6px; }
.reader-voice-pick select { flex: 1; min-width: 0; }
.reader-speed-label { color: var(--accent); font-weight: 700; }
.reader-ctl input[type=range] { width: 100%; accent-color: var(--accent); }
.reader-import-btn { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.reader-dropzone {
margin-top: 14px; padding: 30px 16px; border: 2px dashed var(--border);
border-radius: 12px; text-align: center; color: var(--subtext);
display: flex; flex-direction: column; align-items: center; gap: 5px;
transition: border-color .15s, background .15s;
}
.reader-dropzone-icon { font-size: 34px; opacity: .5; }
.reader-dropzone.dragover { border-color: var(--accent); background: rgba(37,99,235,.06); color: var(--accent); }
.reader-dropzone.dragover .reader-dropzone-icon { opacity: 1; }
.reader-transport {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
margin-top: 14px; padding: 10px 14px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius);
}
.reader-tb-group { display: flex; gap: 4px; }
.reader-progress-wrap { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 160px; }
.reader-progress-track { flex: 1; height: 6px; border-radius: 4px; background: var(--panel); overflow: hidden; }
.reader-progress-fill { height: 100%; width: 0; background: var(--accent); transition: width .25s; }
.reader-progress-label { font-size: 12px; color: var(--subtext); white-space: nowrap; min-width: 56px; text-align: right; }
.reader-doc-title { font-size: 13px; font-weight: 700; color: var(--text); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Saved-books library */
.reader-lib-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.reader-lib-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
.reader-book {
position: relative; padding: 14px; border: 1px solid var(--border); border-radius: 10px;
background: var(--panel); cursor: pointer; transition: border-color .15s, transform .1s, box-shadow .15s;
}
.reader-book:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(0,0,0,.08); transform: translateY(-1px); }
.reader-book-icon { font-size: 26px; color: var(--accent); line-height: 1; }
.reader-book-title { font-weight: 700; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reader-book-meta { font-size: 11px; color: var(--subtext); margin-top: 2px; }
.reader-book-bar { position: relative; height: 6px; border-radius: 4px; background: var(--border); margin-top: 10px; overflow: hidden; }
.reader-book-bar-synth { position: absolute; inset: 0 auto 0 0; background: rgba(34,197,94,.45); }
.reader-book-bar-read { position: absolute; inset: 0 auto 0 0; background: var(--accent); }
.reader-book-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); margin-top: 5px; }
.reader-book-del {
position: absolute; top: 6px; right: 6px; border: none; background: rgba(0,0,0,.04);
color: var(--subtext); border-radius: 6px; width: 24px; height: 24px; cursor: pointer; opacity: 0; transition: opacity .15s;
}
.reader-book:hover .reader-book-del { opacity: 1; }
.reader-book-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.1); }
/* Synthesise-ahead bar + range selection */
.reader-synthbar {
display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
margin-top: 10px; padding: 8px 12px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius);
}
.reader-page-range { display: inline-flex; align-items: center; gap: 6px; }
.reader-page-inp {
width: 56px; padding: 4px 6px; border: 1px solid var(--border);
border-radius: 6px; background: var(--panel); color: var(--text); font-family: var(--font);
}
#reader-select-toggle.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.reader-sel-info { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--accent); font-weight: 600; }
.reader-sel-clear { border: none; background: none; color: var(--subtext); cursor: pointer; font-size: 13px; padding: 0 2px; }
.reader-sel-clear:hover { color: var(--red, #ef4444); }
.reader-sel-hint { font-size: 12px; color: var(--subtext); }
.reader-sel-hint b { color: var(--accent); }
.reader-export { display: inline-flex; align-items: center; gap: 6px; }
#reader-audiobook-btn .mdi { color: var(--accent); }
/* Audiobook live casting — floating, non-blocking, minimisable panel */
.ab-castpanel {
position: fixed; right: 18px; bottom: 18px; z-index: 1000;
width: min(880px, 96vw); max-height: 82vh; display: flex; flex-direction: column;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden;
}
.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); cursor: default; }
.ab-castpanel-head > .mdi { color: var(--accent); font-size: 18px; }
.ab-castpanel-title { font-weight: 800; font-size: 14px; }
.ab-castpanel-count { font-size: 12px; color: var(--subtext); }
.ab-castpanel-btn { border: none; background: none; color: var(--subtext); cursor: pointer; padding: 2px 5px; border-radius: 6px; line-height: 1; }
.ab-castpanel-btn:hover { background: var(--panel); color: var(--text); }
.ab-castpanel-bar { margin: 0; border-radius: 0; }
.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--border); }
.ab-cv-done { font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
.ab-cv-done .mdi { color: #10b981; }
/* minimised → just the header chip in the corner */
.ab-castpanel.minimized { width: auto; max-width: 320px; }
.ab-castpanel.minimized .ab-castpanel-bar,
.ab-castpanel.minimized .ab-cv-body,
.ab-castpanel.minimized .ab-castpanel-foot { display: none; }
.ab-castpanel.minimized .ab-castpanel-head { cursor: pointer; border-bottom: none; }
.ab-cv-body { display: grid; grid-template-columns: 1fr 220px; gap: 12px; padding: 12px; }
.ab-cv-feed { height: 44vh; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; background: var(--panel); display: flex; flex-direction: column; gap: 3px; }
.ab-cv-row { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 12.5px; padding: 2px 0; align-items: baseline; }
.ab-cv-row.is-narr { opacity: .6; }
.ab-cv-spk { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ab-cv-row.is-narr .ab-cv-spk { color: var(--subtext); font-weight: 600; }
.ab-cv-txt { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); }
.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; height: 44vh; }
.ab-cv-side-head { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-bottom: 6px; }
.ab-cv-chars { display: flex; flex-direction: column; gap: 5px; }
.ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
.ab-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 20px; }
.ab-chip-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); flex-shrink: 0; }
.ab-chip b { margin-left: auto; color: var(--subtext); font-weight: 700; }
.audiobook-narr-note { color: var(--subtext); }
@media (max-width: 560px) { .ab-cv-body { grid-template-columns: 1fr; } .ab-cv-side { height: auto; max-height: 22vh; } }
/* Audiobook casting overlay */
.audiobook-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.audiobook-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px 24px; width: min(420px, 90vw); box-shadow: 0 12px 40px rgba(0,0,0,.25); }
.audiobook-title { font-weight: 800; font-size: 15px; display: flex; align-items: center; gap: 8px; }
.audiobook-title .mdi { color: var(--accent); font-size: 20px; }
.audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; }
.audiobook-box .reader-synth-track { width: 100%; }
.audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; }
.audiobook-count { font-weight: 500; font-size: 12px; color: var(--subtext); margin-left: auto; }
.audiobook-preview-box { width: min(760px, 94vw); }
.audiobook-preview-box .audiobook-title { flex-wrap: wrap; }
.audiobook-seglist { max-height: 56vh; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px; margin-top: 6px; }
.audiobook-seg { display: grid; grid-template-columns: 130px 96px 1fr; gap: 8px; align-items: center; padding: 4px 6px; border-radius: 6px; }
.audiobook-seg:nth-child(odd) { background: var(--panel); }
.audiobook-seg.is-dialog { box-shadow: inset 3px 0 0 var(--accent); }
.audiobook-seg input { padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); font-size: 12px; font-family: var(--font); min-width: 0; }
.audiobook-seg input:disabled { opacity: .4; }
.audiobook-seg-text { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#reh-tb-audiobook .mdi { color: var(--accent); }
#reader-charsheets-btn .mdi, #reh-charsheets-btn .mdi { color: var(--accent); }
/* Character sheets overlay */
.cs-box { width: min(900px, 95vw); max-height: 90vh; display: flex; flex-direction: column; }
.cs-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 12px; align-content: start; }
.cs-group-label { grid-column: 1 / -1; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-top: 4px; }
.cs-card { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--panel); }
.cs-card.cs-main { box-shadow: inset 4px 0 0 var(--accent); }
.cs-card.cs-supp { box-shadow: inset 4px 0 0 var(--border); }
.cs-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.cs-name { font-weight: 800; font-size: 15px; }
.cs-archetype { font-size: 12px; font-weight: 700; color: var(--accent); }
.cs-tier { margin-left: auto; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; }
.cs-aliases { font-size: 12px; color: var(--subtext); font-style: italic; margin: 2px 0 6px; }
.cs-fields { margin: 0; display: flex; flex-direction: column; gap: 5px; }
.cs-row { display: grid; grid-template-columns: 120px 1fr; gap: 8px; font-size: 12.5px; }
.cs-row dt { font-weight: 700; color: var(--subtext); }
.cs-row dd { margin: 0; color: var(--text); }
.cs-row ul { margin: 0; padding-left: 16px; }
.cs-sources { margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--border); font-size: 11px; color: var(--subtext); }
.cs-sources b { color: var(--text); }
.cs-sources .mdi { color: var(--accent); }
.reader-synth-prog { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
.reader-synth-track { width: 130px; height: 6px; border-radius: 4px; background: var(--panel); overflow: hidden; }
.reader-synth-fill { height: 100%; width: 0; background: rgba(234,179,8,.9); transition: width .15s; }
.reader-synth-label { font-size: 12px; color: var(--subtext); min-width: 52px; }
/* Select mode: clickable sentences (pointer, not crosshair — it's click-to-pick) */
.reader-doc.reader-selecting { box-shadow: inset 0 0 0 2px var(--accent); }
.reader-doc.reader-selecting .reader-stat { pointer-events: auto; cursor: pointer; }
.reader-doc.reader-selecting .reader-stat:hover { background: rgba(37,99,235,.16); outline: 1px dashed rgba(37,99,235,.5); }
.reader-doc.reader-selecting .reader-word { cursor: pointer; }
.reader-doc.reader-selecting .reader-word:hover { background: rgba(37,99,235,.18); }
/* committed range */
.reader-stat.sel { outline: 2px solid var(--accent); outline-offset: 0; background: rgba(37,99,235,.10); }
.reader-word.in-sel { box-shadow: inset 0 -2px 0 var(--accent); }
/* the start "anchor" pulses while waiting for the end click */
.reader-stat.sel-anchor { outline: 2px solid var(--accent); background: rgba(37,99,235,.20); animation: readerAnchorPulse 1s ease-in-out infinite; }
.reader-word.sel-anchor { box-shadow: inset 0 -2px 0 var(--accent); background: rgba(37,99,235,.22); animation: readerAnchorPulse 1s ease-in-out infinite; }
@keyframes readerAnchorPulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
/* Zoom toolbar + status legend */
.reader-toolbar {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;
margin-top: 10px; padding: 8px 12px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius);
}
.reader-zoom { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.reader-zoom-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.reader-zoom-pct { font-size: 12px; font-weight: 700; min-width: 44px; text-align: center; color: var(--subtext); }
.reader-legend { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 11px; color: var(--subtext); }
.reader-leg { display: inline-flex; align-items: center; gap: 5px; }
.reader-leg::before { content: ''; width: 11px; height: 11px; border-radius: 3px; }
.reader-leg-pending::before { background: rgba(239,68,68,.45); }
.reader-leg-synth::before { background: rgba(234,179,8,.65); }
.reader-leg-ready::before { background: rgba(34,197,94,.55); }
.reader-leg-reading::before { background: rgba(37,99,235,.6); }
.reader-doc {
margin-top: 14px; max-height: 72vh; overflow-y: auto;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.reader-doc:empty { display: none; }
.reader-parsing { display: flex; align-items: center; gap: 10px; padding: 24px; color: var(--subtext); font-size: 13px; }
.reader-doc.two-page { flex-direction: row; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
.reader-page {
position: relative; flex: 0 0 auto;
box-shadow: 0 2px 10px rgba(0,0,0,.12); background: #fff;
}
.reader-canvas { display: block; }
.reader-overlay { position: absolute; inset: 0; pointer-events: none; }
.reader-hl-word {
position: absolute; border-radius: 3px; background: rgba(37,99,235,.30); z-index: 3;
box-shadow: 0 0 0 1.5px rgba(37,99,235,.75); transition: left .12s, top .12s, width .12s, height .12s;
}
/* Per-sentence synthesis-state overlay (PDF) */
.reader-stat { position: absolute; border-radius: 3px; z-index: 1; transition: background .2s; }
.reader-stat-pending { background: rgba(239,68,68,.12); }
.reader-stat-synth { background: rgba(234,179,8,.30); }
.reader-stat-ready { background: rgba(34,197,94,.18); }
.reader-stat-reading { background: rgba(37,99,235,.20); z-index: 2; }
.reader-text {
max-width: 760px; background: var(--surface); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px 34px; line-height: 1.85; font-size: 17px;
}
.reader-text-para { margin: 0 0 1em; }
.reader-word { border-radius: 3px; padding: 0 1px; cursor: pointer; transition: background .12s; }
/* Per-sentence synthesis-state tint (text) */
.reader-word.stat-pending { background: rgba(239,68,68,.09); }
.reader-word.stat-synth { background: rgba(234,179,8,.22); }
.reader-word.stat-ready { background: rgba(34,197,94,.14); }
.reader-word.stat-reading { background: rgba(37,99,235,.15); }
.reader-word.is-word { background: rgba(37,99,235,.42) !important; box-shadow: 0 0 0 1px rgba(37,99,235,.6); }