Compare commits

...

26 Commits

Author SHA1 Message Date
fb7dcb52f8 feat: stacked Conversation config + browser WAV decode; changelog (v1.10.1)
- Conversation Playground: STT/LLM/TTS/System prompt now full-width stacked.
- Fix conversation mic upload on ARM64: decode recording in-browser to
  16 kHz mono WAV, bypassing server ffmpeg webm/EBML parser (with fallback).
- Document v1.10.0 (unified Library + character tags) and v1.10.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:35:57 +02:00
73c27944ac feat: collapsible nested sidebar groups
Group headers (Voice Actions, Speak, Setup) are now collapsible parents;
Tags nests under Library; Integrations + Settings nest under Setup.
Navigating a section auto-opens its whole ancestor tree chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:30:14 +02:00
52994df70b feat: unified Library + character tags + shared cast (v1.10.0)
Restructure the sidebar into Voices / Speak / Setup groups and fuse the
Read Aloud and Script Rehearser features into one workspace:

- New combined Library section (Books · Theater Plays · Characters/Cast)
  with cross-links (Rehearse a book, Read Aloud a play).
- Character tags like voice tags: one record, many productions, reusable
  across books/scripts; seeded with origin book, editable in the editor.
- Shared cast resolution: opening a production fills empty cast slots from
  the character roster (matched by book OR tag); voice choices written back
  on save. Productions joined by normalized title (prodKey).
- Tags nav group for voices (distinct tags + counts, Cloned/Designed/Fav).
- Fold standalone Characters into Library; Read Aloud now a single entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:12:22 +02:00
464420b11c feat: richer default Seed Finder test sentence (v1.9.8)
Longer mixed DE/EN phrase covering dotted numbers, compound nouns,
umlauts, special characters, English technical vocabulary, time
formats and prose — reveals more of a voice's character per seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:24:37 +02:00
217d92b91a fix: voice inspector TDZ crash + batch ops respect selection (v1.9.7)
- voice-inspector: move `curGender` const before its first use — it was
  declared at line 86 but referenced at line 61, causing a ReferenceError
  (temporal dead zone) that silently aborted selectVoice() on every click
- Calc dB, Precompute, Batch Seeds now filter to checked voices when a
  bulk selection is active, matching Benchmark's existing behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 10:06:06 +02:00
6b52a346d5 fix: stop casting feed from hijacking scroll and deleting visible rows
- Auto-scroll to bottom is now gated on the user being within 80px of
  the bottom. Scrolling up to review or edit a line freezes the feed
  in place — new rows are still added, but the viewport doesn't move.
- Row-trimming is likewise suppressed while the user is scrolled up,
  so old lines stay visible as long as they're being read/edited.
  Trim limit raised from 80 → 600 rows so almost nothing is evicted.
- A floating "↓ Live" pill button appears at the bottom of the feed
  whenever the user has scrolled up. Clicking it returns to the live
  bottom and re-enables auto-scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:47:25 +02:00
a978e39e49 feat: expandable LLM panel + page markers in casting feed; fix page numbering
Casting view improvements:
- "LLM Reading…" row now shows a preview of the passage being processed.
  A chevron button expands it to a full scrollable view of the passage
  text (up to 260px), so you can follow what the LLM is reading live.
- Page-break dividers ("Page N") appear in the casting feed whenever the
  source PDF page changes, giving a real-time view of page boundaries.

Bug fix:
- Page numbers in the Rehearser script were 0-indexed (PDF-internal) so
  the first page break showed "Page 1" for what was PDF page 2, etc.
  Now always emits 1-indexed page numbers (\f${page+1}).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:23:19 +02:00
868ce6a7bd feat: show page numbers on page-break markers in Script Rehearser
When a PDF audiobook is cast and saved to the Rehearser, page breaks now
carry the source PDF page number. In the Stage view each divider renders
as "— Page N —" instead of the generic "— Page break —".

Implementation:
- audiobook.js: `audiobookBuildScript()` encodes the page number in the
  form-feed line (\fN instead of bare \f) at each page boundary.
- rehearser-parse.js: `parseScript()` now matches `line.startsWith('\f')`
  and extracts the trailing page number into `line.page`.
- rehearser.js: `buildScriptPage()` renders "— Page N —" when `line.page`
  is set; `toScript()` round-trips the number back (\fN) so it survives
  save/reload; `importPDFScript()` also encodes page numbers (\f<pageIdx+1>)
  when importing screenplay PDFs directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:32 +02:00
01a2097c5e feat: add Native Speed control to Try it out and Read aloud
Exposes the `speed` parameter (0.5–2.0) on the TTS `/v1/audio/speech`
request so audio is generated at the target tempo natively via the
faster-qwen3-tts backend rather than post-processing.

- Backend: `/api/tts-preview` now extracts and forwards a `speed`
  override (clamped 0.1–4.0) through the same extra-params mechanism
  already used for seed/temperature; backends that reject it fall back
  cleanly via `_post_tts_with_fallback`.
- Try it out: "Native Speed" number input (0.5–2, step 0.05) added to
  the text card; value persists in localStorage per browser; passed as
  `extra` through `createTtsAudioSource` and `generateChunkedTts`.
- Read aloud: "Native Speed" control added to the generation controls
  row alongside seed/temperature; included in `readerGenParams()` and
  saved/restored with library documents (each book tracks its own speed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:01:08 +02:00
65e2784d2d Preserve audiobook pagination when saving to Rehearser (v1.9.2)
audiobookScopeText now records PDF page boundaries as character offsets in the
scope text; audiobookBuildScript realigns each segment against the source and
emits \f page-break markers at the nearest boundary. Saved/opened cast scripts
keep the book's pagination instead of collapsing to one flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:49:51 +02:00
e62abbcd1d Fix character-sheet LLM URL + audiobook preview; Stage font controls (v1.9.1)
- Character sheets: stop sending the dead localhost:11434 default; fall back to
  the server's configured llm_url so extraction uses the same working LLM.
- Audiobook casting: hoist highlightText to module scope so the "Review & cast"
  manual-correction preview renders its segment rows again.
- Stage: unify narrator/dialog font size, add A-/A+ play text-size control
  (scales A4 + paginated views), and make the cast chip list collapsible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:44:30 +02:00
495550bf6a Release v1.9.0 — Character Library + richer character sheets
Add a persistent, book-scoped Character Library (new Characters section,
IndexedDB) that auto-fills from Character-sheet analysis with editable cards.
Enrich extraction with six narrative fields (backstory, relationships,
motivation, fears, mannerisms, voice/speech) plus the greyscale Good↔Evil
alignment bar, arc arrow, and 5-area Deep Analysis. Add ⋯ separators between
non-contiguous passages in Recast unknown, and harden dialogue attribution
against hallucination with same-language emotion tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:22:40 +02:00
18b4c1959c Release v1.8.1 2026-06-25 18:48:42 +02:00
ca599be5c3 style: reduce gap between stars in compact rating display 2026-06-23 10:40:48 +02:00
7e4c80d784 feat: simplify compact list meta row and update gender badges 2026-06-23 10:34:20 +02:00
7a296e4273 feat: use MDI silhouettes for avatars and add flag to compact meta row 2026-06-23 10:28:06 +02:00
307922b90c feat: Add row highlighting during batch voice benchmark 2026-06-23 09:13:41 +02:00
6ffba2d588 fix: Add new paths to config, fix Uvicorn logging, and update Storage settings 2026-06-22 21:36:59 +02:00
c26dabe296 feat: Implement dark theme variables and add save button to general settings 2026-06-22 21:35:16 +02:00
3908f365e5 fix: Abort fetch when cancelling audiobook casting and close UI immediately 2026-06-22 17:36:05 +02:00
3cc39b8ace fix: Restore edit mode button on page load and move select-all checkbox 2026-06-22 16:46:44 +02:00
af49bb4d6f fix: Correct CSS grid order for inline name input 2026-06-22 15:22:04 +02:00
cada45fb15 feat: Add Inline Table Edit Mode 2026-06-22 15:15:13 +02:00
6f0b278bd9 fix: Resolve table header click-to-sort logic 2026-06-22 15:07:55 +02:00
f82a2cfb6a chore: Commit all pending local changes 2026-06-22 14:59:37 +02:00
3f1e82ef81 fix: Add drag-over visual feedback CSS for inspector avatar 2026-06-22 14:49:31 +02:00
47 changed files with 4392 additions and 1467 deletions

View File

@ -9,6 +9,136 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.10.1] — 2026-06-27
### Added
- **Collapsible nested sidebar**: group headers *Voice Actions*, *Speak* and *Setup* are now expandable parent menus; **Tags** nests under *Library*, and *Integrations* (App Routing · Connect Apps) and *Settings* nest under *Setup*. Opening a section auto-expands its whole ancestor chain.
### Changed
- **Conversation Playground** config stacks full-width — Speech to Text, Language Model, Text to Speech and System Prompt each on their own row for clarity.
### Fixed
- **Conversation microphone failed with "Audio upload failed: Decoding failed / EBML header parsing failed"** on ARM64: recorded audio is now decoded in the browser and uploaded as 16 kHz mono WAV, bypassing the server-side ffmpeg webm parser entirely (falls back to the raw blob if browser decoding is unavailable).
---
## [1.10.0] — 2026-06-27
### Added
- **Combined Library** (new *Library* section under *Speak*) with three tabs — **Books**, **Theater Plays**, **Characters / Cast** — bringing audiobooks, rehearsals and the character roster into one place. Book and play cards cross-link: *Rehearse* a book, *Read Aloud* a play.
- **Character tags** (like voice tags): each character carries a comma-separated list of productions, seeded with its origin book and editable in the character editor. A character can now be **reused across several books/scripts** — one record, many tags.
- **Shared cast resolution**: opening a production in the Rehearser (and audiobook casting, which routes through it) auto-fills empty cast slots from the shared character roster, matched by book **or** tag. Cast voice choices are written back to existing characters on save, keeping the roster in sync. Productions are joined by normalized title (`prodKey`).
- **Tags** navigation group under *Voices*: distinct voice tags with live counts, plus the *Cloned / Designed / Favourites* predicates, each filtering the voice list.
### Changed
- **Sidebar restructured** into clearer groups: *Voices* (Library · Tags · Voice Actions), *Speak* (Quick Play · Conversation · Read Aloud · Script Rehearsal · Library), *Setup* (Benchmark · Engines · Integrations · Settings). "Try It Out" is now **Quick Play**; "My Voices" is now **Library**.
- The standalone **Characters** section was folded into the combined Library; **Read Aloud** is now a single entry (its library lives in the combined Library); the Rehearser's Library/Cast steps moved into the combined Library, leaving Stage · Summary · Import/Export in the sidebar.
---
## [1.9.8] — 2026-06-27
### Changed
- **Seed Finder default sentence**: updated to a longer mixed DE/EN test phrase covering numbers with dots (3.567), compound nouns, umlauts, special characters, English technical vocabulary, time formats, and motivational prose — gives a more complete picture of a voice's character per seed.
---
## [1.9.7] — 2026-06-27
### Fixed
- **Voice inspector broken**: `curGender` was referenced before its `const` declaration inside `selectVoice()`, causing a temporal dead zone ReferenceError that silently prevented the inspector from opening. Clicking a voice now correctly opens the detail panel again.
- **Batch operations ignore selection**: Calc dB, Precompute, and Batch Seeds now operate only on the checked (selected) voices when a selection is active, matching the existing behaviour of Benchmark. Previously all three always ran on all active / visible voices regardless of selection.
---
## [1.9.6] — 2026-06-26
### Fixed
- **Casting feed — scroll hijacking**: the feed no longer forces the view to the bottom while you are scrolled up reviewing or editing earlier lines. Auto-scroll only fires when you are already within 80px of the bottom.
- **Casting feed — vanishing text**: the trimming limit was raised from 80 to 600 rows, and trimming is now suppressed while you're scrolled up, so older lines stay visible as long as you're looking at them.
### Added
- **Casting feed — "↓ Live" jump button**: a floating pill button appears at the bottom of the feed whenever you've scrolled up. Click it to immediately return to the live bottom of the feed and re-enable auto-scroll.
---
## [1.9.5] — 2026-06-26
### Added
- **Casting view — page-break lines**: as the LLM processes a PDF audiobook, a **"Page N"** divider row now appears in the casting feed whenever the source PDF page changes. This gives a live view of where each page boundary falls within the script.
- **Casting view — expandable LLM passage panel**: the "LLM Reading…" indicator now shows a preview of the passage being processed. A **chevron button** (▾/▴) expands it into a full scrollable view of the passage text so you can follow exactly what the LLM is reading and thinking about.
### Fixed
- **Page numbers were off by one**: page-break markers emitted into the Rehearser script now use the correct 1-indexed PDF page number (was storing 0-indexed, so "Page 1" showed for what was actually the second PDF page).
---
## [1.9.4] — 2026-06-26
### Changed
- **Audiobook casting — page numbers in Rehearser**: page-break markers now carry the source PDF page number. In the Script Rehearser Stage, each page divider shows **"— Page N —"** instead of the generic "— Page break —", making it easy to cross-reference the audiobook script against the book. Applies both to audiobooks cast from Read Aloud PDFs and to screenplay PDFs imported directly into the Rehearser.
---
## [1.9.3] — 2026-06-26
### Added
- **Native Speed for Try it out & Read aloud**: a **Native Speed** control (range 0.5×–2.0×, default 1.0) is now available in both the *Try it out* and *Read aloud* sections. It passes the `speed` parameter directly to the TTS generation request (natively via the faster-qwen3-tts backend), producing audio at the target tempo from the model rather than using post-processing pitch/time-shift. Try it out persists the chosen speed in localStorage (per browser); Read aloud saves it with the document in the library (each book remembers its own speed).
---
## [1.9.2] — 2026-06-26
### Fixed
- **Audiobook casting — pagination preserved on save**: saving (or opening) a cast audiobook as a Script Rehearsal now keeps the book's **page breaks**. Read-aloud page boundaries are tracked through casting and re-emitted as `\f` markers at the nearest segment boundary, so the Rehearser paginates the saved script to match the source PDF instead of producing one continuous flow.
---
## [1.9.1] — 2026-06-26
### Fixed
- **Character sheets — "Connection refused" failures**: the analysis could send a dead `localhost:11434` LLM URL to the server (when app settings hadn't loaded yet), causing every passage to fail. It now never falls back to that hard-coded default — when no endpoint is explicitly chosen it lets the **server use its own configured `llm_url`**, so character-sheet extraction uses the same working LLM as the rest of the app.
- **Audiobook casting — empty "Review & cast" preview**: opening the manual-correction preview after a cast rendered no lines because the name-highlighter (`highlightText`) was scoped to the live cast view only. It is now shared, so you can review, fix speakers/emotions, and open in the Rehearser again.
### Changed
- **Script Rehearser / Stage — one text size**: narrator (action) text and spoken dialogue now use the **same font size** instead of mismatched 15px/16px, so the play reads evenly.
- **Script Rehearser / Stage — text-size control**: a new **A− / A+** control in the Stage toolbar shrinks or enlarges the whole play (persists per browser), in both A4 and paginated/scroll views.
- **Script Rehearser / Stage — collapsible character list**: the row of cast chips can now be **collapsed or expanded** via a "Characters (N)" toggle to free up vertical space.
---
## [1.9.0] — 2026-06-26
### Added
- **Character Library**: a new **Characters** section in the sidebar collects every character the LLM extracts into a persistent, browsable library (IndexedDB), grouped by the book or script they came from. Running **Character sheets** from Read Aloud or the Script Rehearser now auto-saves each character (keyed by *book + name*), merging in new detail on re-runs. Each card can be **edited** inline or **deleted**, and keeps its greyscale Good↔Evil alignment bar, arc arrow, page/line sources, and the 5-area psychological **Deep Analysis**. New module `static/js/characters-library.js`.
- **Richer character sheets**: extraction now gathers six more book-derived fields per character — *Backstory & Origin, Relationships, Motivation, Fears, Mannerisms & Habits,* and a casting-focused *Voice & Speech* pattern (accent, pacing, register, verbal tics).
- **Character sheets — morality at a glance**: each sheet now shows a **greyscale Good↔Evil alignment bar** (white = good, black = evil) with a 0–100 score, plus an **arc arrow** indicating whether the character stays put, *descends* (↘ good→bad), *redeems* (↗ bad→good), or follows a *complex* (↕) path. Also added **Clothing & Appearance** and **Capabilities** fields and richer physical detail (height, hair, eyes, skin, gait), with sources now carrying a short category **line hint**.
- **Character sheets — Deep Analysis**: a per-character button runs a 5-area psychological & narrative study (Core Flaw & Desire · Agency & Passivity · Dialogue & Voice · Narrative Arc · Paradox & Depth) in a modal. New endpoint `POST /api/character-deep-analysis`.
- **Audiobook casting — Recast & rescue tools**: after a cast run you can now **Recast all** (re-run the whole document with tweaked settings/prompt), **Recast unknown** (re-attribute only the leftover *Unknown* lines using surrounding context), run a **2nd Quality Run — Verify** pass that re-checks every speaker assignment and resolves Unknowns, and **Save script** straight to Script Rehearsals without leaving the page.
### Changed
- **Recast unknown — passage separators**: re-analysing only the *Unknown* speakers now shows a `⋯` divider between non-adjacent passages, so it's clear where one excerpt ends and another begins.
- **Dialogue attribution — fidelity & language**: the attribution prompt now forbids hallucinating/summarising (segments must reconstruct the passage word-for-word), emits emotion tags in the **same language** as the text (e.g. German *wütend/flüsternd*), and demands strict JSON. Removed the brittle text-script fallback parser that could mis-split prompt echoes into fake speakers.
---
## [1.8.1] — 2026-06-25
### Added
- **Audiobook casting — Warmup request**: Added an invisible "Wake up" request before starting the attribution loop, showing a clear "Waking up LLM model..." status. This absorbs the 3–4 minute cold-boot time of massive models (like 120B/30B via llama-swap) without freezing the UI or timing out the first real book chunk.
- **Audiobook casting — Quick-cancel**: The character assignment popup now has a close button and responds to the `Escape` key, so you can easily dismiss it if you click "Assign" by accident.
### Changed
- **Audiobook casting — Narrator quick-pick**: The `📖 Narrator` role is now always pinned to the top of the manual assignment dropdown list, so you don't have to scroll or type to revert a mis-cast line to narration.
- **LLM timeout increased**: The hardcoded API timeout in `routes/conversation.py` for all LLM calls has been increased from 3 minutes to 10 minutes (`timeout=600`), providing plenty of headroom for dynamic model proxies to download and load models into VRAM on demand.
### Fixed
- **Read Aloud — "Fit width" scaling bug**: Clicking "Fit width" on PDFs with a small cover page (e.g. A5) but larger subsequent pages previously zoomed the cover perfectly but blew the text pages up massively (e.g. 241%), forcing horizontal scrolling. The scale is now computed against the *maximum* width of all pages, guaranteeing the entire book fits.
- **Read Aloud — Left-side text cut-off (CSS bug)**: Fixed a flexbox centering issue on the document container where a zoomed PDF page that was wider than the screen would overflow equally on both sides. Because browsers only scroll to the right, the left edge of the page was permanently inaccessible. Fixed by replacing `align-items: center` with `margin: 0 auto`.
- **Audiobook casting — Minified bundle caching**: Features added directly to JS files weren't appearing because the app was serving an older, cached minified bundle. The bundle has been rebuilt and version cache-busting ensures the new UI shows up immediately.
---
## [1.8.0] — 2026-06-22
### Added
@ -393,7 +523,8 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.1...HEAD
[1.8.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...v1.8.1
[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.1.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.0.0...v1.1.0

View File

@ -1 +1 @@
1.8.0
1.10.1

View File

@ -35,10 +35,10 @@ _SETTINGS_KEYS = {
"refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation",
"captures_default_voice",
"client_voice_bindings",
"llm_url", "llm_model",
"llm_url", "llm_model", "llm_api_key",
# Browser-persistent UI state
"engine_local_urls", "engine_container_names", "custom_engine_cards",
"refine_llm_url", "conv_llm_url", "seed_finder_text",
"seed_finder_dir", "pt_dir", "audiobook_prompt",
}
# ── TTS stability defaults ────────────────────────────────────────────────────
@ -269,6 +269,7 @@ def _load_settings() -> dict:
"captures_default_voice": "",
"client_voice_bindings": {},
"llm_url": "http://localhost:11434/v1",
"llm_api_key": "",
"llm_model": "",
"engine_local_urls": {},
"engine_container_names": {},
@ -276,6 +277,8 @@ def _load_settings() -> dict:
"refine_llm_url": "",
"conv_llm_url": "",
"seed_finder_text": "",
"seed_finder_dir": "",
"pt_dir": "",
}
if CONFIG_FILE.exists():
try:

View File

@ -18,6 +18,7 @@ services:
- ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
- ./logs:/logs:rw # application logs
- /home/sparky/Docker/tts-dgx-spark-faster-qwen3-tts/config:/tts-config:rw
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./static:/app/static:ro
- ./server.py:/app/server.py:ro
@ -31,7 +32,8 @@ services:
- VOICES_DIR=/voices
- OUTPUT_DIR=/voices/active_voices
# Containers that load WAV files and need a restart after voice changes
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
- TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone
- TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
extra_hosts:

View File

@ -0,0 +1 @@
Die 3.567 neuen High-End Geräte für das Server-Update benötigen eine außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance bei großer Last zu gewährleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - 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.

View File

@ -33,6 +33,7 @@ services:
- /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/logs:/logs:rw
- /home/sparky/Docker/tts-dgx-spark-faster-qwen3-tts/config:/tts-config:rw
- /var/run/docker.sock:/var/run/docker.sock:ro
# ── Hot-reload mounts (changes apply on container recreate, not restart) ──
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/server.py:/app/server.py:ro
@ -47,8 +48,8 @@ services:
- VOICES_DIR=/voices
- OUTPUT_DIR=/voices/active_voices
- TTS_STREAM_URL=http://host.docker.internal:8023
- TTS_CONTAINER_NAME=faster-qwen3-tts-voiceclone
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
- TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone
- TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
extra_hosts:

View File

@ -64,6 +64,7 @@ def _sentence_split(buf: str) -> int:
def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str:
"""Inline synchronous persona rewrite; raises RuntimeError on failure."""
settings = _load_settings()
system = (
f"Rephrase the user's text as if spoken by this character: {persona}\n"
"Keep the same meaning but adapt vocabulary, tone, and style to the character. "
@ -82,11 +83,12 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str
resp = requests.post(
f"{llm_url.rstrip('/')}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60,
)
resp.raise_for_status()
result = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
if result.startswith('"') and result.endswith('"'):
result = result[1:-1].strip()
return result
@ -149,11 +151,12 @@ async def refine_text(request: Request):
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60,
)
resp.raise_for_status()
refined = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
refined = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
if refined.startswith('"') and refined.endswith('"'):
refined = refined[1:-1].strip()
return {"text": refined, "original": text}
@ -209,11 +212,12 @@ async def rewrite_with_persona(request: Request):
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
timeout=60,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
resp.raise_for_status()
result = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
if result.startswith('"') and result.endswith('"'):
result = result[1:-1].strip()
return {"text": result, "original": text, "persona": persona}
@ -291,11 +295,12 @@ async def analyze_characters(request: Request):
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
timeout=180,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
for candidate in (content, _extract_json_block(content)):
if not candidate:
@ -389,10 +394,11 @@ async def match_characters_voices(request: Request):
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=180,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
break
except requests.exceptions.ConnectionError as e:
# llama-swap (and similar) often drop the first request while swapping/loading
@ -460,9 +466,9 @@ async def character_sheets(request: Request):
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"
"You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich "
"character sheets passage by passage as a book is read. Extract playable, action-oriented sheets "
"an actor can use to immediately know how to PLAY the character.\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 "
@ -472,22 +478,37 @@ async def character_sheets(request: Request):
"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"
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
"- alignment: strict moral code + the one line they will never cross\n"
"- moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous\n"
"- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'\n"
"- arc_note: one sentence explaining the arc or moral position visible so far\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"
"- capabilities: combat, magic, social, technical, or other demonstrated abilities\n"
"- backstory: origin, formative background and history revealed in the text\n"
"- relationships: key allies, family, rivals and enemies — name them and how they relate\n"
"- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)\n"
"- fears: their deepest fears, phobias or dread\n"
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
"- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\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"
"- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, "
"a short verbatim quote that supports the sheet (1-5 entries), and a brief label (e.g. 'appearance', 'alignment'). "
"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'
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"",'
'"inventory":[],"secret":"","conflict_style":"","win_condition":"",'
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
)
user = (
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
@ -500,17 +521,18 @@ async def character_sheets(request: Request):
{"role": "user", "content": user},
],
"temperature": 0.4,
"max_tokens": 3500,
"max_tokens": 4096,
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=240,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
except Exception as e:
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
@ -539,14 +561,110 @@ async def character_sheets(request: Request):
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]})
# Clamp moral alignment score
try:
mas = int(s.get("moral_alignment_score") or 50)
mas = max(0, min(100, mas))
except (TypeError, ValueError):
mas = 50
arc = str(s.get("arc_direction") or "neutral").strip()
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
arc = "neutral"
s.update({
"name": name, "inventory": inv[:3],
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
"sources": src[:5],
"moral_alignment_score": mas,
"arc_direction": arc,
})
clean.append(s)
names.append(name)
return {"sheets": clean, "characters": names}
@router.post("/api/character-deep-analysis")
async def character_deep_analysis(request: Request):
"""Run a deep 5-area psychological analysis of a single character.
Body: {name, role, goal, summary, text_excerpt, language, llm_url, model}
Returns: {analysis: {core_flaw, agency, dialogue_voice, narrative_arc, paradox}}
"""
data = await request.json()
name: str = (data.get("name") or "").strip()
role: str = (data.get("role") or "unknown").strip()
goal: str = (data.get("goal") or "").strip()
summary: str = (data.get("summary") or "").strip()
excerpt: str = (data.get("text_excerpt") or "").strip()
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 name:
raise HTTPException(400, "Character name required")
lang_note = f" Write the analysis in {language}." if language else ""
system = (
f"You are an expert developmental editor, literary coach, and psychologist specializing in "
f"profound character studies.{lang_note} Provide a deep, multi-layered psychological and "
f"narrative analysis of the character in exactly this JSON structure:\n"
'{"core_flaw":"<analysis of primary flaw, its psychological origin, and conflict with their desire>",'
'"agency":"<are they active or reactive? where does their momentum stall? what harder choices could force growth?>",'
'"dialogue_voice":"<speech pattern analysis: how does their dialogue reflect background, anxieties, hidden motives? how do they sound distinct?>",'
'"narrative_arc":"<how does their worldview shift? what is the lie they believe vs the truth they must accept?>",'
'"paradox":"<3 specific contradictions in behaviour/personality that make them feel authentic and unpredictable>"}\n'
"Be ruthlessly comprehensive and honest. Use concrete examples from the text. "
"Map where character development might break down or feel cliché. "
"Respond with STRICT JSON only — no markdown, no explanation./no-think"
)
user = (
f'CHARACTER: "{name}"\n'
f"Role in story: {role}\n"
f"Core goal: {goal or 'not specified'}\n"
f"Background/key actions: {summary or 'not specified'}\n\n"
+ (f"Text excerpt for analysis:\n{excerpt[:4000]}\n\n" if excerpt else "")
+ "Provide the deep psychological analysis now."
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.6,
"max_tokens": 2048,
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
except Exception as e:
raise HTTPException(502, f"LLM deep analysis failed: {e}")
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
analysis = {}
for cand in (content, _extract_json_block(content)):
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and "core_flaw" in parsed:
analysis = parsed
break
except Exception:
continue
if not analysis:
analysis = {"core_flaw": raw, "agency": "", "dialogue_voice": "", "narrative_arc": "", "paradox": ""}
return {"name": name, "analysis": analysis}
@router.post("/api/attribute-dialogue")
async def attribute_dialogue(request: Request):
"""Split a prose passage into attributed segments for a multi-voice audiobook.
@ -568,42 +686,55 @@ async def attribute_dialogue(request: Request):
raise HTTPException(400, "No text provided")
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
base_prompt = _settings.get("audiobook_prompt") or ""
if not base_prompt.strip():
base_prompt = (
"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 the exact English word '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 in the SAME LANGUAGE as the text (e.g. for German: wütend, traurig, flüsternd); '' 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"
"- DO NOT hallucinate, summarize, or alter the text. The combined text of your segments MUST exactly match the original passage, word for word, except for dropped quotation marks."
)
else:
if lang_hint:
base_prompt += f"\n\n{lang_hint}"
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'
f"{base_prompt}\n\n"
"You MUST respond with a single, valid JSON object containing a 'segments' array.\n"
"Do NOT output any reasoning, chain of thought, or conversational text. Output ONLY the raw JSON object:\n"
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}'
)
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
+ "Passage to attribute:\n" + text + "\n\n"
+ "Return ONLY valid JSON. Do not include markdown blocks or any other text."
)
payload: dict = {
"messages": [
@ -611,23 +742,31 @@ async def attribute_dialogue(request: Request):
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": min(4096, max(700, len(text) // 2 + 800)),
"max_tokens": min(8192, max(2048, len(text) + 1000)),
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=180,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or msg.get("reasoning_content") or "").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)):
block = _extract_json_block(content)
candidates = [content, block]
if block:
# If the JSON was truncated by token limits, appending ]} to the last complete object often salvages it.
candidates.append(block + "]}")
for cand in candidates:
if not cand:
continue
try:
@ -637,6 +776,13 @@ async def attribute_dialogue(request: Request):
break
except Exception:
continue
if not segments:
# If the LLM completely failed to output JSON despite the strict system prompt,
# it means it dropped into a conversational/reasoning hallucination.
# Do NOT try to parse its reasoning as a script. Abort and preserve the book text.
segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}]
# Normalise + collect speaker roster
clean, chars = [], []
for seg in segments:
@ -1106,14 +1252,23 @@ async def mcp_sse(request: Request):
# ── Conversation playground ───────────────────────────────────────────────────
@router.get("/api/conversation/llm-models")
async def conversation_llm_models(url: str = ""):
async def conversation_llm_models(url: str = "", api_key: str = ""):
"""List models from a local LLM endpoint (Ollama / vLLM / LM Studio)."""
from core.validation import _validate_http_url
settings = _load_settings()
base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
key_to_use = api_key or settings.get("llm_api_key") or "sk-dummy-key"
try:
base = _validate_http_url(base, allow_private=True)
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": "Bearer no-key"})
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
# Fallback for gateways like LiteLLM that might prefer /models over /v1/models
if r.status_code != 200 and base.endswith("/v1"):
fallback_base = base[:-3]
r2 = requests.get(f"{fallback_base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
if r2.status_code == 200:
r = r2
if r.status_code == 200:
payload = r.json()
data = payload.get("data", []) if isinstance(payload, dict) else []
@ -1122,9 +1277,10 @@ async def conversation_llm_models(url: str = ""):
for item in data if item
]
return {"models": models, "url": base}
except Exception:
pass
return {"models": [], "url": base}
else:
return {"models": [], "url": base, "error": r.text}
except Exception as e:
return {"models": [], "url": base, "error": str(e)}
def _make_tts_task(
@ -1242,7 +1398,7 @@ async def conversation_turn(
try:
resp = requests.post(
f"{eff_llm_url}/chat/completions", json=llm_payload,
headers={"Authorization": "Bearer no-key"}, stream=True, timeout=120,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, stream=True, timeout=120,
)
resp.raise_for_status()
for raw_line in resp.iter_lines():

View File

@ -19,18 +19,24 @@ _LOCAL_CONTAINER_DEFS: list[dict] = [
{"name": "ollama", "label": "Ollama", "role": "llm", "port": 11434, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ollama/ollama", "repo": "https://ollama.com", "description": "Local LLM runtime. The app uses its OpenAI-compatible /v1 endpoint."},
{"name": "llama-swap", "label": "llama-swap", "role": "llm", "port": 28080, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ghcr.io/mostlygeek/llama-swap:cuda", "repo": "https://github.com/mostlygeek/llama-swap", "description": "Model swapping proxy for local LLMs. Useful as the default Rehearser LLM endpoint."},
{"name": "llama-qwen35-4b", "label": "Qwen3.5 4B LLM", "role": "llm", "port": 19001, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "local", "repo": "", "description": "Dedicated Qwen LLM container. Use if it exposes an OpenAI-compatible /v1 API."},
{"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup; restart after adding or editing voices."},
{"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Best Qwen path for tone, style, and character prompts."},
{"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "CustomVoice speakers with per-request instruct overrides for tone/style."},
{"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:streaming", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."},
{"name": "vllm", "label": "vLLM", "role": "llm", "port": 8000, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "vllm/vllm-openai:latest", "repo": "https://github.com/vllm-project/vllm", "description": "High-throughput GPU inference. Serve any HuggingFace model with PagedAttention."},
{"name": "lmstudio", "label": "LM Studio", "role": "llm", "port": 1234, "stack": "", "image": "local", "repo": "https://lmstudio.ai", "description": "GUI app for running quantized models. Built-in model browser, chat UI, and a local server with OpenAI API."},
{"name": "tts-faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup; restart after adding or editing voices."},
{"name": "tts-faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Best Qwen path for tone, style, and character prompts."},
{"name": "tts-faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "CustomVoice speakers with per-request instruct overrides for tone/style."},
{"name": "tts-faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:streaming", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."},
{"name": "faster-whisper", "label": "faster-whisper", "role": "stt", "port": 8010, "stack": "faster-whisper-dgx-spark", "image": "local", "repo": "https://github.com/SYSTRAN/faster-whisper", "description": "Fast local Whisper transcription endpoint."},
{"name": "whisper-cpp", "label": "whisper.cpp", "role": "stt", "port": 8085, "stack": "", "image": "local", "repo": "https://github.com/ggml-org/whisper.cpp", "description": "Minimal C++ Whisper with a built-in HTTP server. CPU or Metal/CUDA. Low memory, fast on consumer hardware."},
{"name": "fish-speech-api", "label": "Fish-Speech API", "role": "tts", "port": 38080, "stack": "fish-speech-dgx-spark", "image": "local", "repo": "https://github.com/fishaudio/fish-speech", "description": "Tone-aware cloning backend. Recommended for Rehearser character voices when you need both identity and emotion."},
{"name": "nemotron-asr-streaming", "label": "Nemotron ASR Streaming", "role": "stt", "port": 8006, "stack": "nemotron-asr-streaming", "image": "local", "repo": "https://github.com/pipecat-ai/nemotron-january-2026", "description": "Streaming ASR service. Use as a custom STT endpoint if it exposes OpenAI transcription or /transcribe."},
{"name": "parakeet-stt-multi-9011", "label": "NVIDIA Parakeet STT", "role": "stt", "port": 8007, "stack": "nvidia-parakeet-voice-recognition", "image": "local", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "Parakeet STT container on port 8007."},
{"name": "nvidia-magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. Fixed speaker voices, fast multilingual synthesis."},
{"name": "nvidia-parakeet-asr", "label": "NVIDIA Parakeet Router ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet speech router endpoint."},
{"name": "nvidia-parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "Parakeet RNNT NIM multilingual ASR on port 8092."},
{"name": "kokoro-tts", "label": "Kokoro FastAPI", "role": "tts", "port": 8880, "stack": "kokoro-tts", "image": "local", "repo": "", "description": "OpenAI-compatible TTS. Built-in voices: af_bella, bf_emma, am_adam... ~300 MB RAM."},
{"name": "vibevoice-msft-tts", "label": "VibeVoice TTS", "role": "tts", "port": 8027, "stack": "vibevoice", "image": "local", "repo": "", "description": "VibeVoice REST service. Simple text-to-speech backend."},
{"name": "nvidia-zeroshot-nim", "label": "NVIDIA Zeroshot NIM", "role": "tts", "port": 8093, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Zeroshot clone endpoint with audio_prompt."},
{"name": "nvidia-flow-nim", "label": "NVIDIA Flow NIM", "role": "tts", "port": 8094, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Flow clone endpoint with audio_prompt and transcript."},
{"name": "voicebox", "label": "Voicebox", "role": "llm", "port": 17493, "stack": "voicebox", "image": "local", "repo": "", "description": "Companion voice app/control plane. Not used as a normal TTS engine unless wrapped as an API."},
{"name": "whisperx-blackwell", "label": "WhisperX Blackwell", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service. Use as custom STT if its API is OpenAI-compatible or /transcribe."},
{"name": "xtts-dgx-spark", "label": "XTTS v2", "role": "tts", "port": 8005, "stack": "xtts_v2", "image": "local", "repo": "https://github.com/daswer123/xtts-api-server", "description": "XTTS v2 OpenAI-compatible speaker endpoint."},
@ -110,14 +116,15 @@ async def restart_local_container(name: str):
@router.get("/api/probe-url")
async def probe_url(url: str, type: str = ""):
async def probe_url(url: str, type: str = "", api_key: str = ""):
"""Server-side API probe — checks service-specific endpoints and validates JSON responses."""
base = _normalize_service_url(url).rstrip("/")
for _suffix in ("/v1", "/api/v1"):
if base.endswith(_suffix):
base = base[: -len(_suffix)]
break
hdrs = {"User-Agent": "TTS-Voice-Creator/probe"}
key_to_use = api_key if api_key else "sk-dummy-key"
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"}
if type == "llm":
checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)]
@ -156,7 +163,7 @@ async def probe_url(url: str, type: str = ""):
return {"ok": False, "error": last_err}
@router.get("/api/engine-models")
async def engine_models(url: str, type: str = ""):
async def engine_models(url: str, type: str = "", api_key: str = ""):
"""Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser."""
base = _normalize_service_url(url).rstrip("/")
if not base:
@ -171,9 +178,10 @@ async def engine_models(url: str, type: str = ""):
last_err = "No response"
seen: set[str] = set()
models: list[str] = []
key_to_use = api_key if api_key else "sk-dummy-key"
for endpoint in candidates:
try:
r = requests.get(endpoint, timeout=6, headers={"Authorization": "Bearer no-key", "User-Agent": "TTS-Voice-Creator/models"})
r = requests.get(endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"})
if r.status_code >= 400:
last_err = f"HTTP {r.status_code} from {endpoint}"
continue

View File

@ -641,9 +641,23 @@ async def rename_voice(request: Request):
if backup and backup.exists():
if backup.resolve() != new_backup.resolve():
backup.rename(new_backup)
new_meta = _load_meta(new_audio)
# Also rename TTS config assets if mounted
tts_config = Path("/tts-config")
if tts_config.exists():
old_pt = tts_config / "speakers" / f"{old_id}.pt"
if old_pt.exists():
old_pt.rename(tts_config / "speakers" / f"{new_id}.pt")
old_seeds = tts_config / "seed_samples" / old_id
if old_seeds.exists() and old_seeds.is_dir():
old_seeds.rename(tts_config / "seed_samples" / new_id)
new_meta = _load_meta(new_audio)
if backup and backup.exists():
new_meta["original_backup"] = str(new_backup)
_save_meta(new_audio, new_meta)
new_meta["needs_tts_restart"] = True
_save_meta(new_audio, new_meta)
return {"new_id": new_id, "path": str(new_audio), "file_type": new_audio.suffix.lower().lstrip(".")}

View File

@ -68,6 +68,7 @@ async def reader_list_docs():
continue
meta["id"] = d.name
meta["synthCount"] = len(_audio_indices(d))
meta["hasCover"] = (d / "cover.jpg").is_file()
docs.append(meta)
docs.sort(key=lambda m: m.get("updated") or "", reverse=True)
return {"docs": docs}
@ -170,3 +171,21 @@ async def reader_get_audio(doc_id: str, idx: int):
if not f.is_file():
raise HTTPException(404, "Audio not found")
return FileResponse(str(f), media_type="audio/mpeg")
@router.put("/api/reader/docs/{doc_id}/cover")
async def reader_put_cover(doc_id: str, request: Request):
d = _doc_dir(doc_id)
if not d.is_dir():
raise HTTPException(404, "Document not found")
body = await request.body()
(d / "cover.jpg").write_bytes(body)
return {"ok": True}
@router.get("/api/reader/docs/{doc_id}/cover")
async def reader_get_cover(doc_id: str):
f = _doc_dir(doc_id) / "cover.jpg"
if not f.is_file():
raise HTTPException(404, "Cover not found")
return FileResponse(str(f), media_type="image/jpeg", headers={"Cache-Control": "max-age=31536000"})

View File

@ -160,7 +160,7 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
except Exception:
pass
try:
r = requests.get(f"{base}/v1/models", timeout=3)
r = requests.get(f"{base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
if r.status_code == 200:
ok = True
payload = r.json()

View File

@ -470,6 +470,14 @@ async def tts_preview(request: Request):
_overrides[_k] = int(_v) if _k == "seed" else float(_v)
except (TypeError, ValueError):
pass
_speed_v = data.get("speed")
if _speed_v is not None and _speed_v != "":
try:
_speed_f = float(_speed_v)
if _speed_f != 1.0:
_overrides["speed"] = max(0.1, min(4.0, _speed_f))
except (TypeError, ValueError):
pass
if _overrides:
from core.config import _tts_extra_params
settings = dict(settings)

View File

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

View File

@ -5,6 +5,7 @@ import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import Response
@ -18,7 +19,19 @@ logger = logging.getLogger("uvicorn.error")
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(title="TTS Voice Creator - Clone and Design")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Uvicorn resets loggers during startup when run via CLI.
# We must attach our handlers here to ensure they capture the active logs.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
l = logging.getLogger(name)
if _buf_handler not in l.handlers:
l.addHandler(_buf_handler)
if "_file_handler" in globals() and _file_handler not in l.handlers:
l.addHandler(_file_handler)
yield
app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan)
# ── Response compression ──────────────────────────────────────────────────────
# Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.8.0-3">
<link rel="stylesheet" href="/static/style.css?v=1.10.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -119,67 +119,101 @@
<div class="nav-group-label">Voices</div>
<div class="nav-item nav-tree-head active" data-nav-section="s-voices" id="nav-voices-head" onclick="navTo('s-voices')">
<span class="nav-icon"><span class="mdi mdi-account-voice"></span></span>
<span class="nav-label">My Voices</span>
<span class="nav-label">Library</span>
<span class="nav-badge" id="nav-voices-count"></span>
<span class="nav-chevron" id="nav-voices-chevron" onclick="event.stopPropagation();toggleNavTree('nav-voices-tree','nav-voices-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree open" id="nav-voices-tree">
<div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')"><span class="mdi mdi-account-voice"></span> All voices <span class="ntc" id="ntc-all"></span></div>
<div class="nav-tree-item" data-voice-cat="cloned" onclick="navVoicesCat('cloned')"><span class="mdi mdi-microphone-variant"></span> Cloned <span class="ntc" id="ntc-cloned"></span></div>
<div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')"><span class="mdi mdi-auto-fix"></span> Designed <span class="ntc" id="ntc-designed"></span></div>
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')"><span class="mdi mdi-star-outline"></span> Favorites <span class="ntc" id="ntc-favorites"></span></div>
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')"><span class="mdi mdi-star-outline"></span> Favourites <span class="ntc" id="ntc-favorites"></span></div>
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')"><span class="mdi mdi-eye-off-outline"></span> Hidden <span class="ntc" id="ntc-hidden"></span></div>
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')"><span class="mdi mdi-wrench-outline"></span> Library tools</div>
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')"><span class="mdi mdi-wrench-outline"></span> Tools</div>
<div class="nav-tree-head nav-subhead" id="nav-tags-head" onclick="toggleNavTree('nav-tags-tree','nav-tags-chevron')">
<span class="nav-icon"><span class="mdi mdi-tag-multiple-outline"></span></span>
<span class="nav-label">Tags</span>
<span class="nav-chevron" id="nav-tags-chevron"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-tags-tree"></div>
</div>
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')"> <span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice</div>
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div>
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div>
<div class="nav-item nav-tree-head" data-nav-section="s-rehearser" id="nav-rehearser-head" onclick="navTo('s-rehearser')">
<span class="nav-icon"><span class="mdi mdi-theater"></span></span>
<span class="nav-label">Script Rehearser</span>
<span class="nav-chevron" id="nav-rehearser-chevron" onclick="event.stopPropagation();toggleNavTree('nav-rehearser-tree','nav-rehearser-chevron')"><span class="mdi mdi-chevron-down"></span></span>
<div class="nav-group-head" id="nav-actions-head" onclick="toggleNavTree('nav-actions-tree','nav-actions-chevron')">
<span class="nav-label">Voice Actions</span>
<span class="nav-chevron" id="nav-actions-chevron"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-rehearser-tree">
<div class="nav-tree-item is-active" data-rehearser-phase="1" onclick="navRehearserPhase(1)"><span class="mdi mdi-bookshelf"></span> Library</div>
<div class="nav-tree-item" data-rehearser-phase="2" onclick="navRehearserPhase(2)"><span class="mdi mdi-account-group-outline"></span> Cast</div>
<div class="nav-tree-item" data-rehearser-phase="3" onclick="navRehearserPhase(3)"><span class="mdi mdi-theater"></span> Stage</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" id="nav-actions-tree">
<div class="nav-tree-item" data-nav-section="s-clone" onclick="navTo('s-clone')"><span class="mdi mdi-microphone-variant"></span> Clone a Voice</div>
<div class="nav-tree-item" data-nav-section="s-design" onclick="navTo('s-design')"><span class="mdi mdi-auto-fix"></span> Design a Voice</div>
<div class="nav-tree-item" data-nav-section="s-studio" onclick="navTo('s-studio')"><span class="mdi mdi-earth"></span> Get a Voice Online</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-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
<div class="nav-group-label" style="margin-top:6px">Setup</div>
<div class="nav-item nav-tree-head" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')">
<span class="nav-icon"><span class="mdi mdi-brain"></span></span>
<span class="nav-label">Engines</span>
<span class="nav-chevron" id="nav-engines-chevron" onclick="event.stopPropagation();toggleNavTree('nav-engines-tree','nav-engines-chevron')"><span class="mdi mdi-chevron-down"></span></span>
<div class="nav-group-head" id="nav-speak-head" onclick="toggleNavTree('nav-speak-tree','nav-speak-chevron')">
<span class="nav-label">Speak</span>
<span class="nav-chevron" id="nav-speak-chevron"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-engines-tree">
<div class="nav-tree-item is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')"><span class="mdi mdi-brain"></span> Language Models</div>
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-text-to-speech"></span> Text to Speech</div>
<div class="nav-tree" id="nav-speak-tree">
<div class="nav-tree-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"><span class="mdi mdi-play"></span> Quick Play</div>
<div class="nav-tree-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"><span class="mdi mdi-forum-outline"></span> Conversation</div>
<div class="nav-tree-item" data-nav-section="s-reader" onclick="navTo('s-reader')"><span class="mdi mdi-book-open-page-variant-outline"></span> Read Aloud</div>
<div class="nav-tree-head nav-subhead" data-nav-section="s-rehearser" id="nav-rehearser-head" onclick="navTo('s-rehearser')">
<span class="nav-icon"><span class="mdi mdi-theater"></span></span>
<span class="nav-label">Script Rehearsal</span>
<span class="nav-chevron" id="nav-rehearser-chevron" onclick="event.stopPropagation();toggleNavTree('nav-rehearser-tree','nav-rehearser-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-rehearser-tree">
<div class="nav-tree-item is-active" data-rehearser-phase="3" onclick="navRehearserPhase(3)"><span class="mdi mdi-theater"></span> Stage</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>
<div class="nav-tree-head nav-subhead" data-nav-section="s-library" id="nav-library-head" onclick="navTo('s-library')">
<span class="nav-icon"><span class="mdi mdi-bookshelf"></span></span>
<span class="nav-label">Library</span>
<span class="nav-chevron" id="nav-library-chevron" onclick="event.stopPropagation();toggleNavTree('nav-library-tree','nav-library-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-library-tree">
<div class="nav-tree-item is-active" data-library-view="books" onclick="navLibraryView('books')"><span class="mdi mdi-book-open-page-variant-outline"></span> Books</div>
<div class="nav-tree-item" data-library-view="plays" onclick="navLibraryView('plays')"><span class="mdi mdi-theater"></span> Theater Plays</div>
<div class="nav-tree-item" data-library-view="characters" onclick="navLibraryView('characters')"><span class="mdi mdi-account-box-multiple-outline"></span> Characters / Cast</div>
</div>
</div>
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')"> <span class="nav-icon"><span class="mdi mdi-swap-horizontal"></span></span> App Routing</div>
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')"> <span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps</div>
<div class="nav-item nav-tree-head" data-nav-section="s-settings" id="nav-settings-head" onclick="navTo('s-settings')">
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span>
<span class="nav-label">Settings</span>
<span class="nav-chevron" id="nav-settings-chevron" onclick="event.stopPropagation();toggleNavTree('nav-settings-tree','nav-settings-chevron')"><span class="mdi mdi-chevron-down"></span></span>
<div class="nav-group-head" id="nav-setup-head" onclick="toggleNavTree('nav-setup-tree','nav-setup-chevron')">
<span class="nav-label">Setup</span>
<span class="nav-chevron" id="nav-setup-chevron"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-settings-tree">
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')"><span class="mdi mdi-tune-vertical"></span> General</div>
<div class="nav-tree-item" data-settings-cat="connections" onclick="navSettingsCat('connections')"><span class="mdi mdi-lan-connect"></span> Connections</div>
<div class="nav-tree-item" data-settings-cat="playback" onclick="navSettingsCat('playback')"><span class="mdi mdi-speaker-outline"></span> Playback</div>
<div class="nav-tree-item" data-settings-cat="captures" onclick="navSettingsCat('captures')"><span class="mdi mdi-record-circle-outline"></span> Captures</div>
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')"><span class="mdi mdi-code-json"></span> Payloads</div>
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')"><span class="mdi mdi-folder-outline"></span> Storage</div>
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')"><span class="mdi mdi-key-outline"></span> API Keys</div>
<div class="nav-tree-item" data-settings-cat="backup" onclick="navSettingsCat('backup')"><span class="mdi mdi-backup-restore"></span> Backup</div>
<div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')"><span class="mdi mdi-text-box-outline"></span> Logs</div>
<div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')"><span class="mdi mdi-information-outline"></span> About</div>
<div class="nav-tree" id="nav-setup-tree">
<div class="nav-tree-item" data-nav-section="s-performance" onclick="navTo('s-performance')"><span class="mdi mdi-speedometer"></span> Benchmark</div>
<div class="nav-tree-head nav-subhead" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')">
<span class="nav-icon"><span class="mdi mdi-brain"></span></span>
<span class="nav-label">Engines</span>
<span class="nav-chevron" id="nav-engines-chevron" onclick="event.stopPropagation();toggleNavTree('nav-engines-tree','nav-engines-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-engines-tree">
<div class="nav-tree-item is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')"><span class="mdi mdi-brain"></span> Language Models</div>
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-account-voice"></span> Text to Speech</div>
</div>
<div class="nav-tree-head nav-subhead" id="nav-integrations-head" onclick="toggleNavTree('nav-integrations-tree','nav-integrations-chevron')">
<span class="nav-icon"><span class="mdi mdi-transit-connection-variant"></span></span>
<span class="nav-label">Integrations</span>
<span class="nav-chevron" id="nav-integrations-chevron"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-integrations-tree">
<div class="nav-tree-item" data-nav-section="s-routing" onclick="navTo('s-routing')"><span class="mdi mdi-swap-horizontal"></span> App Routing</div>
<div class="nav-tree-item" data-nav-section="s-connect" onclick="navTo('s-connect')"><span class="mdi mdi-api"></span> Connect Apps</div>
</div>
<div class="nav-tree-head nav-subhead" data-nav-section="s-settings" id="nav-settings-head" onclick="navTo('s-settings')">
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span>
<span class="nav-label">Settings</span>
<span class="nav-chevron" id="nav-settings-chevron" onclick="event.stopPropagation();toggleNavTree('nav-settings-tree','nav-settings-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-settings-tree">
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')"><span class="mdi mdi-tune-vertical"></span> General</div>
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')"><span class="mdi mdi-code-json"></span> Payloads</div>
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')"><span class="mdi mdi-folder-outline"></span> Storage</div>
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')"><span class="mdi mdi-key-outline"></span> API Keys</div>
<div class="nav-tree-item" data-settings-cat="backup" onclick="navSettingsCat('backup')"><span class="mdi mdi-backup-restore"></span> Backup</div>
<div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')"><span class="mdi mdi-text-box-outline"></span> Logs</div>
<div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')"><span class="mdi mdi-information-outline"></span> About</div>
</div>
</div>
</nav>
@ -263,6 +297,7 @@
<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-library" style="display:none"></section>
</main>
</div><!-- /app-shell -->
@ -270,12 +305,34 @@
<div id="toast" aria-live="polite" aria-atomic="true"></div>
<div id="status-bar" role="status" aria-live="polite" aria-atomic="true">Loading…</div>
<!-- Global Directory Browser Modal -->
<div class="reh-modal-overlay" id="global-dir-browser-modal" hidden style="z-index:9999;">
<div class="reh-modal-box" style="width:500px; max-width:90vw; max-height:80vh;">
<div class="reh-modal-head">
<span>Browse Container Folders</span>
<button type="button" class="btn-icon" onclick="document.getElementById('global-dir-browser-modal').hidden = true;"><span class="mdi mdi-close"></span></button>
</div>
<div style="padding:16px; overflow-y:auto; flex:1;">
<div class="vef-dir-browser" style="margin-top:0; border:1px solid var(--border); border-radius:var(--radius); background:var(--bg-card); display:flex; flex-direction:column; min-height: 250px;">
<div class="vef-breadcrumb" id="global-dir-browser-crumb" style="padding:8px 12px; border-bottom:1px solid var(--border); background:var(--bg-sunken); overflow-x:auto; white-space:nowrap;"></div>
<div class="vef-dir-list" id="global-dir-browser-list" style="padding:8px; overflow-y:auto; flex:1; max-height:300px;"><span class="vef-loading" style="padding:8px; color:var(--text-muted);">Loading…</span></div>
</div>
<p class="note" style="margin-top:12px;">This browses the filesystem <strong>inside</strong> the Docker container, not your host computer. Ensure volumes are mapped correctly.</p>
</div>
<div style="padding:16px; border-top:1px solid var(--border); display:flex; gap:12px; align-items:center;">
<span class="note" id="global-dir-browser-path" style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:monospace;">/</span>
<button class="btn-secondary" type="button" onclick="document.getElementById('global-dir-browser-modal').hidden = true;">Cancel</button>
<button class="btn-primary" type="button" id="global-dir-browser-select">Select Folder</button>
</div>
</div>
</div>
<!-- WaveSurfer — self-hosted, loaded synchronously (voice waveform requires it) -->
<script src="/static/vendor/wavesurfer.min.js"></script>
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.8.0-2"></script>
<script src="/static/loader.js?v=1.10.1"></script>
</body>
</html>

View File

@ -57,10 +57,11 @@
return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
}
async function probeUrl(rawUrl, type = '') {
async function probeUrl(rawUrl, type = '', apiKey = '') {
const url = normalizeProbeUrl(rawUrl);
const params = { url };
if (type) params.type = type;
if (apiKey) params.api_key = apiKey;
const r = await fetch('/api/probe-url?' + new URLSearchParams(params));
return r.json();
}
@ -219,14 +220,31 @@
statusEl.className = 'llm-active-status' + (cls ? ' ' + cls : '');
}
async function fetchModels(url) {
async function fetchModels(url, apiKey) {
url = (url || urlInp.value.trim());
if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; }
const want = (_appSettings && _appSettings.llm_model) || '';
setStatus('Loading models…');
if (refreshBtn) refreshBtn.disabled = true;
try {
const d = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url)).then(r => r.json());
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
const d = await fetch(fetchUrl).then(r => r.json());
if (d.error) {
let msg = 'Failed';
try {
const j = JSON.parse(d.error);
msg = j.error?.message || j.detail || d.error;
} catch(e) { msg = d.error; }
// truncate long error messages
if (msg.length > 50) msg = msg.substring(0, 47) + '...';
modelSel.innerHTML = '<option value="">Auth / Error: ' + escHtml(msg) + '</option>';
setStatus(msg, 'err');
return;
}
const models = d.models || [];
modelSel.innerHTML = models.length
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
@ -255,6 +273,40 @@
// Also react when user picks a datalist suggestion (fires 'change')
urlInp.addEventListener('change', commitUrl);
// Helper to refresh the dropdown options based on visible cards
function refreshEndpointOptions(currentUrl) {
if (!urlInp || urlInp.tagName !== 'SELECT') return;
let html = '<option value="">— Select an endpoint —</option>';
const added = new Set();
document.querySelectorAll('.llm-local-card, .llm-card').forEach(card => {
const isLlm = card.closest('#dc-grid-llm, #llm-cloud-grid, .llm-service-grid');
if (!isLlm) return;
const uInp = card.querySelector('.dc-url-inp');
if (uInp) {
const u = uInp.value.trim() || uInp.placeholder;
const name = card.querySelector('.llm-local-name')?.textContent.trim() || 'Local Engine';
if (u && !added.has(u)) { added.add(u); html += `<option value="${escHtml(u)}">${escHtml(name)}</option>`; }
}
const epCode = card.querySelector('.llm-endpoint code');
if (epCode) {
const u = epCode.textContent.trim();
const name = card.querySelector('.llm-card-name')?.textContent.trim() || 'Cloud API';
if (u && !added.has(u)) { added.add(u); html += `<option value="${escHtml(u)}">${escHtml(name)}</option>`; }
}
});
// If the current URL isn't in the options, add it as a custom entry
const activeUrl = currentUrl || urlInp.value;
if (activeUrl && !added.has(activeUrl)) {
html += `<option value="${escHtml(activeUrl)}">Custom: ${escHtml(activeUrl)}</option>`;
}
urlInp.innerHTML = html;
if (activeUrl) urlInp.value = activeUrl;
}
// Populate the dropdown when the user opens it
urlInp.addEventListener('focus', () => refreshEndpointOptions());
modelSel.addEventListener('change', () => {
applyAndSaveSettings({ llm_model: modelSel.value });
if (_appSettings) _appSettings.llm_model = modelSel.value;
@ -264,9 +316,10 @@
refreshBtn?.addEventListener('click', () => fetchModels());
// Exposed so the "Use as LLM" card buttons can sync this panel
window.syncActiveLlmPanel = function (url) {
window.syncActiveLlmPanel = function (url, apiKey) {
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
urlInp.value = url || '';
fetchModels(url);
fetchModels(url, apiKey);
};
// Initial state — restore saved endpoint + fetch its models
@ -280,8 +333,13 @@
else { _appSettings.llm_url = s.llm_url || _appSettings.llm_url; _appSettings.llm_model = s.llm_model || _appSettings.llm_model; }
} catch (_) {}
}
urlInp.value = url || '';
if (url) fetchModels(url);
// wait a tick for docker cards to render so options are available
setTimeout(() => {
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
urlInp.value = url || '';
if (url) fetchModels(url);
}, 100);
})();
})();

File diff suppressed because it is too large Load Diff

View File

@ -2,16 +2,23 @@
//
// 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).
// source (page + short quote + category hint). Shared by both sections via one overlay.
const CS_CHUNK_CHARS = 4000;
const _cs = { running: false, cancel: false, cache: {} };
function csLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
// app's loaded settings. Crucially, NEVER fall back to the hardcoded
// localhost:11434 default — if settings aren't loaded yet, return '' so the
// SERVER uses its own configured llm_url instead of a dead localhost address
// (which caused "Connection refused" character-sheet failures).
function csLlmUrl() {
const explicit = $('reh-llm-url')?.value.trim();
if (explicit) return explicit;
const fromSettings = (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url) ? _appSettings.llm_url : '';
if (fromSettings && !/localhost:11434|127\.0\.0\.1:11434/.test(fromSettings)) return fromSettings;
return '';
}
function csLlmModel() { return $('reh-llm-model')?.value || ''; }
function csLang() { return $('reh-design-lang')?.value || ''; }
@ -44,29 +51,41 @@ function csRehearserText() {
// ── 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.
const CS_SCALAR_FIELDS = ['aliases', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
'attribute_high', 'attribute_low', 'skills', 'capabilities',
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
'secret', 'conflict_style', 'win_condition'];
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());
const missing = CS_SCALAR_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; }
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]; });
CS_SCALAR_FIELDS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
if (s.tier === 'main') e.tier = 'main';
if (s.moral_alignment_score != null) {
e.moral_alignment_score = e.moral_alignment_score != null
? Math.round((e.moral_alignment_score + s.moral_alignment_score) / 2)
: s.moral_alignment_score;
}
if (s.arc_direction && s.arc_direction !== 'neutral') e.arc_direction = s.arc_direction;
(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); });
(s.sources || []).forEach(src => {
if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
});
}
}
@ -125,7 +144,95 @@ function csProgress(total) {
};
}
// ── Rendering ────────────────────────────────────────────────────────────────
// ── Alignment bar ─────────────────────────────────────────────────────────────
function csAlignmentBar(score, arcDirection, arcNote) {
const pct = Math.max(0, Math.min(100, score ?? 50));
const arcMap = {
'stable-good': { arrow: '→', label: 'Stable Good', color: '#c8e6c9' },
'stable-bad': { arrow: '→', label: 'Stable Evil', color: '#777' },
'neutral': { arrow: '→', label: 'Neutral', color: '#aaa' },
'good-to-bad': { arrow: '↘', label: 'Descends', color: '#ff7043' },
'bad-to-good': { arrow: '↗', label: 'Redeems', color: '#66bb6a' },
'complex': { arrow: '↕', label: 'Complex arc', color: '#ab47bc' },
};
const arc = arcMap[arcDirection] || arcMap['neutral'];
const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Neutral/Ambiguous';
return `<div class="cs-alignment-wrap" title="${escHtml(arcNote || '')}">
<div class="cs-alignment-labels"><span>◼ Evil</span><span>Good ◻</span></div>
<div class="cs-alignment-bar">
<div class="cs-alignment-track"></div>
<div class="cs-alignment-marker" style="left:${pct}%" title="${pct}/100 — ${label}"></div>
</div>
<div class="cs-arc-row">
<span class="cs-arc-arrow" style="color:${arc.color}" title="${escHtml(arc.label)}">${arc.arrow}</span>
<span class="cs-arc-label" style="color:${arc.color}">${escHtml(arc.label)}</span>
${arcNote ? `<span class="cs-arc-note">${escHtml(arcNote)}</span>` : ''}
</div>
</div>`;
}
// ── Deep analysis modal ───────────────────────────────────────────────────────
async function csDeepAnalysis(sheet, sourceText) {
const llm_url = csLlmUrl(), model = csLlmModel(), language = csLang();
const modal = document.createElement('div');
modal.className = 'audiobook-overlay';
modal.innerHTML = `<div class="audiobook-box cs-deep-box">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-brain"></span> Deep Analysis — ${escHtml(sheet.name)}</span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cs-deep-close">Close</button>
</div>
<div class="cs-deep-body" id="cs-deep-body">
<div style="text-align:center;padding:40px;color:var(--subtext)">
<span class="mdi mdi-loading mdi-spin" style="font-size:28px"></span><br>Running deep psychological analysis…
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelector('#cs-deep-close').addEventListener('click', () => modal.remove());
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
try {
const summary = [sheet.archetype, sheet.alignment, sheet.secret, sheet.win_condition, sheet.conflict_style]
.filter(Boolean).join('; ');
const r = await fetch('/api/character-deep-analysis', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: sheet.name, role: sheet.tier, goal: sheet.win_condition,
summary, text_excerpt: (sourceText || '').slice(0, 8000), language, llm_url, model,
}),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const data = await r.json();
const a = data.analysis || {};
const section = (icon, title, content) => content
? `<div class="cs-deep-section">
<div class="cs-deep-section-head"><span class="mdi ${icon}"></span> ${title}</div>
<div class="cs-deep-section-body">${escHtml(String(content))}</div>
</div>` : '';
modal.querySelector('#cs-deep-body').innerHTML = `
<div class="cs-deep-header">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<span class="cs-name" style="font-size:20px">${escHtml(sheet.name)}</span>
${sheet.archetype ? `<span class="cs-archetype">${escHtml(sheet.archetype)}</span>` : ''}
</div>
${csAlignmentBar(sheet.moral_alignment_score, sheet.arc_direction, sheet.arc_note)}
</div>
${section('mdi-heart-broken-outline', '1 — Core Flaw & Desire', a.core_flaw)}
${section('mdi-run-fast', '2 — Agency & Passivity', a.agency)}
${section('mdi-comment-quote-outline', '3 — Dialogue & Voice', a.dialogue_voice)}
${section('mdi-timeline-outline', '4 — Narrative Arc', a.narrative_arc)}
${section('mdi-infinity', '5 — Paradox & Depth', a.paradox)}
`;
} catch (err) {
modal.querySelector('#cs-deep-body').innerHTML =
`<div style="color:var(--error);padding:20px">Error: ${escHtml(String(err.message))}</div>`;
}
}
// ── Card renderer ─────────────────────────────────────────────────────────────
function csField(label, value) {
if (!value) return '';
@ -134,25 +241,50 @@ function csField(label, value) {
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 invHtml = inv.length
? `<div class="cs-row"><dt>Signature Items</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>` : '';
? `<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-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${
sources.map(x =>
`${x.line_hint ? `<span class="cs-src-hint">[${escHtml(x.line_hint)}]</span> ` : ''}` +
`${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'}" data-name="${escHtml(s.name)}">
<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>
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}"
title="5-area psychological deep analysis"
style="margin-left:auto;font-size:11px;padding:2px 8px;">
<span class="mdi mdi-brain"></span> Deep Analysis
</button>
</div>
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
<dl class="cs-fields">
${csField('Physical', s.physical)}
${csField('Alignment &amp; Ethos', s.alignment)}
${csField('Clothing & Appearance', s.clothing)}
${csField('Alignment & Ethos', s.alignment)}
${attrs}
${csField('Trained Skills', s.skills)}
${csField('Capabilities', s.capabilities)}
${invHtml}
${csField('Backstory & Origin', s.backstory)}
${csField('Relationships', s.relationships)}
${csField('Motivation', s.motivation)}
${csField('Fears', s.fears)}
${csField('Mannerisms & Habits', s.mannerisms)}
${s.voice_pattern ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice &amp; Speech</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
${csField('Dark Secret / Fatal Flaw', s.secret)}
${csField('Conflict Style', s.conflict_style)}
${csField('Win Condition', s.win_condition)}
@ -171,28 +303,44 @@ function csToMarkdown(sheets) {
for (const s of list) {
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
if (s.aliases) md += `*aka ${s.aliases}*\n`;
const mas = s.moral_alignment_score ?? 50;
const masLabel = mas >= 70 ? 'Good' : mas <= 30 ? 'Evil' : 'Neutral';
md += `- **Moral alignment:** ${mas}/100 (${masLabel}) — Arc: ${s.arc_direction || 'neutral'}\n`;
if (s.arc_note) md += ` *${s.arc_note}*\n`;
const f = (l, v) => v ? `- **${l}:** ${v}\n` : '';
md += f('Physical', s.physical) + f('Alignment & Ethos', s.alignment)
md += f('Physical', s.physical) + f('Clothing', s.clothing)
+ 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('; ');
+ f('Trained Skills', s.skills) + f('Capabilities', s.capabilities)
+ f('Backstory & Origin', s.backstory) + f('Relationships', s.relationships)
+ f('Motivation', s.motivation) + f('Fears', s.fears)
+ f('Mannerisms & Habits', s.mannerisms) + f('Voice & Speech', s.voice_pattern)
+ f('Signature Items', (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.line_hint ? '[' + x.line_hint + '] ' : ''}${x.page != null ? 'p.' + x.page + ' ' : ''}"${x.quote}"`).join('; ');
if (src) md += `- *Sources:* ${src}\n`;
}
}
return md.trim();
}
function csShow(sheets, title) {
function csShow(sheets, title, sourceText) {
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('') : '';
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 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>
@ -202,30 +350,56 @@ function csShow(sheets, title) {
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'));
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(); });
// Wire Deep Analysis buttons
ov.querySelectorAll('.cs-deep-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const name = btn.dataset.name;
const sheet = sheets.find(s => s.name === name);
if (sheet) csDeepAnalysis(sheet, sourceText);
});
});
}
// ── Entry points (reader + rehearser) ────────────────────────────────────────
// Persist a fresh batch of sheets into the Character Library (auto-save).
async function csSaveToLibrary(book, sheets) {
if (typeof clUpsertMany !== 'function') return;
try {
const n = await clUpsertMany(book, sheets);
if (n) toast(`${n} character${n !== 1 ? 's' : ''} saved to library`, 'success');
} catch (e) { /* non-fatal — the overlay still works */ }
}
async function csForReader() {
const text = csReaderText();
const book = readerState.title || 'Untitled book';
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 (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets', text); return; }
const sheets = await csGenerate(text, key);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
csShow(sheets, readerState.title || 'Character sheets');
csSaveToLibrary(book, sheets);
csShow(sheets, readerState.title || 'Character sheets', text);
}
async function csForRehearser() {
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
const book = $('reh-script-title')?.value.trim() || 'Untitled script';
const text = csRehearserText();
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
if (_cs.cache[key]) { csShow(_cs.cache[key], title); return; }
const sheets = await csGenerate(csRehearserText(), key);
if (_cs.cache[key]) { csShow(_cs.cache[key], title, text); return; }
const sheets = await csGenerate(text, key);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
csShow(sheets, title);
csSaveToLibrary(book, sheets);
csShow(sheets, title, text);
}
$('reader-charsheets-btn')?.addEventListener('click', csForReader);

View File

@ -0,0 +1,334 @@
// ── Character library ─────────────────────────────────────────────────────────
//
// Persistent, book-scoped character sheets. Running "Character sheets" in Read
// Aloud or the Rehearser upserts each extracted character into this library,
// keyed by (book + name). Cards reuse the renderers from character-sheets.js
// (csCardHtml, csAlignmentBar, csDeepAnalysis), which loads before this module.
const CL_DB_NAME = 'character-library';
const CL_STORE = 'characters';
// Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics.
const CL_EDIT_FIELDS = [
['name', 'Name'], ['aliases', 'Aliases'], ['archetype', 'Archetype'],
['physical', 'Physical'], ['clothing', 'Clothing & Appearance'],
['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'],
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
['voice_pattern', 'Voice & Speech'], ['secret', 'Dark Secret / Fatal Flaw'],
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
];
// ── IndexedDB ─────────────────────────────────────────────────────────────────
function clDbOpen() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(CL_DB_NAME, 1);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(CL_STORE)) {
const store = db.createObjectStore(CL_STORE, { keyPath: 'id' });
store.createIndex('book', 'book', { unique: false });
}
};
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function clDbOp(mode, fn) {
const db = await clDbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(CL_STORE, mode);
const req = fn(tx.objectStore(CL_STORE));
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function clGetAll() {
const db = await clDbOpen();
return new Promise((resolve, reject) => {
const req = db.transaction(CL_STORE, 'readonly').objectStore(CL_STORE).getAll();
req.onsuccess = e => resolve(e.target.result || []);
req.onerror = e => reject(e.target.error);
});
}
async function clGet(id) { return clDbOp('readonly', s => s.get(id)); }
async function clPut(rec) { return clDbOp('readwrite', s => s.put(rec)); }
async function clDelete(id) { return clDbOp('readwrite', s => s.delete(id)); }
function clKey(book, name) {
return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase();
}
// Merge an incoming sheet into an existing one without blanking solid facts —
// same rule as csMerge in character-sheets.js (longer value wins; sources append;
// alignment score averages).
function clMergeSheet(existing, incoming) {
const e = { ...existing };
const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
scalars.forEach(f => { if ((incoming[f] || '').length > (e[f] || '').length) e[f] = incoming[f]; });
if (incoming.tier === 'main') e.tier = 'main';
if (incoming.moral_alignment_score != null) {
e.moral_alignment_score = e.moral_alignment_score != null
? Math.round((e.moral_alignment_score + incoming.moral_alignment_score) / 2)
: incoming.moral_alignment_score;
}
if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction;
e.inventory = [...(existing.inventory || [])];
(incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
e.sources = [...(existing.sources || [])];
(incoming.sources || []).forEach(src => {
if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
});
return e;
}
// Upsert one sheet into the library under a book. Returns the stored record.
async function clUpsert(book, sheet) {
const name = (sheet.name || '').trim();
if (!name) return null;
const bk = (book || '').trim() || 'Unsorted';
const id = clKey(bk, name);
const now = new Date();
const prev = await clGet(id).catch(() => null);
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sheet, name };
// Tags (comma-separated, like voices) — the productions this character belongs
// to. Seeded with its origin book; accumulates without ever blanking so a
// character can be reused across several books/scripts.
const tags = clMergeTags(prev?.tags, sheet.tags, bk);
const rec = {
id, book: bk, name, tags,
sheet: merged,
analysis: prev?.analysis || null,
voice: prev?.voice || sheet.voice || null,
created: prev?.created || now,
updated: now,
};
await clPut(rec);
return rec;
}
// Union a character's tag strings into one clean comma-separated list.
function clMergeTags(...parts) {
const set = new Set();
parts.forEach(p => String(p || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t)));
return [...set].join(', ');
}
// All characters belonging to a production, by origin book OR by tag membership.
async function clGetAllByTagOrBook(title) {
const key = String(title || '').trim().toLowerCase();
if (!key) return [];
const all = await clGetAll().catch(() => []);
return all.filter(r => {
if (String(r.book || '').trim().toLowerCase() === key) return true;
return String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === key);
});
}
// Bulk upsert a list of sheets for one book (used by the analysis side-effect).
async function clUpsertMany(book, sheets) {
let n = 0;
for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; }
return n;
}
// ── Rendering ─────────────────────────────────────────────────────────────────
let _clRecords = [];
async function clRender() {
const grid = document.getElementById('cl-grid');
if (!grid) return;
// Bind filter/search once the section fragment exists (it loads after this JS).
const filterEl = document.getElementById('cl-book-filter');
const searchEl = document.getElementById('cl-search');
if (filterEl && !filterEl.dataset.bound) { filterEl.dataset.bound = '1'; filterEl.addEventListener('change', clApplyFilter); }
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
// Populate the production filter with the union of origin books and all tags
// (preserve current selection if still present).
const filter = document.getElementById('cl-book-filter');
const prods = clAllProductions();
if (filter) {
const cur = filter.value;
filter.innerHTML = `<option value="">All productions (${prods.length})</option>` +
prods.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).join('');
if (cur && prods.includes(cur)) filter.value = cur;
}
clApplyFilter();
}
// Distinct production names across origin books and character tags.
function clAllProductions() {
const set = new Set();
_clRecords.forEach(r => {
if (r.book) set.add(r.book);
String(r.tags || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t));
});
return [...set].sort((a, b) => a.localeCompare(b));
}
function clApplyFilter() {
const grid = document.getElementById('cl-grid');
if (!grid) return;
const book = document.getElementById('cl-book-filter')?.value || '';
const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase();
let recs = _clRecords.slice();
if (book) {
const bk = book.toLowerCase();
recs = recs.filter(r =>
(r.book || '').toLowerCase() === bk ||
String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === bk));
}
if (q) recs = recs.filter(r =>
(r.name || '').toLowerCase().includes(q) ||
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
(r.tags || '').toLowerCase().includes(q) ||
(r.book || '').toLowerCase().includes(q));
if (!recs.length) {
grid.innerHTML = `<div class="cl-empty">
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
<p>${_clRecords.length ? 'No characters match your filter.' : 'No characters yet.'}</p>
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
</div>`;
return;
}
// Group by book.
const byBook = new Map();
for (const r of recs) { if (!byBook.has(r.book)) byBook.set(r.book, []); byBook.get(r.book).push(r); }
const groups = [...byBook.keys()].sort((a, b) => a.localeCompare(b));
grid.innerHTML = groups.map(bk => {
const list = byBook.get(bk).sort((a, b) => {
const t = (a.sheet?.tier === 'main' ? 0 : 1) - (b.sheet?.tier === 'main' ? 0 : 1);
return t || (a.name || '').localeCompare(b.name || '');
});
return `<div class="cl-book-group">
<div class="cl-book-head"><span class="mdi mdi-book-open-page-variant-outline"></span> ${escHtml(bk)}
<span class="cl-book-count">${list.length}</span></div>
<div class="cl-book-cards">${list.map(clCardHtml).join('')}</div>
</div>`;
}).join('');
}
function clCardHtml(rec) {
const tags = String(rec.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const chips = tags.length
? `<div class="cl-card-tags">${tags.map(t => `<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>${escHtml(t)}</span>`).join('')}</div>`
: '';
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
<div class="cl-card-tools">
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
</div>
${csCardHtml(rec.sheet)}
${chips}
</div>`;
}
// ── Edit modal ──────────────────────────────────────────────────────────────
function clEdit(id) {
const rec = _clRecords.find(r => r.id === id);
if (!rec) return;
const s = rec.sheet || {};
const modal = document.createElement('div');
modal.className = 'audiobook-overlay';
const rows = CL_EDIT_FIELDS.map(([k, label]) => {
const multiline = !['name', 'aliases', 'archetype'].includes(k);
const val = escHtml(String(s[k] || ''));
return `<label class="cl-edit-row"><span>${label}</span>${
multiline
? `<textarea data-field="${k}" rows="2">${val}</textarea>`
: `<input type="text" data-field="${k}" value="${val}">`
}</label>`;
}).join('');
modal.innerHTML = `<div class="audiobook-box cl-edit-box">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-pencil-outline"></span> Edit — ${escHtml(rec.name)}</span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cl-edit-cancel">Cancel</button>
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
</div>
<div class="cl-edit-body">
<label class="cl-edit-row"><span>Tags / productions (comma-separated — reuse across books &amp; scripts)</span>
<input type="text" data-rec-field="tags" value="${escHtml(String(rec.tags || rec.book || ''))}" placeholder="e.g. Das Jahr des Greifen, Star-Trek"></label>
<label class="cl-edit-row"><span>Moral alignment (0 evil – 100 good)</span>
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
<label class="cl-edit-row"><span>Arc direction</span>
<select data-field="arc_direction">${
['stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex']
.map(o => `<option value="${o}"${(s.arc_direction || 'neutral') === o ? ' selected' : ''}>${o}</option>`).join('')
}</select></label>
${rows}
</div>
</div>`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.querySelector('#cl-edit-cancel').addEventListener('click', close);
modal.addEventListener('click', e => { if (e.target === modal) close(); });
modal.querySelector('#cl-edit-save').addEventListener('click', async () => {
const patch = {};
modal.querySelectorAll('[data-field]').forEach(el => { patch[el.dataset.field] = el.value; });
const newName = (patch.name || '').trim() || rec.name;
let mas = parseInt(patch.moral_alignment_score, 10);
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas };
// Record-level fields (tags) live outside the sheet.
const tagsEl = modal.querySelector('[data-rec-field="tags"]');
const newTags = tagsEl ? clMergeTags(tagsEl.value, rec.book) : (rec.tags || rec.book);
// If the name changed, the key changes too — delete old, write new.
const newId = clKey(rec.book, newName);
rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.updated = new Date();
try {
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
await clPut(rec);
toast('Character saved', 'success');
close();
clRender();
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
});
}
async function clDeleteRec(id) {
const rec = _clRecords.find(r => r.id === id);
if (!rec) return;
if (!confirm(`Delete "${rec.name}" from ${rec.book}?`)) return;
try { await clDelete(id); toast('Deleted', 'success'); clRender(); }
catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
}
// ── Wiring ──────────────────────────────────────────────────────────────────
// Document-level delegation — the #cl-grid is (re)built on every render and the
// section fragment loads after this script, so we can't bind to it directly.
document.addEventListener('click', e => {
const edit = e.target.closest?.('.cl-edit');
if (edit) { e.stopPropagation(); clEdit(edit.dataset.id); return; }
const del = e.target.closest?.('.cl-delete');
if (del) { e.stopPropagation(); clDeleteRec(del.dataset.id); return; }
const deep = e.target.closest?.('.cl-card-wrap .cs-deep-btn');
if (deep) {
e.stopPropagation();
const rec = _clRecords.find(r => r.id === deep.closest('.cl-card-wrap')?.dataset.id);
if (rec && typeof csDeepAnalysis === 'function') csDeepAnalysis(rec.sheet, '');
}
});
window.clRender = clRender;
window.clUpsertMany = clUpsertMany;
window.clUpsert = clUpsert;
window.clGetAll = clGetAll;
window.clPut = clPut;
window.clGetAllByTagOrBook = clGetAllByTagOrBook;

View File

@ -808,6 +808,59 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
}
// ── Send turn via SSE ─────────────────────────────────────────────────────
// Decode a recorded blob (webm/opus, ogg, …) in the browser and re-encode it
// as a 16 kHz mono WAV. This sidesteps server-side ffmpeg, whose webm/EBML
// parser fails on some platforms (ARM64): "EBML header parsing failed".
// Returns a WAV Blob, or null if decoding isn't possible (caller falls back).
async function blobToWav16k(blob) {
try {
if (!blob || !blob.size) return null;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
const buf = await blob.arrayBuffer();
const ctx = new AC();
let audio;
try { audio = await ctx.decodeAudioData(buf.slice(0)); }
finally { try { ctx.close(); } catch (_) {} }
if (!audio || !audio.length) return null;
const targetRate = 16000;
const inRate = audio.sampleRate;
const chs = audio.numberOfChannels;
// Mix down to mono
const mono = new Float32Array(audio.length);
for (let c = 0; c < chs; c++) {
const d = audio.getChannelData(c);
for (let i = 0; i < d.length; i++) mono[i] += d[i] / chs;
}
// Linear resample to 16 kHz
const outLen = Math.max(1, Math.round(mono.length * targetRate / inRate));
const out = new Float32Array(outLen);
const ratio = mono.length / outLen;
for (let i = 0; i < outLen; i++) {
const pos = i * ratio, i0 = Math.floor(pos), i1 = Math.min(i0 + 1, mono.length - 1);
const f = pos - i0;
out[i] = mono[i0] * (1 - f) + mono[i1] * f;
}
// Encode 16-bit PCM WAV
const bytes = 44 + out.length * 2;
const ab = new ArrayBuffer(bytes);
const view = new DataView(ab);
const ws = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
ws(0, 'RIFF'); view.setUint32(4, bytes - 8, true); ws(8, 'WAVE');
ws(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
view.setUint16(22, 1, true); view.setUint32(24, targetRate, true);
view.setUint32(28, targetRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
ws(36, 'data'); view.setUint32(40, out.length * 2, true);
let off = 44;
for (let i = 0; i < out.length; i++) {
let s = Math.max(-1, Math.min(1, out[i]));
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); off += 2;
}
return new Blob([ab], { type: 'audio/wav' });
} catch (_) { return null; }
}
async function processBlob(blob) {
autoMicGeneration++; // cancel any pending auto-mic from previous turn
clearAudio();
@ -832,8 +885,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let assistantText = '';
let lastStats = null;
// Convert to WAV in-browser so the server never has to ffmpeg-decode webm.
const wav = await blobToWav16k(blob);
const upBlob = wav || blob;
const upName = wav ? 'audio.wav' : 'audio.webm';
const form = new FormData();
form.append('audio', blob, 'audio.webm');
form.append('audio', upBlob, upName);
form.append('stt_backend', sttSel?.value || 'configured');
form.append('llm_url', llmUrlInp?.value.trim() || '');
form.append('llm_model', llmModelSel?.value || '');

View File

@ -201,10 +201,10 @@ function saveCustomEngineCards(cards) {
// Settings key per docker container name; role fallback for custom cards
const DC_USE_MAP = {
'faster-qwen3-tts-voiceclone': { settingKey: 'tts_url', label: 'Use as TTS' },
'faster-qwen3-tts-voicedesign': { settingKey: 'voice_design_url', label: 'Use as Voice Design' },
'faster-qwen3-tts-customvoice': { settingKey: 'customvoice_url', label: 'Use as CustomVoice' },
'faster-qwen3-tts-streaming': { settingKey: 'tts_stream_url', label: 'Use as Streaming TTS' },
'tts-faster-qwen3-tts-voiceclone': { settingKey: 'tts_url', label: 'Use as TTS' },
'tts-faster-qwen3-tts-voicedesign': { settingKey: 'voice_design_url', label: 'Use as Voice Design' },
'tts-faster-qwen3-tts-customvoice': { settingKey: 'customvoice_url', label: 'Use as CustomVoice' },
'tts-faster-qwen3-tts-streaming': { settingKey: 'tts_stream_url', label: 'Use as Streaming TTS' },
'faster-whisper': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
'fish-speech-api': { settingKey: 'fishspeech_url', label: 'Use as Fish-Speech' },
'nemotron-asr-streaming': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
@ -212,9 +212,12 @@ const DC_USE_MAP = {
'parakeet-stt-multi-9011': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
'magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' },
'nvidia-magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' },
'nvidia-parakeet-asr': { settingKey: 'nvidia_router_url', label: 'Use as STT Router' },
'nvidia-parakeet-asr': { settingKey: 'nvidia_router_url', label: 'Use as router' },
'parakeet-rnnt-nim': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
'nvidia-parakeet-rnnt-nim': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
'kokoro-tts': { settingKey: 'kokoro_url', label: 'Use as Kokoro TTS' },
'nvidia-zeroshot-nim': { settingKey: 'nvidia_zeroshot_url', label: 'Use as Zeroshot' },
'nvidia-flow-nim': { settingKey: 'nvidia_flow_url', label: 'Use as Flow NIM' },
'vibevoice-msft-tts': { settingKey: 'vibevoice_url', label: 'Use as VibeVoice' },
'xtts-dgx-spark': { settingKey: 'xtts_url', label: 'Use as XTTS' },
'whisperx-blackwell': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
@ -311,7 +314,7 @@ async function loadLocalContainers() {
const loading = '<div class="dc-loading">Checking container status&hellip;</div>';
if (gridTts) gridTts.innerHTML = loading;
if (gridStt) gridStt.innerHTML = loading;
if (gridLlm) gridLlm.innerHTML = '';
if (gridLlm) gridLlm.innerHTML = loading;
const custom = loadCustomEngineCards();
try {
const r = await fetch('/api/local-containers');
@ -333,10 +336,10 @@ function renderLocalContainers(containers) {
const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM' };
const DC_ICONS = {
'faster-qwen3-tts-voiceclone': '<span class="mdi mdi-speaker-outline"></span>',
'faster-qwen3-tts-voicedesign': '<span class="mdi mdi-auto-fix"></span>',
'faster-qwen3-tts-customvoice': '🎭',
'faster-qwen3-tts-streaming': '<span class="mdi mdi-lightning-bolt"></span>',
'tts-faster-qwen3-tts-voiceclone': '<span class="mdi mdi-speaker-outline"></span>',
'tts-faster-qwen3-tts-voicedesign': '<span class="mdi mdi-auto-fix"></span>',
'tts-faster-qwen3-tts-customvoice': '🎭',
'tts-faster-qwen3-tts-streaming': '<span class="mdi mdi-lightning-bolt"></span>',
'parakeet-asr': '🦜',
'magpie-tts': '🐦',
'parakeet-rnnt-nim': '🦜',
@ -345,6 +348,10 @@ function renderLocalContainers(containers) {
'nvidia-magpie-tts': '<span class="mdi mdi-speaker-wireless"></span>',
'nvidia-parakeet-asr': '<span class="mdi mdi-microphone-variant"></span>',
'nvidia-parakeet-rnnt-nim': '<span class="mdi mdi-microphone-variant"></span>',
'kokoro-tts': '<span class="mdi mdi-music-note"></span>',
'nvidia-zeroshot-nim': '<span class="mdi mdi-star-circle-outline"></span>',
'nvidia-flow-nim': '<span class="mdi mdi-weather-windy"></span>',
'nemotron-asr-streaming': '<span class="mdi mdi-waveform"></span>',
'vibevoice-msft-tts': '<span class="mdi mdi-account-voice"></span>',
'xtts-dgx-spark': '<span class="mdi mdi-account-music-outline"></span>',
'litellm': '<span class="mdi mdi-router-network"></span>',
@ -357,10 +364,10 @@ function renderLocalContainers(containers) {
const _q = (v) => `<span class="mdi mdi-star-outline"></span>`;
const _m = (v) => `<span class="mdi mdi-memory"></span>`;
const DC_METRICS = {
'faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium clone'],[_m(),'6–8 GB VRAM']],
'faster-qwen3-tts-voicedesign': [[_t(),'~0.4× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'faster-qwen3-tts-customvoice': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'faster-qwen3-tts-streaming': [[_t(),'~0.1× GPU'],[_l(),'0.5–1 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'tts-faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium clone'],[_m(),'6–8 GB VRAM']],
'tts-faster-qwen3-tts-voicedesign': [[_t(),'~0.4× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'tts-faster-qwen3-tts-customvoice': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'tts-faster-qwen3-tts-streaming': [[_t(),'~0.1× GPU'],[_l(),'0.5–1 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'magpie-tts': [[_t(),'~0.05× GPU'],[_l(),'0.3–0.8 s'],[_q(),'High'], [_m(),'4–6 GB VRAM']],
'parakeet-asr': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-TDT'],[_m(),'2 GB VRAM']],
'parakeet-rnnt-nim': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-1B'], [_m(),'2 GB VRAM']],
@ -396,7 +403,7 @@ function renderLocalContainers(containers) {
// URL row — input only, no inline Connect button
const defaultUrl = isCustom
? (c.url || '')
: (c.port ? `http://host.docker.internal:${c.port}` : '');
: (c.port ? `http://host.docker.internal:${c.port}${c.role === 'llm' ? '/v1' : ''}` : '');
const urlRowHtml = `
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
@ -420,6 +427,19 @@ function renderLocalContainers(containers) {
data-cn-key="${n}">
</div>`;
// API key row — only for LLMs
const savedApiKey = isCustom
? (c.apiKey || '')
: ((_appSettings?.engine_api_keys?.[c.name]) || localStorage.getItem('dc-apikey-' + n) || '');
const apiKeyRowHtml = (c.role === 'llm' || c.name === 'litellm') ? `
<div class="llm-local-url">
<span class="llm-local-url-label" title="API Key"><span class="mdi mdi-key-outline"></span></span>
<input class="llm-local-url-inp dc-apikey-inp" type="password"
placeholder="API key (optional)" spellcheck="false"
value="${escHtml(savedApiKey)}"
data-dc-apikey-key="${n}">
</div>` : '';
// Use-as button — right-aligned, clearly labelled as the activation action
const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role];
const useBtn = useEntry
@ -458,9 +478,14 @@ function renderLocalContainers(containers) {
const portNum = c.port || '';
const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`;
const snippetCode = c.role === 'tts'
? `curl -s "${snippetBaseUrl}/v1/audio/speech" \\\n -H "Authorization: Bearer dummy" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"tts-1","voice":"default","input":"Hello world","response_format":"wav"}' \\\n --output test.wav`
: `curl -s "${snippetBaseUrl}/v1/audio/transcriptions" \\\n -F "file=@audio.wav" \\\n -F "model=whisper-1"`;
let snippetCode = '';
if (c.role === 'tts') {
snippetCode = `curl -s "${snippetBaseUrl}/v1/audio/speech" \\\n -H "Authorization: Bearer dummy" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"tts-1","voice":"default","input":"Hello world","response_format":"wav"}' \\\n --output test.wav`;
} else if (c.role === 'stt') {
snippetCode = `curl -s "${snippetBaseUrl}/v1/audio/transcriptions" \\\n -F "file=@audio.wav" \\\n -F "model=whisper-1"`;
} else {
snippetCode = `curl -s "${snippetBaseUrl}/v1/chat/completions" \\\n -H "Authorization: Bearer dummy" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"default","messages":[{"role":"user","content":"Hello!"}]}'`;
}
const snippetCopy = escHtml(snippetCode);
const snippetHtml = (portNum || isCustom) ? `
<div class="llm-local-snippet">
@ -479,7 +504,7 @@ function renderLocalContainers(containers) {
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
</div>
${metricsHtml}
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${apiKeyRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
</div>`;
}
@ -571,15 +596,17 @@ function renderLocalContainers(containers) {
const key = btn.dataset.dcUrlKey;
const card = btn.closest('.llm-local-card');
const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`);
const apikeyInp = card?.querySelector(`.dc-apikey-inp[data-dc-apikey-key="${CSS.escape(key)}"]`);
const useBtn = card?.querySelector('.dc-use-btn');
const rawUrl = inp?.value.trim() || inp?.placeholder;
const apiKey = apikeyInp?.value.trim() || '';
if (!rawUrl) { toast('Enter a URL first', 'error'); return; }
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
try {
if (window.probeUrl) {
const type = card ? cardType(card) : '';
const d = await window.probeUrl(rawUrl, type);
const d = await window.probeUrl(rawUrl, type, apiKey);
if (d.ok) {
setConnected(btn, card, useBtn, true);
localStorage.setItem('dc-con-' + key, '1');
@ -588,7 +615,9 @@ function renderLocalContainers(containers) {
const settingKey = useBtn.dataset.dcUseKey;
const url = inp?.value.trim() || inp?.placeholder || '';
if (settingKey && url) {
await window.applyAndSaveSettings({ [settingKey]: url });
const patch = { [settingKey]: url };
if (apiKey && settingKey === 'llm_url') patch.llm_api_key = apiKey;
await window.applyAndSaveSettings(patch);
toast(`✓ Connected and set as active ${useBtn.textContent.replace('Use as ', '').trim()}`, 'success');
} else {
toast('✓ Service reachable', 'success');
@ -618,11 +647,17 @@ function renderLocalContainers(containers) {
btn.addEventListener('click', async () => {
const settingKey = btn.dataset.dcUseKey;
const urlKey = btn.dataset.dcName;
const inp = btn.closest('.llm-local-card')?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(urlKey)}"]`);
const card = btn.closest('.llm-local-card');
const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(urlKey)}"]`);
const apikeyInp = card?.querySelector(`.dc-apikey-inp[data-dc-apikey-key="${CSS.escape(urlKey)}"]`);
const url = inp?.value.trim() || inp?.placeholder || '';
const apiKey = apikeyInp?.value.trim() || '';
if (!url) { toast('Enter a URL first', 'error'); return; }
if (window.applyAndSaveSettings) {
await window.applyAndSaveSettings({ [settingKey]: url });
const patch = { [settingKey]: url };
if (apiKey && settingKey === 'llm_url') patch.llm_api_key = apiKey;
await window.applyAndSaveSettings(patch);
if (settingKey === 'llm_url' && window.syncActiveLlmPanel) window.syncActiveLlmPanel(url, apiKey);
btn.classList.add('active');
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
}

View File

@ -81,14 +81,14 @@ function splitTextIntoChunks(text, maxLen = 800) {
return chunks.length ? chunks : [text];
}
async function generateChunkedTts(voice, text, backend, instruct) {
async function generateChunkedTts(voice, text, backend, instruct, extra = null) {
const chunks = splitTextIntoChunks(text);
const prog = $('preview-chunk-progress');
if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}…`; }
const blobs = [];
for (let i = 0; i < chunks.length; i++) {
if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}…`;
blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend));
blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend, false, extra));
}
if (prog) prog.textContent = 'Merging…';
const merged = await mergeWavBlobs(blobs);

299
static/js/library.js Normal file
View File

@ -0,0 +1,299 @@
// ── Combined Library — Books · Theater Plays · Characters/Cast ────────────────
//
// One home for every production. Books live server-side (reader_library),
// plays live client-side (IndexedDB reh-library), characters live client-side
// (IndexedDB character-library). They are joined by their normalized title —
// see prodKey() — so a book and a play with the same title are one production
// and share the character cast. This module owns the s-library section and
// provides the cross-links that open a production in Read Aloud or the Rehearser.
const LIB_READER_API = '/api/reader/docs';
// The universal join key across books, plays and characters (mirrors clKey).
function prodKey(title) { return String(title || '').trim().toLowerCase(); }
window._libraryView = (function () {
try { return localStorage.getItem('ttsvc_library_view') || 'books'; } catch (_) { return 'books'; }
})();
window.navLibraryView = function (view) {
if (typeof navTo === 'function') navTo('s-library');
window._libraryView = view;
try { localStorage.setItem('ttsvc_library_view', view); } catch (_) {}
document.querySelectorAll('[data-library-view]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.libraryView === view);
});
document.querySelectorAll('[data-library-panel]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.libraryPanel === view);
});
libraryRender(view);
};
window.libraryRender = function (view) {
view = view || window._libraryView || 'books';
// Reflect the active tab/panel even when entered via the section hook
document.querySelectorAll('[data-library-view]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.libraryView === view);
});
document.querySelectorAll('[data-library-panel]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.libraryPanel === view);
});
if (view === 'books') libraryRenderBooks();
else if (view === 'plays') libraryRenderPlays();
else if (view === 'characters' && typeof clRender === 'function') clRender();
};
// ── Books (server-side reader library) ────────────────────────────────────────
async function libraryRenderBooks() {
const list = document.getElementById('lib-books-list');
if (!list) return;
let all = [];
try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
if (!all.length) {
list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No books yet.</p>'
+ '<p class="lib-empty-hint">Open <b>Read Aloud</b>, import a PDF or text, and save it to your library.</p></div>';
return;
}
all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); });
list.innerHTML = all.map(function (rec) {
const total = rec.sentenceCount || 0;
const synthPct = total ? Math.round(((rec.synthCount || 0) / total) * 100) : 0;
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
const cov = libBookCover(rec.title || 'Untitled');
const coverUrl = LIB_READER_API + '/' + rec.id + '/cover?t=' + new Date(rec.updated || Date.now()).getTime();
const bg = rec.hasCover
? 'style="background-image:linear-gradient(to bottom,rgba(0,0,0,.3),rgba(0,0,0,.8)),url(\'' + coverUrl + '\');background-size:cover;background-position:center;color:#fff"'
: 'style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '"';
return '<div class="reh-book lib-book" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" ' + bg + ' title="' + escHtml(rec.title || '') + '">'
+ '<div class="reh-book-actions">'
+ '<button class="reh-book-act lib-act-rehearse" title="Rehearse this title"><span class="mdi mdi-theater"></span></button>'
+ '<button class="reh-book-act lib-act-del-book" title="Delete book"><span class="mdi mdi-delete-outline"></span></button>'
+ '</div>'
+ '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</div>'
+ '<div class="reh-book-meta">'
+ total + ' sentences' + (rec.pageCount ? ' · ' + rec.pageCount + ' pg' : '')
+ '<div class="reh-book-progress" title="' + synthPct + '% audio"><div style="width:' + synthPct + '%"></div></div>'
+ '<div style="margin-top:4px;opacity:.8"><span class="mdi ' + (rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline') + '"></span> ' + readPct + '% read · ' + date + '</div>'
+ '</div></div>';
}).join('');
list.querySelectorAll('.lib-book').forEach(function (el) {
const id = el.dataset.id, title = el.dataset.title;
el.addEventListener('click', function (e) {
if (e.target.closest('.reh-book-act')) return;
if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id);
});
const reh = el.querySelector('.lib-act-rehearse');
if (reh) reh.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInRehearser(title); });
const del = el.querySelector('.lib-act-del-book');
if (del) del.addEventListener('click', function (e) {
e.stopPropagation();
libConfirmDelete(el, 'Delete book?', 'Audio files will be removed.', async function () {
try { const r = await fetch(LIB_READER_API + '/' + id, { method: 'DELETE' }); if (!r.ok) throw new Error('delete failed'); toast('Book deleted', 'success'); libraryRenderBooks(); }
catch (err) { toast(err.message || 'Delete failed', 'error'); }
});
});
});
}
// ── Theater Plays (client-side rehearser library) ─────────────────────────────
async function libraryRenderPlays() {
const list = document.getElementById('lib-plays-list');
if (!list) return;
let all = [];
try { all = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : []; } catch (_) { all = []; }
if (!all.length) {
list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-theater"></span><p>No theater plays yet.</p>'
+ '<p class="lib-empty-hint">Open <b>Script Rehearsal</b> → Import / Export to add a script, or cast a book as an audiobook.</p></div>';
return;
}
all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); });
list.innerHTML = all.map(function (rec) {
const speakers = Object.keys(rec.cast || {});
const total = (typeof parseScript === 'function') ? parseScript(rec.script || '').filter(function (l) { return l.type === 'dialog'; }).length : 0;
const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
const cov = libBookCover(rec.title || 'Untitled');
const avatars = speakers.slice(0, 5).map(function (sp) {
const c = rec.cast[sp] || {};
return '<span style="background:' + (c.color || '#888') + '">' + (sp[0] || '?').toUpperCase() + '</span>';
}).join('');
return '<div class="reh-book lib-play" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '" title="' + escHtml(rec.title || '') + '">'
+ '<div class="reh-book-actions">'
+ '<button class="reh-book-act lib-act-readaloud" title="Read aloud"><span class="mdi mdi-book-open-page-variant-outline"></span></button>'
+ '<button class="reh-book-act lib-act-del-play" title="Delete rehearsal"><span class="mdi mdi-delete-outline"></span></button>'
+ '</div>'
+ '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</div>'
+ '<div class="reh-book-meta">'
+ '<div class="reh-book-avatars">' + avatars + '</div>'
+ total + ' lines · ' + speakers.length + ' cast'
+ '<div class="reh-book-progress"><div style="width:' + pct + '%"></div></div>'
+ '<div style="margin-top:4px;opacity:.8">' + pct + '% · ' + date + '</div>'
+ '</div></div>';
}).join('');
list.querySelectorAll('.lib-play').forEach(function (el) {
const id = parseInt(el.dataset.id, 10), title = el.dataset.title;
el.addEventListener('click', function (e) {
if (e.target.closest('.reh-book-act')) return;
openPlayInRehearser(id);
});
const ra = el.querySelector('.lib-act-readaloud');
if (ra) ra.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInReader(title); });
const del = el.querySelector('.lib-act-del-play');
if (del) del.addEventListener('click', function (e) {
e.stopPropagation();
libConfirmDelete(el, 'Delete rehearsal?', 'This cannot be undone.', async function () {
try { if (typeof rehDbDelete === 'function') await rehDbDelete(id); toast('Rehearsal deleted', 'success'); libraryRenderPlays(); }
catch (err) { toast(err.message || 'Delete failed', 'error'); }
});
});
});
}
// ── Cross-links between Read Aloud and the Rehearser ──────────────────────────
async function openPlayInRehearser(id) {
try {
const db = await rehDbOpen();
const rec = await new Promise(function (res, rej) {
const r = db.transaction(REH_STORE, 'readonly').objectStore(REH_STORE).get(id);
r.onsuccess = function (e) { res(e.target.result); }; r.onerror = function (e) { rej(e.target.error); };
});
if (rec && typeof loadRecord === 'function') loadRecord(rec);
} catch (err) { toast('Could not open rehearsal', 'error'); }
}
// Open a production in the Rehearser: reuse an existing play of the same title,
// else seed one from the book's source text.
async function productionOpenInRehearser(title) {
const key = prodKey(title);
try {
const plays = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : [];
const match = plays.find(function (p) { return prodKey(p.title) === key; });
if (match) { openPlayInRehearser(match.id); return; }
} catch (_) {}
// No play yet — try to seed from the matching book's source.
let books = [];
try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {}
const book = books.find(function (b) { return prodKey(b.title) === key; });
if (book && book.kind !== 'pdf') {
try {
const sr = await fetch(LIB_READER_API + '/' + book.id + '/source');
const text = sr.ok ? await sr.text() : '';
if (text && typeof audiobookOpenInRehearser === 'function') { audiobookOpenInRehearser(text, title, []); return; }
} catch (_) {}
}
if (book && book.kind === 'pdf') {
if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(book.id);
toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal', 'info');
return;
}
toast('No source to rehearse for this title yet', 'error');
}
// Open a production in Read Aloud: reuse the matching saved book.
async function productionOpenInReader(title) {
const key = prodKey(title);
let books = [];
try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {}
const book = books.find(function (b) { return prodKey(b.title) === key; });
if (book && typeof readerOpenLibraryDoc === 'function') { readerOpenLibraryDoc(book.id); return; }
if (typeof navTo === 'function') navTo('s-reader');
toast('No audiobook for this title yet — import its source in Read Aloud', 'info');
}
// ── Shared cast — the character roster a production resolves against ──────────
//
// Returns name(lowercased) → { name, voice, gender, soul, tags } gathered from
// the character library by origin book OR tag membership. The rehearser and the
// audiobook caster both fill empty speaker slots from this, so a character cast
// once is reused everywhere the same production is opened.
async function castForProduction(title) {
const out = {};
if (typeof clGetAllByTagOrBook !== 'function') return out;
let recs = [];
try { recs = await clGetAllByTagOrBook(title); } catch (_) { recs = []; }
recs.forEach(function (r) {
const name = (r.name || '').trim();
if (!name) return;
const voice = (r.voice && r.voice.id) ? r.voice.id : (typeof r.voice === 'string' ? r.voice : '');
out[name.toLowerCase()] = {
name: name,
voice: voice || '',
gender: (r.sheet && r.sheet.gender) || '',
soul: (r.sheet && (r.sheet.voice_pattern || r.sheet.motivation)) || '',
tags: r.tags || '',
};
});
return out;
}
window.castForProduction = castForProduction;
// Persist cast voice choices back to the shared roster. Guard: only updates
// characters that ALREADY exist for this production (by name) — never creates
// records, so throwaway scripts and narrators don't pollute the library.
async function castWriteBack(title, castMap) {
if (!title || !castMap || typeof clGetAllByTagOrBook !== 'function' || typeof clPut !== 'function') return;
let recs = [];
try { recs = await clGetAllByTagOrBook(title); } catch (_) { return; }
if (!recs.length) return;
const byName = {};
recs.forEach(function (r) { byName[(r.name || '').trim().toLowerCase()] = r; });
let n = 0;
for (const sp of Object.keys(castMap)) {
if (String(sp).includes('NARRATOR')) continue;
const slot = castMap[sp] || {};
const voice = slot.voice;
if (!voice || voice === 'me') continue;
const rec = byName[String(sp).trim().toLowerCase()];
if (!rec) continue;
const curId = (rec.voice && rec.voice.id) ? rec.voice.id : (typeof rec.voice === 'string' ? rec.voice : '');
if (curId === voice) continue;
rec.voice = { id: voice };
rec.updated = new Date();
try { await clPut(rec); n++; } catch (_) {}
}
return n;
}
window.castWriteBack = castWriteBack;
// ── Shared helpers ────────────────────────────────────────────────────────────
// Deterministic two-tone cover from a title (mirrors bookCover in rehearser.js,
// kept local so the Library renders even if that module loads later).
function libBookCover(title) {
let h = 0; const s = String(title || 'Untitled');
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
return { c1: 'hsl(' + h + ',55%,42%)', c2: 'hsl(' + ((h + 40) % 360) + ',58%,30%)' };
}
function libConfirmDelete(cardEl, heading, sub, onConfirm) {
if (cardEl.querySelector('.reh-book-del-confirm')) return;
const o = document.createElement('div');
o.className = 'reh-book-del-confirm';
o.style.cssText = 'position:absolute;inset:0;background:rgba(0,0,0,.85);color:#fff;display:flex;flex-direction:column;justify-content:center;align-items:center;border-radius:inherit;z-index:10;padding:12px;text-align:center;box-sizing:border-box';
o.innerHTML = '<div style="font-weight:600;margin-bottom:8px;font-size:14px">' + heading + '</div>'
+ '<div style="font-size:11px;opacity:.8;margin-bottom:12px;line-height:1.4">' + sub + '</div>'
+ '<div style="display:flex;gap:8px">'
+ '<button class="btn-secondary btn-sm" data-lib-cancel style="background:rgba(255,255,255,.15);border:none;color:#fff;padding:6px 12px">Cancel</button>'
+ '<button class="btn-primary btn-sm" data-lib-ok style="background:var(--red,#ef4444);border:none;color:#fff;padding:6px 12px">Delete</button>'
+ '</div>';
o.addEventListener('click', function (e) { e.stopPropagation(); });
o.querySelector('[data-lib-cancel]').addEventListener('click', function (e) { e.stopPropagation(); o.remove(); });
o.querySelector('[data-lib-ok]').addEventListener('click', async function (e) {
e.stopPropagation();
o.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px"></span>';
await onConfirm();
});
cardEl.appendChild(o);
}
window.libraryRenderBooks = libraryRenderBooks;
window.libraryRenderPlays = libraryRenderPlays;
window.productionOpenInRehearser = productionOpenInRehearser;
window.productionOpenInReader = productionOpenInReader;
window.prodKey = prodKey;

22
static/js/pdf/pdf.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
static/js/pdf/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -258,24 +258,45 @@ async function readerLoadPdf(file) {
const nPages = pdf.numPages;
const progress = readerShowParseProgress(nPages);
// Sentences/units are built incrementally per page (page-local) and appended,
// so there is no big synchronous spike at the end and memory work is spread
// across yields — keeps huge books responsive. Earlier indices never shift
// (later pages only append), so caches/status stay aligned.
for (let p = 1; p <= nPages; p++) {
const page = await pdf.getPage(p);
if (seq !== readerState._seq) { progress.done(); return; }
const base = page.getViewport({ scale: 1 }); // geometry reference (scale-independent)
// Pre-fetch page 1 geometry to define scale/zoom quickly for skeletons
const page1 = await pdf.getPage(1);
if (seq !== readerState._seq) { progress.done(); return; }
const base1 = page1.getViewport({ scale: 1 });
// Render empty skeleton pages instantly so the user can scroll
for (let p = 1; p <= nPages; p++) {
const pageDiv = document.createElement('div');
pageDiv.className = 'reader-page';
// set initial size so scrollbar reflects full document size instantly
pageDiv.style.width = base1.width + 'px';
pageDiv.style.height = base1.height + 'px';
const overlay = document.createElement('div');
overlay.className = 'reader-overlay';
pageDiv.appendChild(overlay);
doc.appendChild(pageDiv);
// Use base1 as placeholder geometry until actual page is parsed
readerState.pages.push({ pageDiv, overlay, page: null, base: base1, rendered: false, renderTask: null });
}
const pageIdx = readerState.pages.length;
readerState.pages.push({ pageDiv, overlay, page, base, rendered: false, renderTask: null });
// Apply zoom and rasterize immediately so the user sees the book!
readerApplyZoom('fit-width');
readerSetupLazyRaster();
// Sentences/units are built incrementally per page (page-local) and appended.
// We yield on every page to keep the browser buttery smooth for scrolling.
for (let p = 1; p <= nPages; p++) {
// If page1 was already fetched, reuse it
const page = p === 1 ? page1 : await pdf.getPage(p);
if (seq !== readerState._seq) { progress.done(); return; }
const base = page.getViewport({ scale: 1 }); // actual geometry reference
const pageIdx = p - 1;
const pgState = readerState.pages[pageIdx];
// Update the skeleton with actual page data
pgState.page = page;
pgState.base = base;
// We do NOT change pageDiv.style.width here, it will be updated by readerApplyZoom / readerRenderPage
// Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later
const content = await page.getTextContent();
@ -308,24 +329,21 @@ async function readerLoadPdf(file) {
for (let u = startUnit; u < readerState.sentences.length; u++) idxs.push(u);
readerState.unitsByPage.set(pageIdx, idxs);
// Yield to the event loop every few pages so the browser stays responsive
// on big books (prevents the "page is not responding" prompt).
// Yield to the event loop EVERY page so scrolling remains 60fps
progress.update(p);
if (p % 5 === 0) await new Promise(r => setTimeout(r, 0));
await new Promise(r => setTimeout(r, 0));
}
progress.done();
if (seq !== readerState._seq) return;
readerApplyZoom('fit-width');
readerSetupLazyRaster();
}
// Lightweight parse-progress line shown in the document pane for big PDFs.
// Lightweight floating parse-progress line shown in the document pane for big PDFs.
function readerShowParseProgress(total) {
const doc = $('reader-doc'); if (!doc) return { update() {}, done() {} };
const el = document.createElement('div');
el.className = 'reader-parsing';
el.innerHTML = '<span class="reh-imsdb-spinner"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
doc.appendChild(el);
el.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:16px;"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
el.style.cssText = 'position:sticky; top:12px; z-index:100; margin:0 auto -42px; background:var(--accent); color:#fff; padding:6px 14px; border-radius:20px; font-size:12px; display:inline-flex; align-items:center; gap:8px; box-shadow:0 4px 12px rgba(0,0,0,.25); font-weight:600;';
doc.insertBefore(el, doc.firstChild);
const label = el.querySelector('span:last-child');
return {
update(p) { if (label) label.textContent = 'Reading PDF… page ' + p + ' / ' + total; },
@ -337,14 +355,23 @@ function readerShowParseProgress(total) {
function readerComputeScale(mode) {
const doc = $('reader-doc');
const base = readerState.pages[0]?.base;
if (!doc || !base) return 1;
if (!doc || !readerState.pages.length) return 1;
let maxW = 0, maxH = 0;
for (const p of readerState.pages) {
if (p.base) {
if (p.base.width > maxW) maxW = p.base.width;
if (p.base.height > maxH) maxH = p.base.height;
}
}
if (!maxW) return 1;
const padW = 36, gap = 16;
const availW = (doc.clientWidth || 900) - padW;
const availH = (doc.clientHeight || 600) - 36;
if (mode === 'fit-width') return Math.max(0.2, availW / base.width);
if (mode === 'fit-height') return Math.max(0.2, availH / base.height);
if (mode === 'two') return Math.max(0.2, (availW - gap) / 2 / base.width);
if (mode === 'fit-width') return Math.max(0.2, availW / maxW);
if (mode === 'fit-height') return Math.max(0.2, availH / maxH);
if (mode === 'two') return Math.max(0.2, (availW - gap) / 2 / maxW);
return readerState.scale;
}
@ -824,8 +851,10 @@ function readerGenParams() {
const out = {};
const seed = $('reader-seed')?.value.trim();
const temp = $('reader-temp')?.value.trim();
const nspd = $('reader-tts-speed')?.value.trim();
if (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10);
if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp);
if (nspd !== '' && nspd != null && !isNaN(+nspd) && parseFloat(nspd) !== 1) out.speed = parseFloat(nspd);
return Object.keys(out).length ? out : null;
}
@ -1077,6 +1106,7 @@ async function readerSaveLibrary() {
chunkMode: readerState.chunkMode,
seed: $('reader-seed')?.value.trim() || '',
temperature: $('reader-temp')?.value.trim() || '',
tts_speed: parseFloat($('reader-tts-speed')?.value) || 1,
normalize: readerState.normalize,
sentenceCount: readerState.sentences.length,
pageCount: readerState.pages.length,
@ -1142,6 +1172,7 @@ async function readerOpenLibraryDoc(id) {
if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
if ($('reader-temp')) $('reader-temp').value = rec.temperature || '';
if ($('reader-tts-speed') && typeof rec.tts_speed === 'number') $('reader-tts-speed').value = rec.tts_speed;
readerState.normalize = rec.normalize !== false;
if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);
@ -1196,6 +1227,8 @@ async function readerOpenLibraryDoc(id) {
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast('Opened "' + rec.title + '" · resuming at sentence ' + (readerState.idx + 1), 'success');
if (typeof window.navReaderView === 'function') window.navReaderView('main');
else showReaderView('main');
}
function readerSetSelectValue(id, value) {
@ -1211,33 +1244,127 @@ async function readerRenderLibrary() {
if (!card || !list) return;
let all = [];
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
if (!all.length) { card.hidden = true; list.innerHTML = ''; return; }
card.hidden = false;
if (!all.length) { list.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; }
list.innerHTML = all.map(rec => {
const total = rec.sentenceCount || 0;
const synth = rec.synthCount || 0;
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
const synthPct = total ? Math.round((synth / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
return `<div class="reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}">
<button class="reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
<div class="reader-book-icon"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span></div>
<div class="reader-book-title">${escHtml(rec.title)}</div>
<div class="reader-book-meta">${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''} · ${date}</div>
<div class="reader-book-bar" title="${synthPct}% synthesised"><div class="reader-book-bar-synth" style="width:${synthPct}%"></div><div class="reader-book-bar-read" style="width:${readPct}%"></div></div>
<div class="reader-book-stats"><span><span class="mdi mdi-lightning-bolt"></span> ${synthPct}% audio</span><span><span class="mdi mdi-bookmark-outline"></span> ${readPct}% read</span></div>
let h = 0;
const titleStr = rec.title || 'Untitled';
for (let i = 0; i < titleStr.length; i++) h = (h * 31 + titleStr.charCodeAt(i)) % 360;
const h2 = (h + 40) % 360;
const c1 = `hsl(${h}, 55%, 42%)`, c2 = `hsl(${h2}, 58%, 30%)`;
// A timestamp busts the cache if the cover was updated
const coverUrl = `${READER_API}/${rec.id}/cover?t=${new Date(rec.updated||Date.now()).getTime()}`;
const bgStyle = rec.hasCover ? `style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 100%), url('${coverUrl}'); background-size: cover; background-position: center; color: #fff;"` : `style="--bk1:${c1};--bk2:${c2}"`;
return `<div class="reh-book reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}" ${bgStyle}>
<div class="reh-book-actions">
<button class="reh-book-act reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="reh-book-title">${escHtml(rec.title)}</div>
<div class="reh-book-meta">
${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''}
<div class="reh-book-progress" title="${synthPct}% audio"><div style="width:${synthPct}%"></div></div>
<div style="margin-top:4px;opacity:.8"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span> ${readPct}% read · ${date}</div>
</div>
</div>`;
}).join('');
list.querySelectorAll('.reader-book').forEach(el => el.addEventListener('click', e => {
if (e.target.closest('.reader-book-del')) return;
readerOpenLibraryDoc(el.dataset.id);
}));
list.querySelectorAll('.reader-book').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.reader-book-del')) return;
readerOpenLibraryDoc(el.dataset.id);
});
const delBtn = el.querySelector('.reader-book-del');
if (delBtn) {
delBtn.addEventListener('click', e => {
e.stopPropagation();
if (el.querySelector('.reader-book-del-confirm')) return;
const confirmOverlay = document.createElement('div');
confirmOverlay.className = 'reader-book-del-confirm';
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
confirmOverlay.innerHTML = `
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Book?</div>
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">Audio files will be removed.</div>
<div style="display:flex; gap:8px;">
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
</div>
`;
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
ce.stopPropagation();
confirmOverlay.remove();
});
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
ce.stopPropagation();
confirmOverlay.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px;"></span>';
try {
const r = await fetch(`${READER_API}/${el.dataset.id}`, { method: 'DELETE' });
if (!r.ok) throw new Error('Failed to delete book');
toast('Book deleted', 'success');
readerRenderLibrary();
} catch (err) {
toast(err.message, 'error');
confirmOverlay.remove();
}
});
el.appendChild(confirmOverlay);
});
}
// Drag & drop for cover image
el.addEventListener('dragover', e => {
if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) {
e.preventDefault();
el.style.borderColor = 'var(--accent)';
}
});
el.addEventListener('dragleave', () => { el.style.borderColor = ''; });
el.addEventListener('drop', async e => {
if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
e.preventDefault(); e.stopPropagation();
el.style.borderColor = '';
const f = e.dataTransfer.files?.[0];
if (!f || !f.type.startsWith('image/')) { toast('Please drop an image file', 'error'); return; }
try {
const id = el.dataset.id;
const r = await fetch(`${READER_API}/${id}/cover`, { method: 'PUT', body: f });
if (!r.ok) throw new Error(r.statusText);
// Touch the updated timestamp so the cache buster updates on re-render
await fetch(`${READER_API}/${id}/progress`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updated: new Date().toISOString() }),
});
toast('Cover image updated', 'success');
readerRenderLibrary();
} catch (err) {
toast('Failed to upload cover: ' + (err.message || err), 'error');
}
});
});
list.querySelectorAll('.reader-book-del').forEach(btn => btn.addEventListener('click', e => {
e.stopPropagation();
if (confirm('Delete this saved document and its audio?')) readerDeleteLibraryDoc(btn.dataset.del);
}));
}
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-file-input')?.addEventListener('change', function () {
@ -1322,3 +1449,35 @@ window.addEventListener('resize', (function () {
});
});
})();
window.showReaderView = function (view) {
const main = $('reader-main-view');
const cast = $('reader-audiobook-panel');
const lib = $('reader-library-card');
if (main) main.hidden = (view !== 'main');
if (cast) {
cast.hidden = (view !== 'cast');
if (view === 'cast' && !cast.innerHTML.trim()) {
cast.innerHTML = '<div style="padding:60px 20px; text-align:center; color:var(--subtext);"><span class="mdi mdi-drama-masks" style="font-size:48px; opacity:0.5; margin-bottom:16px; display:block;"></span><h3 style="margin:0 0 8px 0; color:var(--text);">No active casting session</h3><p style="margin:0;">Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.</p><br><button class="btn-primary" onclick="navReaderView(\'main\')">Go to Reader</button></div>';
cast.className = 'ab-castpanel-inline card';
}
}
if (lib) {
lib.hidden = (view !== 'library');
if (view === 'library') {
lib.classList.remove('card'); // Remove card styling so it takes full width/height naturally
lib.style.boxShadow = 'none';
lib.style.border = 'none';
lib.style.background = 'transparent';
readerRenderLibrary(); // Re-fetch on open
}
}
};
window.readerOnShow = function () {
if (window._readerStartView) {
window.showReaderView(window._readerStartView);
window._readerStartView = null;
}
};

View File

@ -54,10 +54,13 @@ function parseScript(text) {
continue;
}
// PDF page-break marker — preserved from importPDFScript
if (line === '\f') {
// PDF page-break marker — preserved from importPDFScript / audiobook casting
// Format: bare \f or \f<number> (e.g. \f3 = page 3)
if (line.startsWith('\f')) {
flushDialog(); flushAction();
result.push({ type: 'pagebreak', speaker: '', text: '', isDirection: true });
const pnStr = line.slice(1).trim();
const pageNum = pnStr && /^\d+$/.test(pnStr) ? parseInt(pnStr, 10) : null;
result.push({ type: 'pagebreak', speaker: '', text: '', page: pageNum, isDirection: true });
state = 'action'; currentSpeaker = null;
continue;
}

View File

@ -204,7 +204,7 @@ function linesToScriptText() {
case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n';
case 'direction':
return line.speaker ? line.text + '\n' : '\n' + line.text + '\n';
case 'pagebreak': return '\n\f\n';
case 'pagebreak': return line.page ? `\n\f${line.page}\n` : '\n\f\n';
default: return '';
}
}).join('').trim();
@ -223,6 +223,8 @@ async function saveToLibrary() {
}
toast('Saved to library', 'success');
rehState._keepSavedIdOnce = true;
// Sync cast voice choices into the shared character roster (existing chars only)
if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} }
await renderLibraryList();
}
@ -371,12 +373,11 @@ async function loadPdfJs() {
if (window.pdfjsLib) return;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
s.src = '/static/js/pdf/pdf.min.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js';
}
// Extract a screenplay from a PDF, reconstructing element types from the
@ -451,7 +452,7 @@ async function importPDFScript(file) {
for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) {
// Emit a page-break marker between PDF pages so the rehearser can paginate exactly
if (pageIdx > 0) { blank(); out.push('\f'); blank(); }
if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); }
const pageLns = allLinesByPage[pageIdx] || [];
for (const ln of pageLns) {
@ -561,6 +562,7 @@ function loadRecord(rec) {
};
});
renderCastList();
rehApplySharedCast(rec.title);
refreshRehBackends().then(() => {
if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice;
@ -568,6 +570,32 @@ function loadRecord(rec) {
showPhase(2);
}
// Fill any empty cast slots from the production's shared character roster
// (character library, matched by book OR tag). Only blanks are filled — an
// explicit voice/soul already on the rehearsal always wins. Re-renders if it
// changed anything.
async function rehApplySharedCast(title) {
if (typeof castForProduction !== 'function' || !title) return;
let roster;
try { roster = await castForProduction(title); } catch (_) { return; }
if (!roster) return;
let changed = false;
Object.keys(rehState.cast || {}).forEach(sp => {
if (String(sp).includes('NARRATOR')) return;
const shared = roster[String(sp).toLowerCase()];
if (!shared) return;
const slot = rehState.cast[sp];
if (shared.voice && !slot.voice) {
slot.voice = shared.voice;
slot.voiceData = (shared.voice !== 'me' && typeof getVoiceData === 'function') ? getVoiceData(shared.voice) : null;
changed = true;
}
if (shared.gender && !slot.gender) { slot.gender = shared.gender; changed = true; }
if (shared.soul && !slot.soul) { slot.soul = shared.soul; changed = true; }
});
if (changed) renderCastList();
}
// ── Library UI ─────────────────────────────────────────────────────────────
// Deterministic cover gradient from the title string
@ -651,10 +679,37 @@ async function renderLibraryList() {
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
if (!confirm('Delete this rehearsal from the library?')) return;
await rehDbDelete(parseInt(btn.dataset.id));
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
renderLibraryList();
const bookEl = btn.closest('.reh-book');
if (bookEl.querySelector('.reh-book-del-confirm')) return;
const confirmOverlay = document.createElement('div');
confirmOverlay.className = 'reh-book-del-confirm';
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
confirmOverlay.innerHTML = `
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Rehearsal?</div>
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">This cannot be undone.</div>
<div style="display:flex; gap:8px;">
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
</div>
`;
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
ce.stopPropagation();
confirmOverlay.remove();
});
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
ce.stopPropagation();
await rehDbDelete(parseInt(btn.dataset.id));
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
renderLibraryList();
});
bookEl.appendChild(confirmOverlay);
}));
}
@ -2103,6 +2158,48 @@ $('reh-start-btn')?.addEventListener('click', () => {
highlightCurrentLine();
});
// ── Stage display controls (font size + collapsible cast) ───────────────────
const REH_STAGE_FONT_KEY = 'ttsvc_reh_stage_scale';
const REH_CAST_COLLAPSED_KEY = 'ttsvc_reh_cast_collapsed';
function rehStageScale() {
const v = parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY) || '1');
return isNaN(v) ? 1 : Math.max(0.7, Math.min(2, v));
}
function rehApplyStageFont() {
// Set on the page wrapper so the scale inherits to BOTH the single A4 page
// (#reh-a4-page) and the paginated `.reh-paper` pages built by page-mode.
const scale = String(rehStageScale());
const wrap = document.querySelector('.reh-page-wrap');
if (wrap) wrap.style.setProperty('--reh-stage-scale', scale);
const page = $('reh-a4-page');
if (page) page.style.setProperty('--reh-stage-scale', scale);
}
function rehStageFontStep(delta) {
const next = Math.max(0.7, Math.min(2, Math.round((rehStageScale() + delta) * 100) / 100));
localStorage.setItem(REH_STAGE_FONT_KEY, String(next));
rehApplyStageFont();
}
function rehApplyCastCollapsed() {
const row = $('reh-cast-row');
if (!row) return;
const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1';
row.classList.toggle('reh-cast-collapsed', collapsed);
const btn = $('reh-cast-toggle');
if (btn) btn.setAttribute('aria-expanded', String(!collapsed));
}
function rehToggleCast() {
const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1';
localStorage.setItem(REH_CAST_COLLAPSED_KEY, collapsed ? '0' : '1');
rehApplyCastCollapsed();
}
$('reh-font-inc')?.addEventListener('click', () => rehStageFontStep(0.1));
$('reh-font-dec')?.addEventListener('click', () => rehStageFontStep(-0.1));
$('reh-cast-toggle')?.addEventListener('click', rehToggleCast);
// ── A4 Script page ─────────────────────────────────────────────────────────
function buildScriptPage() {
@ -2112,7 +2209,8 @@ function buildScriptPage() {
// Cast strip
const castStrip = $('reh-cast-strip');
if (castStrip) {
castStrip.innerHTML = Object.keys(rehState.cast).map(sp => {
const names = Object.keys(rehState.cast);
castStrip.innerHTML = names.map(sp => {
const c = rehState.cast[sp], isMe = c.voice === 'me';
return `<div class="reh-strip-char" title="${escHtml(sp)} — ${isMe?'me':(c.voice||'no voice')}">
${voiceAvatarHtml(isMe?'':c.voice, c.color, 28)}
@ -2120,7 +2218,11 @@ function buildScriptPage() {
${isMe?'<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>':''}
</div>`;
}).join('');
const lbl = $('reh-cast-toggle-label');
if (lbl) lbl.textContent = `Characters (${names.length})`;
}
rehApplyStageFont();
rehApplyCastCollapsed();
const linesEl = $('reh-script-lines'); if (!linesEl) return;
@ -2166,8 +2268,10 @@ function buildScriptPage() {
${editBtn} ${synthDot}
</div>`;
case 'pagebreak':
return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">— Page break —</span></div>`;
case 'pagebreak': {
const pbLabel = line.page ? `— Page ${line.page} —` : '— Page break —';
return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">${pbLabel}</span></div>`;
}
case 'transition':
return `<div class="reh-transition${lineFlags}" data-index="${i}">${bulkCheck}${escHtml(line.text)}</div>`;

View File

@ -7,11 +7,14 @@
// 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_DE = 'Die 3.567 neuen High-End Geräte für das Server-Update benötigen eine außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance bei großer Last zu gewährleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - 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) {
if (window._appSettings && window._appSettings.seed_finder_text) {
return window._appSettings.seed_finder_text;
}
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;
@ -428,10 +431,13 @@ function attachSeedFinder(voiceId, body) {
// 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; }
const _sfUseSelected = typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0;
const ids = _sfUseSelected
? [..._bulkSelected]
: (typeof activeVoiceIds === 'function')
? activeVoiceIds()
: (window._voices || []).filter(v => v.enabled !== false).map(v => v.id);
if (!ids.length) { if (typeof toast === 'function') toast('No voices selected', 'error'); return; }
// In-app config dialog (no browser prompts): pick the seed range, then Start.
document.getElementById('seedbatch-overlay')?.remove();
@ -439,7 +445,7 @@ function seedFinderBatchAll() {
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="audiobook-msg">Generate &amp; cache Seed Finder samples for <b>${ids.length}</b> ${_sfUseSelected ? 'selected' : 'active'} voice${ids.length !== 1 ? 's' : ''}. 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>

View File

@ -328,6 +328,7 @@ const SETTINGS_FIELD_HELP = {
's-llm-url': ['LLM base URL', 'OpenAI-compatible chat endpoint used for rewrites, casting, refinement, and voice-design assistance.'],
's-auto-refine': ['Auto-refine', 'When enabled, the app sends transcripts to the LLM for cleanup automatically. Leave off if you want raw transcripts.'],
's-refine-model': ['Refinement model', 'Optional model override for transcript cleanup. Leave empty to use the current LLM default.'],
's-audiobook-prompt': ['Audiobook prompt', 'Custom LLM instructions for character detection.'],
's-captures-default-voice': ['Default playback voice', 'Preselects a TTS voice in capture workflows so you do not have to choose it every time.'],
's-tts-extra-voice-clone': ['Voice Clone params', 'Advanced JSON sent to the 8020 backend. Temperature/top_p/seed are common controls.'],
's-tts-extra-streaming': ['Streaming params', 'Advanced JSON sent to the streaming backend. Keep close to Voice Clone params for comparable sound.'],
@ -342,7 +343,10 @@ const SETTINGS_FIELD_HELP = {
's-output-dir': ['Active voices directory', 'Container path where new cloned or exported voices are saved. Usually /voices/active_voices.'],
's-tts-key': ['TTS API key', 'Optional. For local OpenAI-compatible servers, sk-local or empty usually works.'],
's-vd-key': ['Voice Design API key', 'Optional. Use only if your Voice Design backend requires Authorization.'],
's-whisper-key': ['Whisper API key', 'Optional. Needed for cloud STT, usually empty for local Whisper-compatible containers.']
's-whisper-key': ['Whisper API key', 'Optional. Needed for cloud STT, usually empty for local Whisper-compatible containers.'],
's-seed-finder-text': ['Seed Finder text', 'Custom test sentence used when opening the Seed Finder. Leave empty to use language defaults.'],
's-seed-finder-dir': ['Seed Finder Sample Files', 'Container path for seed finder sample storage.'],
's-pt-dir': ['PT Files', 'Container path for .pt embedding storage.']
};
@ -459,6 +463,12 @@ async function loadSettings() {
$('s-vd-key').value = s.voice_design_api_key || '';
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
$('s-output-dir').value = s.output_dir || '';
const seedFinderDirEl = $('s-seed-finder-dir');
if (seedFinderDirEl) seedFinderDirEl.value = s.seed_finder_dir || '';
const ptDirEl = $('s-pt-dir');
if (ptDirEl) ptDirEl.value = s.pt_dir || '';
const seedTextEl = $('s-seed-finder-text');
if (seedTextEl) seedTextEl.value = s.seed_finder_text || '';
const themeEl = $('s-theme-select');
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
// Captures settings
@ -557,6 +567,8 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
voice_design_api_key: $('s-vd-key').value,
voices_scan_dir: $('s-voices-scan-dir').value,
output_dir: $('s-output-dir').value,
seed_finder_dir: $('s-seed-finder-dir')?.value || '',
pt_dir: $('s-pt-dir')?.value || '',
stt_language: $('s-stt-language')?.value || '',
stt_preferred_backend: $('s-stt-preferred-backend')?.value || '',
auto_refine: $('s-auto-refine')?.value || 'off',
@ -566,6 +578,7 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
refine_corrections: $('s-refine-corrections')?.checked ?? true,
refine_punctuation: $('s-refine-punctuation')?.checked ?? true,
captures_default_voice: $('s-captures-default-voice')?.value || '',
seed_finder_text: $('s-seed-finder-text')?.value || '',
}) });
_appSettings.tts_stream_url = $('s-tts-stream-url').value;
_appSettings.customvoice_url = $('s-customvoice-url').value;
@ -579,6 +592,7 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
_appSettings.vibevoice_url = $('s-vibevoice-url').value;
_appSettings.xtts_url = $('s-xtts-url')?.value || '';
_appSettings.tts_stream_mode = $('s-tts-stream-mode').value;
_appSettings.seed_finder_text = $('s-seed-finder-text')?.value || '';
_ttsStreamHealth = null;
await refreshTtsBackendAvailability($('tts-backend-select')?.value || '');
markSettingsSeen();
@ -618,3 +632,70 @@ $('helper-apply-btn')?.addEventListener('click', () => {
$('voice-id-input').dispatchEvent(new Event('input'));
});
// ── Global Directory Browser ──────────────────────────────────────────────────
(function initGlobalDirBrowser() {
const modal = document.getElementById('global-dir-browser-modal');
const crumb = document.getElementById('global-dir-browser-crumb');
const list = document.getElementById('global-dir-browser-list');
const pathEl = document.getElementById('global-dir-browser-path');
const selectBtn = document.getElementById('global-dir-browser-select');
if (!modal || !crumb || !list || !pathEl || !selectBtn) return;
let currentTargetInput = null;
let currentPath = '/';
async function navigateTo(path) {
currentPath = path;
list.innerHTML = '<span class="vef-loading" style="padding:8px; color:var(--text-muted);">Loading…</span>';
pathEl.textContent = path;
try {
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
const parts = data.path.split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
crumb.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}" style="background:none; border:none; color:var(--text-link); cursor:pointer; font:inherit; padding:2px;">${escHtml(c.label)}</button><span class="vef-sep" style="color:var(--text-muted); margin:0 4px;">/</span>`
: `<span class="vef-crumb-cur" style="padding:2px;">${escHtml(c.label)}</span>`
).join('');
crumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => navigateTo(b.dataset.path)));
if (!data.dirs || !data.dirs.length) {
list.innerHTML = '<span class="vef-empty" style="padding:8px; color:var(--text-muted); display:block;">No subdirectories here.</span>';
} else {
list.innerHTML = data.dirs.map(d =>
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}" style="display:flex; width:100%; text-align:left; background:none; border:none; padding:6px 8px; border-radius:4px; cursor:pointer; color:var(--text); align-items:center; gap:8px;">
<span class="mdi mdi-folder-outline" style="color:var(--text-muted);"></span> ${escHtml(d)}
</button>`
).join('');
list.querySelectorAll('.vef-dir-item').forEach(b => {
b.addEventListener('click', () => navigateTo(b.dataset.path));
b.addEventListener('mouseover', () => b.style.background = 'var(--bg-hover)');
b.addEventListener('mouseout', () => b.style.background = 'none');
});
}
pathEl.textContent = data.path;
currentPath = data.path;
} catch (e) {
list.innerHTML = `<span class="vef-error" style="padding:8px; color:var(--color-danger); display:block;">Error: ${escHtml(e.message)}</span>`;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('.s-browse-dir-btn');
if (!btn) return;
currentTargetInput = document.getElementById(btn.dataset.target);
if (!currentTargetInput) return;
const startPath = currentTargetInput.value.trim() || currentTargetInput.placeholder || '/';
modal.hidden = false;
navigateTo(startPath);
});
selectBtn.addEventListener('click', () => {
if (currentTargetInput && currentPath) {
currentTargetInput.value = currentPath;
currentTargetInput.dispatchEvent(new Event('input', { bubbles: true }));
}
modal.hidden = true;
});
})();

View File

@ -219,17 +219,17 @@ async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
return await r.blob();
}
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false) {
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false, extra = null) {
const mode = effectiveTtsPlaybackMode(modeOverride);
if (backend !== 'streaming' || mode === 'buffered') {
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona);
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra);
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
}
try {
return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'};
} catch (e) {
if (mode === 'streaming') throw e;
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona);
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra);
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
}
}
@ -246,6 +246,15 @@ function _onPreviewGenerated(source, voice, text, backend) {
if (typeof historyPush === 'function' && source.blob) historyPush(voice, text, backend, source.blob, source.url);
}
const _TRYOUT_SPEED_KEY = 'ttsvc_tryout_native_speed';
(function _restoreTryoutSpeed() {
const saved = localStorage.getItem(_TRYOUT_SPEED_KEY);
if (saved) { const el = $('preview-native-speed'); if (el) el.value = saved; }
})();
$('preview-native-speed')?.addEventListener('change', function () {
localStorage.setItem(_TRYOUT_SPEED_KEY, this.value);
});
$('preview-btn').addEventListener('click', async () => {
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
const applyPersona = $('preview-persona-toggle')?.checked || false;
@ -255,12 +264,14 @@ $('preview-btn').addEventListener('click', async () => {
$('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true;
if($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true;
if($('effects-apply-btn')) $('effects-apply-btn').disabled=true;
const _nspd = parseFloat($('preview-native-speed')?.value);
const _extra = (!isNaN(_nspd) && _nspd !== 1) ? {speed: _nspd} : null;
try {
const audio = $('preview-audio');
const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
const source = useChunked
? await generateChunkedTts(voice, text, backend, instruct)
: await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona);
? await generateChunkedTts(voice, text, backend, instruct, _extra)
: await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra);
previewBlob = source.blob;
window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
audio.src = source.url;

View File

@ -45,6 +45,7 @@ function selectVoice(wrap) {
const langCode = v.lang || wrap.dataset.lang || '';
const flagCc = v.flag || wrap.dataset.flagCc || langCode;
const rating = v.rating || 0;
const curGender = v.gender || 'N';
const nameParts = voiceId.split('_');
// Prefer an explicit display name (e.g. the character name for Rehearser voices);
@ -58,11 +59,14 @@ function selectVoice(wrap) {
const inspFlagIcon = inspFlagCc
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
const defaultSilClass = curGender === 'M' ? 'mdi-face-man' : (curGender === 'F' ? 'mdi-face-woman' : 'mdi-account');
const silHtml = `<span class="mdi ${defaultSilClass}" style="color:#fff;font-size:32px;display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:${color}"></span>`;
const inspIconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 48) : null;
const avatarHtml = picSrcInsp
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
: inspIconHtml || inspFlagIcon || initial;
const avatarBgStyle = (picSrcInsp || inspIconHtml || inspFlagIcon) ? '' : `style="background:${color}"`;
: inspIconHtml || silHtml;
const avatarBgStyle = (picSrcInsp || inspIconHtml) ? '' : `style="background:${color}"`;
const flagIconHtml = inspFlagIcon || '';
@ -80,7 +84,6 @@ function selectVoice(wrap) {
const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'};
const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'};
const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
const curGender = v.gender || 'N';
const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
inspector.innerHTML = `

View File

@ -107,7 +107,7 @@ function toggleSortDir() {
}
function syncSortHeaders() {
document.querySelectorAll('.vl-header [data-sort]').forEach(el => {
document.querySelectorAll('.vl-header [data-sort], .vl-table-header [data-sort]').forEach(el => {
el.classList.remove('sort-asc', 'sort-desc');
if (el.dataset.sort === _sortField)
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
@ -134,19 +134,35 @@ document.addEventListener('click', e => {
const icon = btn?.querySelector('.mdi');
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
renderVoiceList();
}
if (e.target.closest('#voice-table-view-btn')) {
} else if (e.target.closest('#voice-table-view-btn')) {
window._voiceTableView = !window._voiceTableView;
if (!window._voiceTableView) window._voiceTableEditMode = false;
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);
const editBtn = document.getElementById('voice-table-edit-btn');
if (editBtn) {
editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
editBtn.classList.toggle('active', !!window._voiceTableEditMode);
}
document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', !!window._voiceTableEditMode);
// 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>';
}
renderVoiceList();
} else if (e.target.closest('#voice-table-edit-btn')) {
window._voiceTableEditMode = !window._voiceTableEditMode;
const editBtn = document.getElementById('voice-table-edit-btn');
editBtn?.classList.toggle('active', window._voiceTableEditMode);
document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', window._voiceTableEditMode);
renderVoiceList();
}
});
// Restore the preferences on load
@ -159,14 +175,8 @@ document.addEventListener('click', e => {
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();
if (sel) sel.value = field;
setSort(field);
}
}
});
@ -175,6 +185,30 @@ document.addEventListener('change', e => {
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
});
document.addEventListener('change', async e => {
if (e.target.classList.contains('vl-inline-edit')) {
const row = e.target.closest('.vl-row');
if (!row) return;
const voiceId = row.dataset.id;
const field = e.target.dataset.field;
let value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
if (field === 'rating') value = parseInt(value) || 0;
const payload = {};
payload[field] = value;
try {
await saveMeta(voiceId, payload);
const v = _voices.find(vv => vv.id === voiceId);
if (v) v[field] = value;
if (typeof toast === 'function') toast('Saved ' + field, 'success');
} catch (err) {
if (typeof toast === 'function') toast('Failed to save ' + field, 'error');
}
}
});
const FLAG_LANGUAGE_CANDIDATES = {
GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'],
CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'],
@ -383,7 +417,9 @@ async function clientVoiceLoudness(v) {
}
async function clientCalculateVoiceDb() {
const voices = visibleLibraryVoices();
const voices = (_bulkSelected && _bulkSelected.size > 0)
? (_voices || []).filter(v => _bulkSelected.has(v.id))
: visibleLibraryVoices();
const errors = [];
let calculated = 0;
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'};
@ -588,7 +624,8 @@ function libraryTargetDb() {
$('calculate-db-btn').addEventListener('click', async () => {
$('calculate-db-btn').disabled = true;
status('Calculating voice loudness…');
const _calcTarget = (_bulkSelected && _bulkSelected.size > 0) ? `${_bulkSelected.size} selected` : 'visible';
status(`Calculating voice loudness (${_calcTarget})…`);
try {
const d = await clientCalculateVoiceDb();
renderVoiceList();
@ -732,9 +769,26 @@ async function runVoiceBenchmarkBatch() {
const total = voices.length;
const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true};
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''};
// Mark targeted rows as pending
voices.forEach(v => {
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (row) {
row.classList.remove('benchmarking-active', 'benchmarking-done');
row.classList.add('benchmarking-pending');
}
});
setBenchmarkProgress(0, total, 'Starting benchmark...', stats);
for (let i = 0; i < voices.length; i++) {
const voice = voices[i];
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(voice.id)}"]`);
if (row) {
row.classList.remove('benchmarking-pending');
row.classList.add('benchmarking-active');
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats);
status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`);
try {
@ -760,8 +814,20 @@ async function runVoiceBenchmarkBatch() {
stats.errors++;
stats.last = `${voice.id}: failed · ${e.message}`;
}
if (row) {
row.classList.remove('benchmarking-active');
row.classList.add('benchmarking-done');
}
setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats);
}
// Cleanup any remaining pending rows just in case
voices.forEach(v => {
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (row) row.classList.remove('benchmarking-pending');
});
setBenchmarkProgress(total, total, 'Benchmark complete', stats);
return aggregate;
}
@ -844,9 +910,11 @@ $('copy-active-voices-btn').addEventListener('click', async () => {
// (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 _useSelected = _bulkSelected && _bulkSelected.size > 0;
const ids = _useSelected ? [..._bulkSelected] : activeVoiceIds();
const _scopeLabel = _useSelected ? `${ids.length} selected` : `${ids.length} active`;
if (!ids.length) { toast('No voices to precompute', 'error'); return; }
if (!confirm(`Precompute speaker embeddings for ${_scopeLabel} 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');
@ -1455,6 +1523,11 @@ function renderVoiceList() {
if (_tvBtn) {
_tvBtn.classList.toggle('active', !!window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!window._voiceTableView);
const editBtn = document.getElementById('voice-table-edit-btn');
if (editBtn) {
editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
editBtn.classList.toggle('active', !!window._voiceTableEditMode);
}
}
// Apply sidebar category filter
@ -1793,6 +1866,7 @@ function makeVoiceRow(v) {
const benchCls = benchmarkClass(v);
const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'};
const genderMapLetter = {F:'F', M:'M', N:'D', '':'?'};
const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
const gender = v.gender || '';
@ -1816,43 +1890,73 @@ function makeVoiceRow(v) {
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
: null;
// Avatar: photo > gender/type icon > flag icon > initial letter (with color)
// Avatar: photo > gender/type icon > silhouette (with color)
const defaultSilClass = gender === 'M' ? 'mdi-face-man' : (gender === 'F' ? 'mdi-face-woman' : 'mdi-account');
const silHtml = `<span class="mdi ${defaultSilClass}" style="color:#fff;font-size:20px;display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:${color}"></span>`;
const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
: iconHtml ? 'vl-avatar vl-avatar-icon'
: flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
const avatarStyle = (picSrc || iconHtml || flagIconHtml) ? '' : `style="background:${color}"`;
: iconHtml ? 'vl-avatar vl-avatar-icon' : 'vl-avatar vl-avatar-sil';
const avatarStyle = (picSrc || iconHtml) ? '' : `style="background:${color}"`;
const avatarContent = picSrc
? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
: iconHtml || flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`;
: iconHtml || silHtml;
wrap.innerHTML = `
<div class="vl-compact">
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
<div class="vl-info">
<span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit vl-name-input" data-field="name" value="${escHtml(dispName)}" title="Display Name" placeholder="Name">`
: `<span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>`}
<div class="vl-meta">
<span class="vl-lang-label" title="${escHtml(currentFlag || langCode)}">${flagEmoji}</span>
${gender ? `<span class="vl-gender-letter ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMapLetter[gender]||'?'}</span>` : ''}
${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText.split(' ')[0])}</span>` : ''}
<div class="vr-rating">${starsHtml}</div>
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
${gender ? `<span class="vl-gender-label ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMap[gender]||'?'} ${genderLabel[gender]||''}</span>` : ''}
${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : ''}
</div>
</div>
<div class="vr-play-group">
<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>
<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-lang" title="${escHtml(currentFlag || langCode)}">
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit" data-field="lang" value="${escHtml(langCode)}" style="width:100%">`
: `${flagEmoji} ${escHtml(langCode)}`}
</div>
<div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">
${window._voiceTableEditMode
? `<select class="vl-inline-edit" data-field="gender" style="width:100%"><option value="N" ${gender==='N'?'selected':''}>N</option><option value="M" ${gender==='M'?'selected':''}>M</option><option value="F" ${gender==='F'?'selected':''}>F</option></select>`
: `${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-rating">
${window._voiceTableEditMode
? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">`
: 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 class="vl-tbl-note" title="${escHtml(v.note||'')}">
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit" data-field="note" value="${escHtml(v.note||'')}" style="width:100%">`
: escHtml(v.note || '')}
</div>
<div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit" data-field="tag" value="${escHtml(v.tag||'')}" style="width:100%">`
: escHtml(v.tag || '')}
</div>
<div class="vl-tbl-active">
${window._voiceTableEditMode
? `<input type="checkbox" class="vl-inline-edit" data-field="enabled" ${v.enabled!==false?'checked':''} style="width:16px;height:16px">`
: `<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 class="vr-main-row">
@ -3002,6 +3106,13 @@ function _bulkUpdate() {
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
cb.checked = _bulkSelected.has(cb.dataset.id);
});
const headerCb = document.getElementById('vl-select-all-header');
if (headerCb) {
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
headerCb.checked = allSelected;
}
}
function _bulkToggle(id, checked) {
@ -3043,16 +3154,15 @@ $('vl-bulk-select-all')?.addEventListener('click', () => {
_bulkUpdate();
});
// Always-visible Select-all toggle in the sort bar (the bulk bar only appears once
// Always-visible Select-all toggle in the table header (the bulk bar only appears once
// something is selected, so this is the entry point). Selects every visible row, or
// clears the selection if they're all already selected.
$('vl-select-all-visible')?.addEventListener('click', () => {
$('vl-select-all-header')?.addEventListener('change', (e) => {
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
if (allSelected) {
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
} else {
if (e.target.checked) {
rows.forEach(r => _bulkSelected.add(r.dataset.id));
} else {
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
}
_bulkUpdate();
});

View File

@ -4,7 +4,7 @@
const SECTIONS = [
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
's-llms', 's-conversation',
's-llms', 's-conversation', 's-library',
];
function _storedSection() {
@ -126,6 +126,8 @@
'/static/js/reader.js',
'/static/js/audiobook.js',
'/static/js/character-sheets.js',
'/static/js/characters-library.js',
'/static/js/library.js',
];
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
var _bundleOk = false;

View File

@ -18,7 +18,7 @@
llms: 's-llms'
};
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library'];
function storedSection() {
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
@ -69,13 +69,40 @@
var _treeOpen = {};
try { _treeOpen = JSON.parse(localStorage.getItem('ttsvc_trees') || '{}'); } catch(_) {}
// Every collapsible tree must be listed so _applyTreeStates toggles its
// open class + chevron. `section` (optional) auto-opens the tree (and its
// ancestor trees, handled in _openTreesForSection) when that section opens.
var TREES = [
{ tree: 'nav-voices-tree', chevron: 'nav-voices-chevron', section: 's-voices' },
{ tree: 'nav-engines-tree', chevron: 'nav-engines-chevron', section: 's-llms' },
{ tree: 'nav-settings-tree', chevron: 'nav-settings-chevron', section: 's-settings' },
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
{ tree: 'nav-voices-tree', chevron: 'nav-voices-chevron', section: 's-voices' },
{ tree: 'nav-tags-tree', chevron: 'nav-tags-chevron' },
{ tree: 'nav-actions-tree', chevron: 'nav-actions-chevron' },
{ tree: 'nav-speak-tree', chevron: 'nav-speak-chevron' },
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
{ tree: 'nav-library-tree', chevron: 'nav-library-chevron', section: 's-library' },
{ tree: 'nav-setup-tree', chevron: 'nav-setup-chevron' },
{ tree: 'nav-engines-tree', chevron: 'nav-engines-chevron', section: 's-llms' },
{ tree: 'nav-integrations-tree', chevron: 'nav-integrations-chevron' },
{ tree: 'nav-settings-tree', chevron: 'nav-settings-chevron', section: 's-settings' },
];
// Open the tree(s) for a section: the head's own tree (via TREES.section)
// plus every ancestor .nav-tree of the active nav element, so nested items
// become visible. Persists once if anything changed.
function _openTreesForSection(sectionId) {
var changed = false;
TREES.forEach(function (t) {
if (t.section === sectionId && !_treeOpen[t.tree]) { _treeOpen[t.tree] = true; changed = true; }
});
var el = document.querySelector('[data-nav-section="' + sectionId + '"]');
while (el) {
if (el.classList && el.classList.contains('nav-tree') && el.id && !_treeOpen[el.id]) {
_treeOpen[el.id] = true; changed = true;
}
el = el.parentElement;
}
if (changed) localStorage.setItem('ttsvc_trees', JSON.stringify(_treeOpen));
}
function _applyTreeStates() {
TREES.forEach(function (t) {
var open = !!_treeOpen[t.tree];
@ -97,6 +124,7 @@
// 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();
if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books');
setStoredSection(sectionId);
setSectionHash(sectionId);
SECTIONS.forEach(function (id) {
@ -107,13 +135,9 @@
item.classList.toggle('active', item.dataset.navSection === sectionId);
});
// Navigating to a section auto-opens its tree (but never closes others)
TREES.forEach(function (t) {
if (t.section === sectionId && !_treeOpen[t.tree]) {
_treeOpen[t.tree] = true;
localStorage.setItem('ttsvc_trees', JSON.stringify(_treeOpen));
}
});
// Navigating to a section auto-opens its tree AND every ancestor tree
// (so a nested item is actually visible), but never closes others.
_openTreesForSection(sectionId);
_applyTreeStates();
// When entering settings, show the active sub-page (default: connections)
@ -180,30 +204,46 @@
function n(fn) { return voices.filter(fn).length; }
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v || ''; }
set('ntc-all', n(function () { return true; }));
set('ntc-cloned', n(function (v) { return v.has_ref; }));
set('ntc-designed', n(function (v) { return !v.has_ref; }));
set('ntc-favorites',n(function (v) { return (v.rating || 0) >= 4; }));
set('ntc-hidden', n(function (v) { return v.enabled === false; }));
set('nav-voices-count', voices.length);
window.buildTagsTree(voices);
};
// ── Virtual tag "subfolders" under the fixed categories ──────────────────
var tree = document.getElementById('nav-voices-tree');
if (!tree) return;
tree.querySelectorAll('.nav-tree-tag').forEach(function (e) { e.remove(); });
// ── Tags tree — distinct voice tags with counts, plus the computed
// Cloned / Designed / Favourites predicates that moved out of Library ──
window.buildTagsTree = function (voices) {
voices = voices || window._voices;
var tree = document.getElementById('nav-tags-tree');
if (!tree || !voices) return;
tree.innerHTML = '';
function n(fn) { return voices.filter(fn).length; }
// Computed predicate rows (not literal tag strings) → navVoicesCat
var preds = [
{ cat: 'cloned', icon: 'microphone-variant', label: 'Cloned', count: n(function (v) { return v.has_ref; }) },
{ cat: 'designed', icon: 'auto-fix', label: 'Designed', count: n(function (v) { return !v.has_ref; }) },
{ cat: 'favorites', icon: 'star-outline', label: 'Favourites', count: n(function (v) { return (v.rating || 0) >= 4; }) },
];
preds.forEach(function (p) {
var el = document.createElement('div');
el.className = 'nav-tree-item' + (window._voiceSidebarCat === p.cat && !window._voiceTagFilter ? ' is-active' : '');
el.dataset.voiceCat = p.cat;
el.innerHTML = '<span class="mdi mdi-' + p.icon + '"></span> ' + p.label + ' <span class="ntc">' + (p.count || '') + '</span>';
el.addEventListener('click', function () { window.navVoicesCat(p.cat); });
tree.appendChild(el);
});
// Literal tag strings → navVoicesTag (filters via _voiceTagFilter)
var counts = {};
voices.forEach(function (v) {
String(v.tag || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean)
.forEach(function (t) { counts[t] = (counts[t] || 0) + 1; });
});
var names = Object.keys(counts).sort(function (a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); });
if (!names.length) return;
var lbl = document.createElement('div');
lbl.className = 'nav-tree-tag nav-tree-tag-label';
lbl.textContent = 'Tags';
tree.appendChild(lbl);
names.forEach(function (tag) {
var el = document.createElement('div');
el.className = 'nav-tree-item nav-tree-tag' + (window._voiceTagFilter === tag ? ' is-active' : '');
el.className = 'nav-tree-item' + (window._voiceTagFilter === tag ? ' is-active' : '');
el.dataset.voiceTag = tag;
var icon = document.createElement('span'); icon.className = 'mdi mdi-tag-outline'; el.appendChild(icon);
el.appendChild(document.createTextNode(' ' + tag + ' '));
@ -277,6 +317,19 @@
});
};
// ── Reader tree sub-navigation ───────────────────────────────────────────
window.navReaderView = function (view) {
navTo('s-reader');
document.querySelectorAll('[data-reader-view]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.readerView === view);
});
if (typeof showReaderView === 'function') {
showReaderView(view);
} else {
window._readerStartView = view;
}
};
// Restore last-used section from hash/localStorage (fallback: My Voices)
var _savedSection = storedSection();
// On very first visit, default voices tree open

View File

@ -0,0 +1,26 @@
<div class="section-head">
<span class="section-icon"><span class="mdi mdi-account-box-multiple-outline"></span></span>
<div class="section-title">
<h2>Characters</h2>
<p>Your character library &mdash; persistent, book-scoped sheets gathered by the LLM as you read or rehearse. Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to fill it up.</p>
</div>
</div>
<div class="cl-toolbar card" style="padding:12px; display:flex; gap:10px; align-items:center; flex-wrap:wrap">
<label class="cl-tool-field">
<span class="cl-tool-label"><span class="mdi mdi-book-open-page-variant-outline"></span> Book</span>
<select id="cl-book-filter"><option value="">All books</option></select>
</label>
<label class="cl-tool-field" style="flex:1; min-width:180px">
<span class="cl-tool-label"><span class="mdi mdi-magnify"></span> Search</span>
<input id="cl-search" type="text" placeholder="Name, alias, archetype…" spellcheck="false">
</label>
</div>
<div id="cl-grid" class="cl-grid">
<div class="cl-empty">
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
<p>No characters yet.</p>
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
</div>
</div>

View File

@ -147,10 +147,14 @@
<div class="card">
<h2><span class="mdi mdi-message-text-outline"></span> Reference transcript</h2>
<p class="card-subtitle">The exact words spoken in the audio — type them or auto-transcribe.</p>
<div class="btn-row" style="gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
<label style="font-size:13px;color:var(--subtext);white-space:nowrap">Recognition engine:</label>
<select id="clone-stt-backend" aria-label="Speech-to-text recognition engine" style="flex:1;min-width:160px"><option value="configured">Configured Whisper/STT</option></select>
<button class="btn-secondary" id="clone-refresh-stt-btn" type="button">Refresh</button>
<div class="engine-setup-row" style="padding:10px 12px; margin-bottom:12px">
<div class="engine-setup-col no-border" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-microphone"></span> Speech to Text <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('stt'); return false;" title="Configure Speech to Text settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="clone-stt-backend" aria-label="Speech-to-text recognition engine"><option value="configured">Configured Whisper/STT</option></select>
<button class="btn-secondary" id="clone-refresh-stt-btn" type="button"><span class="mdi mdi-refresh"></span></button>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn-secondary" id="transcribe-btn"><span class="mdi mdi-message-text-outline"></span> Auto-transcribe</button>

View File

@ -7,36 +7,40 @@
</div>
<!-- Config bar -->
<div class="conv-config-bar card">
<div class="conv-config-row">
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
<select id="conv-stt-select" aria-label="Speech-to-text engine"><option value="configured">Checking...</option></select>
<div class="conv-config-bar card" style="padding:14px">
<div class="engine-setup-row" style="margin-bottom:8px">
<div class="engine-setup-col">
<label class="engine-setup-label"><span class="mdi mdi-microphone"></span> Speech to Text <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('stt'); return false;" title="Configure Speech to Text settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="conv-stt-select" aria-label="Speech-to-text engine"><option value="configured">Checking...</option></select>
</div>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
<div style="display:flex;gap:6px">
<div class="engine-setup-col" style="flex:1.5">
<label class="engine-setup-label"><span class="mdi mdi-brain"></span> Language Model <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;" title="Configure Language Model settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<input id="conv-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
<button class="btn-secondary" id="conv-llm-fetch-btn" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="conv-llm-model-select" aria-label="Language model"><option value="">— fetch models —</option></select>
</div>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
<div style="display:flex;gap:6px">
<div class="engine-setup-col" style="flex:1.5; border-right:none; padding-right:0">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="conv-tts-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="conv-tts-fetch-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
<select id="conv-tts-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
</div>
</div>
<div class="conv-config-group conv-config-group--actions">
</div>
<div class="conv-prompt-row" style="display:flex; gap:12px; align-items:flex-start">
<div style="flex:1">
<label class="conv-cfg-label"><span class="mdi mdi-text-box-outline"></span> System prompt</label>
<textarea id="conv-system-prompt" aria-label="System prompt" class="conv-system-textarea" rows="1" spellcheck="false">You are a helpful voice assistant. Keep replies short and conversational.</textarea>
</div>
<div style="align-self:flex-end">
<button class="btn-secondary" id="conv-clear-btn" title="Clear conversation"><span class="mdi mdi-delete-outline"></span> Clear</button>
</div>
</div>
<div class="conv-prompt-row">
<label class="conv-cfg-label"><span class="mdi mdi-text-box-outline"></span> System prompt</label>
<textarea id="conv-system-prompt" aria-label="System prompt" class="conv-system-textarea" rows="1" spellcheck="false">You are a helpful voice assistant. Keep replies short and conversational.</textarea>
</div>
</div>
<!-- Main area: chat + stats -->
@ -104,7 +108,7 @@
<div class="conv-pipe-val" id="cpv-llm">—</div>
</div>
<div class="conv-pipe-step" id="cps-tts">
<div class="conv-pipe-label"><span class="mdi mdi-text-to-speech"></span> TTS</div>
<div class="conv-pipe-label"><span class="mdi mdi-account-voice"></span> TTS</div>
<div class="conv-pipe-bar"><div class="conv-pipe-fill" id="cpf-tts"></div></div>
<div class="conv-pipe-val" id="cpv-tts">—</div>
</div>

View File

@ -0,0 +1,44 @@
<div class="section-head">
<span class="section-icon"><span class="mdi mdi-bookshelf"></span></span>
<div class="section-title">
<h2>Library</h2>
<p>Your productions in one place &mdash; books to read aloud, theater plays to rehearse, and the shared character cast that links them.</p>
</div>
</div>
<div class="lib-tabs">
<button class="lib-tab is-active" data-library-view="books" onclick="navLibraryView('books')"><span class="mdi mdi-book-open-page-variant-outline"></span> Books</button>
<button class="lib-tab" data-library-view="plays" onclick="navLibraryView('plays')"><span class="mdi mdi-theater"></span> Theater Plays</button>
<button class="lib-tab" data-library-view="characters" onclick="navLibraryView('characters')"><span class="mdi mdi-account-box-multiple-outline"></span> Characters / Cast</button>
</div>
<!-- Books -->
<div class="lib-panel is-active" data-library-panel="books">
<div id="lib-books-list" class="reh-bookshelf"></div>
</div>
<!-- Theater Plays -->
<div class="lib-panel" data-library-panel="plays">
<div id="lib-plays-list" class="reh-bookshelf"></div>
</div>
<!-- Characters / Cast -->
<div class="lib-panel" data-library-panel="characters">
<div class="cl-toolbar card" style="padding:12px; display:flex; gap:10px; align-items:center; flex-wrap:wrap">
<label class="cl-tool-field">
<span class="cl-tool-label"><span class="mdi mdi-tag-multiple-outline"></span> Production</span>
<select id="cl-book-filter"><option value="">All productions</option></select>
</label>
<label class="cl-tool-field" style="flex:1; min-width:180px">
<span class="cl-tool-label"><span class="mdi mdi-magnify"></span> Search</span>
<input id="cl-search" type="text" placeholder="Name, alias, archetype, tag…" spellcheck="false">
</label>
</div>
<div id="cl-grid" class="cl-grid">
<div class="cl-empty">
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
<p>No characters yet.</p>
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your cast.</p>
</div>
</div>
</div>

View File

@ -1,25 +1,50 @@
<div class="section-head">
<span class="section-icon"><span class="mdi mdi-brain"></span></span>
<div class="section-title">
<h2>Engines</h2>
<p>Local and cloud services for language generation, speech recognition, and speech synthesis.</p>
<style>
.header-cat-block { display: none !important; }
.header-cat-block.is-active { display: flex !important; }
</style>
<div class="section-head" style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div style="display:flex; gap:16px; align-items:center;">
<span class="section-icon"><span class="mdi mdi-brain"></span></span>
<div class="section-title">
<h2>Engines</h2>
<p style="margin:0">Local and cloud services for language generation, speech recognition, and synthesis.</p>
</div>
</div>
<div style="flex: 1; display: flex; justify-content: flex-end; text-align: right;">
<div class="llm-sec-header header-cat-block" data-engines-cat="llm" style="border:none; padding:0; background:none; align-items: flex-end; flex-direction: column;">
<div style="max-width: 600px;">
<h3 class="llm-sec-title">Language Models</h3>
<p class="llm-sec-note" style="margin-bottom:0">Pick the <strong>Active Language Model</strong> below — it is used by default for every LLM task (persona rewriting, transcription refinement, conversation, and rehearser character analysis). The cards below let you connect engines and apply a URL with <strong>Use as LLM</strong>.</p>
</div>
</div>
<div class="llm-sec-header header-cat-block" data-engines-cat="stt" style="border:none; padding:0; background:none; align-items: flex-end; flex-direction: column;">
<div style="max-width: 600px;">
<h3 class="llm-sec-title">Speech Recognition</h3>
<p class="llm-sec-note">Your Docker stack containers appear at the top. Local runners and cloud APIs below.</p>
</div>
</div>
<div class="llm-sec-header header-cat-block" data-engines-cat="tts" style="border:none; padding:0; background:none; align-items: flex-end; flex-direction: column;">
<div style="max-width: 600px;">
<h3 class="llm-sec-title">Text to Speech</h3>
<p class="llm-sec-note">Docker-detected engines and local presets are listed together here. Click <strong>Use as TTS</strong> to apply a URL to this app&rsquo;s backend settings.</p>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
<button class="btn-secondary dc-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
<span class="llm-free-badge llm-free-local">100% Local</span>
</div>
</div>
</div>
</div>
<!-- ── Language Models sub-page ──────────────────────────── -->
<div class="s-engines-page" data-page="llm">
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Language Models</h3>
<p class="llm-sec-note">Pick the <strong>Active Language Model</strong> below — it is used by default for every LLM task (persona rewriting, transcription refinement, conversation, and rehearser character analysis). The cards below let you connect engines and apply a URL with <strong>Use as LLM</strong>.</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('llm')"><span class="mdi mdi-plus"></span> Add Custom</button>
<span class="llm-free-badge llm-free-local">Local first</span>
</div>
</div>
<!-- ── Active Language Model (global picker) ──────────────── -->
<div class="llm-active-panel card">
<div class="llm-active-head">
@ -31,16 +56,9 @@
</div>
<div class="llm-active-controls">
<label class="llm-active-lbl"><span class="mdi mdi-server-network"></span> Endpoint</label>
<input id="llm-active-url" type="text" class="llm-active-url-inp" list="llm-active-url-suggestions"
placeholder="http://host:port/v1" spellcheck="false" autocomplete="off">
<datalist id="llm-active-url-suggestions">
<option value="http://host.docker.internal:11434/v1">Ollama — :11434</option>
<option value="http://host.docker.internal:8000/v1">vLLM — :8000</option>
<option value="http://host.docker.internal:1234/v1">LM Studio — :1234</option>
<option value="http://host.docker.internal:8080/v1">llama.cpp — :8080</option>
<option value="http://host.docker.internal:28080/v1">llama-swap — :28080</option>
<option value="http://host.docker.internal:14000/v1">LiteLLM — :14000</option>
</datalist>
<select id="llm-active-url" class="llm-active-url-inp" style="appearance: auto; cursor: pointer; padding-right: 25px;">
<option value="">— Select an endpoint —</option>
</select>
<label class="llm-active-lbl"><span class="mdi mdi-brain"></span> Model</label>
<select id="llm-active-model" aria-label="Active language model"><option value="">— fetch models —</option></select>
<button id="llm-active-refresh" class="btn-secondary" type="button" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
@ -48,207 +66,17 @@
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;margin-bottom:12px">
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Local</h4>
<button class="btn-primary" type="button" onclick="openAddEngineDialog('llm')"><span class="mdi mdi-plus"></span> Add Custom</button>
</div>
<div id="dc-grid-llm" class="dc-grid" style="margin-bottom:14px"></div>
<h4 class="llm-local-cat">Local</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-head-dots-horizontal-outline"></span></span>
<span class="llm-local-name">Ollama</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~40–80 tok/s GPU</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> &lt;1 s TTFT</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Any HF/GGUF model</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> GPU / CPU</span>
</div>
<p class="llm-local-desc">Easiest local LLM setup. Pull any model with one command. Runs Llama, Mistral, Qwen, Phi, Gemma and many more.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:11434/v1" data-llm-local-key="ollama" data-llm-local-default="http://localhost:11434/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="ollama" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-ollama-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Quick start</span>
<button class="llm-copy-btn" data-copy="curl https://ollama.ai/install.sh | sh&#10;ollama pull llama3.3">Copy</button>
</div>
<pre>curl https://ollama.ai/install.sh | sh
ollama pull llama3.3</pre>
</div>
<a class="llm-local-link" href="https://ollama.ai" target="_blank" rel="noopener">ollama.ai <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card llm-local-card-running">
<div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-lightning-bolt"></span></span>
<span class="llm-local-name">vLLM</span>
<span class="llm-local-compat llm-compat-detected">Detected in stack</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~200+ tok/s GPU</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> &lt;0.5 s TTFT</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> PagedAttention</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> GPU required</span>
</div>
<p class="llm-local-desc">High-throughput GPU inference. Already in your Docker stack. Serve any HuggingFace model with PagedAttention.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8000/v1" data-llm-local-key="vllm" data-llm-local-default="http://localhost:8000/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="vllm" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-vllm-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Add a model to your stack</span>
<button class="llm-copy-btn" data-copy="--model Qwen/Qwen3-8B --served-model-name qwen3-8b">Copy</button>
</div>
<pre>--model Qwen/Qwen3-8B \
--served-model-name qwen3-8b</pre>
</div>
<a class="llm-local-link" href="https://docs.vllm.ai" target="_blank" rel="noopener">docs.vllm.ai <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🎛️</span>
<span class="llm-local-name">LM Studio</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~20–60 tok/s</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> &lt;2 s TTFT</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> GGUF format</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> GPU / CPU</span>
</div>
<p class="llm-local-desc">GUI app for running quantized models. Built-in model browser, chat UI, and a local server with OpenAI API.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:1234/v1" data-llm-local-key="lmstudio" data-llm-local-default="http://localhost:1234/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="lmstudio" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-lmstudio-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar"><span>Enable in LM Studio</span></div>
<pre>Developer tab → Start server → Port 1234
Check "Enable CORS" for browser access</pre>
</div>
<a class="llm-local-link" href="https://lmstudio.ai" target="_blank" rel="noopener">lmstudio.ai <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🔷</span>
<span class="llm-local-name">llama.cpp</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~5–60 tok/s</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> &lt;1 s TTFT</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> GGUF format</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> CPU native</span>
</div>
<p class="llm-local-desc">Lightweight GGUF model server. Minimal RAM usage. Runs on CPU or GPU. Great for embedding in Docker.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8080/v1" data-llm-local-key="llamacpp" data-llm-local-default="http://localhost:8080/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="llamacpp" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-llamacpp-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker one-liner</span>
<button class="llm-copy-btn" data-copy="docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server -hf QuantFactory/Meta-Llama-3-8B-GGUF -hff Meta-Llama-3-8B.Q4_K_M.gguf">Copy</button>
</div>
<pre>docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server \
-hf QuantFactory/Meta-Llama-3-8B-GGUF \
-hff Meta-Llama-3-8B.Q4_K_M.gguf</pre>
</div>
<a class="llm-local-link" href="https://github.com/ggml-org/llama.cpp" target="_blank" rel="noopener">github.com/ggml-org/llama.cpp <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-swap-horizontal"></span></span>
<span class="llm-local-name">llama-swap</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> Hot model swap</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> On-demand load</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Many models, 1 port</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> GPU / CPU</span>
</div>
<p class="llm-local-desc">Proxy that loads/unloads llama.cpp (and other) models on demand and exposes them all on one OpenAI endpoint. Pick any served model below.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://host.docker.internal:28080/v1" data-llm-local-key="llama-swap" data-llm-local-default="http://host.docker.internal:28080/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="llama-swap" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-llama-swap-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker one-liner</span>
<button class="llm-copy-btn" data-copy="docker run -p 28080:8080 -v ./config.yaml:/app/config.yaml ghcr.io/mostlygeek/llama-swap:cuda">Copy</button>
</div>
<pre>docker run -p 28080:8080 \
-v ./config.yaml:/app/config.yaml \
ghcr.io/mostlygeek/llama-swap:cuda</pre>
</div>
<a class="llm-local-link" href="https://github.com/mostlygeek/llama-swap" target="_blank" rel="noopener">github.com/mostlygeek/llama-swap <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-call-split"></span></span>
<span class="llm-local-name">LiteLLM</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> 100+ providers</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> Unified API</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Routing &amp; fallback</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> Proxy / gateway</span>
</div>
<p class="llm-local-desc">Gateway that exposes local and cloud models behind one OpenAI-compatible endpoint, with routing, fallbacks, and budgets. Pick any configured model below.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://host.docker.internal:14000/v1" data-llm-local-key="litellm" data-llm-local-default="http://host.docker.internal:14000/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="litellm" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-litellm-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker one-liner</span>
<button class="llm-copy-btn" data-copy="docker run -p 14000:4000 -v ./config.yaml:/app/config.yaml ghcr.io/berriai/litellm:main-latest --config /app/config.yaml">Copy</button>
</div>
<pre>docker run -p 14000:4000 \
-v ./config.yaml:/app/config.yaml \
ghcr.io/berriai/litellm:main-latest \
--config /app/config.yaml</pre>
</div>
<a class="llm-local-link" href="https://docs.litellm.ai" target="_blank" rel="noopener">docs.litellm.ai <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:28px;margin-bottom:12px">
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase"><span class="mdi mdi-earth"></span> Cloud APIs <span class="llm-free-badge" style="margin-left:8px;vertical-align:middle;text-transform:none">Free tiers</span></h4>
<button class="btn-primary" type="button" onclick="openAddEngineDialog('llm')"><span class="mdi mdi-plus"></span> Add Custom</button>
</div>
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px"><span class="mdi mdi-earth"></span> Cloud APIs <span class="llm-free-badge" style="margin-left:8px;vertical-align:middle">Free tiers</span></h4>
<div class="llm-service-grid">
<div class="llm-card llm-card-highlight">
@ -365,7 +193,38 @@ Check "Enable CORS" for browser access</pre>
<span class="llm-model-tag">mistral-large-latest</span>
</div>
</div>
</div>
<h4 class="llm-local-cat" style="margin-top: 40px; text-transform: uppercase;">Additional Settings</h4>
<div class="card" style="margin-bottom:24px;">
<div class="s-page-head">
<h3>LLM Refinement Defaults</h3>
<p>Automatic text cleanup after transcription</p>
</div>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px;">
<div class="s-field">
<label>Auto-refine after transcription</label>
<select id="s-auto-refine">
<option value="off">Off — show raw transcription</option>
<option value="on">On — refine automatically</option>
</select>
<span class="s-hint">When on, the LLM refinement runs immediately after each capture.</span>
</div>
<div class="s-field">
<label>Refinement model</label>
<input type="text" id="s-refine-model" placeholder="e.g. llama3.2, qwen3:8b, gpt-4o-mini">
<span class="s-hint">Model name sent to the active LLM URL. Leave empty to use the app's current default.</span>
</div>
</div>
<div class="s-toggle-grid">
<label class="s-toggle-row"><input type="checkbox" id="s-refine-fillers"><div><span class="s-toggle-title">Remove filler words</span><span class="s-hint">Strips um, uh, like, you know, sort of.</span></div></label>
<label class="s-toggle-row"><input type="checkbox" id="s-refine-repetitions"><div><span class="s-toggle-title">Remove repetitions</span><span class="s-hint">Collapses repeated words and phrases.</span></div></label>
<label class="s-toggle-row"><input type="checkbox" id="s-refine-corrections"><div><span class="s-toggle-title">Fix grammar &amp; spelling</span><span class="s-hint">Light corrections without changing meaning.</span></div></label>
<label class="s-toggle-row"><input type="checkbox" id="s-refine-punctuation"><div><span class="s-toggle-title">Fix punctuation</span><span class="s-hint">Adds missing commas, periods, and sentence casing.</span></div></label>
</div>
<div class="s-page-actions" style="margin-top: 15px;">
<button class="btn-primary s-save-btn">Save settings</button>
</div>
</div>
</div><!-- /llm -->
@ -373,113 +232,21 @@ Check "Enable CORS" for browser access</pre>
<!-- ── Speech to Text sub-page ───────────────────────────── -->
<div class="s-engines-page" data-page="stt">
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Speech Recognition</h3>
<p class="llm-sec-note">Your Docker stack containers appear at the top. Local runners and cloud APIs below.</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('stt')"><span class="mdi mdi-plus"></span> Add Custom</button>
<button class="btn-secondary dc-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
<span class="llm-free-badge llm-free-local">100% Local</span>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;margin-bottom:12px">
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Local</h4>
<button class="btn-primary" type="button" onclick="openAddEngineDialog('stt')"><span class="mdi mdi-plus"></span> Add Custom</button>
</div>
<div id="dc-grid-stt" class="dc-grid" style="margin-bottom:14px"></div>
<!-- Inline STT test panel -->
<div class="stt-test-panel" id="stt-test-panel">
<div class="stt-test-row">
<span class="stt-test-label">Quick test</span>
<select id="stt-test-backend" class="stt-test-select"></select>
<button id="stt-test-mic-btn" class="stt-test-mic" title="Hold to record, click again to stop">
<span class="mdi mdi-microphone"></span>
</button>
<span id="stt-test-status" class="stt-test-status">Ready</span>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:28px;margin-bottom:12px">
<div style="display:flex;gap:8px;align-items:center">
<span class="mdi mdi-cloud-outline"></span>
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Cloud APIs</h4>
<span class="llm-free-badge">Free tiers</span>
</div>
<div id="stt-test-result" class="stt-test-result" style="display:none"></div>
<button class="btn-primary" type="button" onclick="openAddEngineDialog('stt')"><span class="mdi mdi-plus"></span> Add Custom</button>
</div>
<h4 class="llm-local-cat">Other local STT</h4>
<div class="llm-local-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-microphone-variant"></span>️</span>
<span class="llm-local-name">faster-whisper-server</span>
<span class="llm-local-compat">OpenAI-compat</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~70&times; RT GPU</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> 0.5–2 s</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> large-v3</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> 1.5 GB VRAM</span>
</div>
<p class="llm-local-desc">Drop-in local replacement for the Whisper API. GPU-accelerated via CTranslate2. OpenAI-compatible endpoint.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8000" data-llm-local-key="faster-whisper" data-llm-local-default="http://localhost:8000" spellcheck="false">
<button class="llm-local-ping" data-ping-key="faster-whisper" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-faster-whisper-stt" type="button" title="Copy URL to Settings → faster-whisper-server URL and set as active STT"><span class="mdi mdi-content-copy"></span> Use as STT</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>docker-compose snippet</span>
<button class="llm-copy-btn" data-copy="services:&#10; whisper:&#10; image: fedirz/faster-whisper-server:latest-cuda&#10; ports: [&quot;8000:8000&quot;]&#10; environment:&#10; - WHISPER__MODEL=large-v3&#10; deploy:&#10; resources:&#10; reservations:&#10; devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]">Copy</button>
</div>
<pre>services:
whisper:
image: fedirz/faster-whisper-server:latest-cuda
ports: ["8000:8000"]
environment:
- WHISPER__MODEL=large-v3
deploy:
resources:
reservations:
devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]</pre>
</div>
<a class="llm-local-link" href="https://github.com/fedirz/faster-whisper-server" target="_blank" rel="noopener">github.com/fedirz/faster-whisper-server <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🔉</span>
<span class="llm-local-name">whisper.cpp</span>
<span class="llm-local-compat">HTTP server</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~8–15&times; RT CPU</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> 1–5 s</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> large-v3 Q5</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> ~1 GB RAM</span>
</div>
<p class="llm-local-desc">Minimal C++ Whisper with a built-in HTTP server. CPU or Metal/CUDA. Low memory, fast on consumer hardware.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8085" data-llm-local-key="whisper-cpp" data-llm-local-default="http://localhost:8085" spellcheck="false">
<button class="llm-local-ping" data-ping-key="whisper-cpp" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-whisper-cpp-stt" type="button" title="Copy URL to Settings → whisper.cpp URL and set as active STT"><span class="mdi mdi-content-copy"></span> Use as STT</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Build &amp; run</span>
<button class="llm-copy-btn" data-copy="git clone https://github.com/ggml-org/whisper.cpp&#10;cd whisper.cpp && cmake -B build && cmake --build build -j&#10;./build/bin/whisper-server -m models/ggml-large-v3.bin --port 8085">Copy</button>
</div>
<pre>git clone https://github.com/ggml-org/whisper.cpp
cd whisper.cpp && cmake -B build && cmake --build build -j
./build/bin/whisper-server \
-m models/ggml-large-v3.bin --port 8085</pre>
</div>
<a class="llm-local-link" href="https://github.com/ggml-org/whisper.cpp" target="_blank" rel="noopener">github.com/ggml-org/whisper.cpp <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
</div>
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px"><span class="mdi mdi-earth"></span> Cloud APIs <span class="llm-free-badge" style="margin-left:8px;vertical-align:middle">Free tiers</span></h4>
<div class="llm-service-grid">
<div class="llm-card llm-card-highlight">
@ -565,197 +332,120 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:28px;margin-bottom:12px">
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Additional Settings</h4>
</div>
<div class="card" style="margin-bottom: 24px;">
<div class="s-page-head">
<h3>STT Connections</h3>
<p>Set up endpoints for Speech-to-Text backends.</p>
</div>
<div class="settings-grid stt-settings-grid">
<div class="s-field stt-url-field">
<label>Active STT URL</label>
<input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010">
<span class="s-hint">Default recognition endpoint. Expected: <code>POST /v1/audio/transcriptions</code>.</span>
<div class="btn-row settings-mini-actions">
<button class="btn-secondary" id="s-use-parakeet-asr" type="button">Use Parakeet</button>
<button class="btn-secondary" id="s-use-nvidia-router" type="button">Use router</button>
<button class="btn-secondary" id="s-use-faster-whisper" type="button">Use faster-whisper</button>
<button class="btn-secondary" id="s-use-whisper-cpp" type="button">Use whisper.cpp</button>
</div>
</div>
<div class="s-field">
<label>faster-whisper-server URL <span class="s-label-note">(CTranslate2 GPU)</span></label>
<input type="text" id="s-faster-whisper-url" placeholder="http://host.docker.internal:8000">
<span class="s-hint">GPU-accelerated Whisper via CTranslate2. ~70&times; RT &middot; 1.5 GB VRAM.</span>
</div>
<div class="s-field">
<label>whisper.cpp URL <span class="s-label-note">(CPU/CUDA)</span></label>
<input type="text" id="s-whisper-cpp-url" placeholder="http://host.docker.internal:8080">
<span class="s-hint">Lightweight C++ Whisper server. ~8&ndash;15&times; RT CPU &middot; ~1 GB RAM.</span>
</div>
<div class="s-field">
<label>Groq API key <span class="s-label-note">(Groq Whisper &amp; LLM)</span></label>
<div class="s-key-row">
<input type="password" id="s-groq-api-key" placeholder="gsk_&hellip;" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-groq-api-key" aria-label="Toggle API key visibility"><span class="mdi mdi-eye-outline"></span></button>
</div>
<span class="s-hint">Used for Groq Whisper STT and LLM. Free: 2 000 req/day.</span>
</div>
</div>
<div class="s-page-actions" style="margin-top: 15px;">
<button class="btn-primary s-save-btn">Save connections</button>
</div>
</div>
<!-- Inline STT test panel -->
<div class="stt-test-panel" id="stt-test-panel">
<div class="stt-test-row">
<span class="stt-test-label">Quick test</span>
<select id="stt-test-backend" class="stt-test-select"></select>
<button id="stt-test-mic-btn" class="stt-test-mic" title="Hold to record, click again to stop">
<span class="mdi mdi-microphone"></span>
</button>
<span id="stt-test-status" class="stt-test-status">Ready</span>
</div>
<div id="stt-test-result" class="stt-test-result" style="display:none"></div>
</div>
<div class="card" style="margin-top: 24px;">
<div class="s-page-head">
<h3>STT Transcription Defaults</h3>
<p>Default language and preferred STT backend for captures.</p>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>Default language</label>
<select id="s-stt-language">
<option value="">Auto-detect</option>
<option value="en">English</option>
<option value="de">German</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="nl">Dutch</option>
<option value="ja">Japanese</option>
<option value="zh">Chinese</option>
</select>
</div>
<div class="s-field">
<label>Preferred STT backend</label>
<select id="s-stt-preferred-backend">
<option value="">Use active STT URL (from Connections)</option>
<option value="groq">Groq Whisper — cloud, fastest</option>
<option value="faster-whisper">faster-whisper-server — GPU local</option>
<option value="whisper-cpp">whisper.cpp — CPU/CUDA local</option>
<option value="parakeet">NVIDIA Parakeet — GPU local</option>
</select>
<span class="s-hint">Overrides the active STT URL for STT-TTS panel captures.</span>
</div>
</div>
<div class="s-page-actions" style="margin-top: 15px;">
<button class="btn-primary s-save-btn">Save settings</button>
</div>
</div>
</div><!-- /stt -->
<!-- ── Text to Speech sub-page ───────────────────────────── -->
<div class="s-engines-page" data-page="tts">
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Text to Speech</h3>
<p class="llm-sec-note">Docker-detected engines and local presets are listed together here. Click <strong>Use as TTS</strong> to apply a URL to this app&rsquo;s backend settings.</p>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;margin-bottom:12px">
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Local</h4>
<button class="btn-primary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
</div>
<div id="dc-grid-tts" class="dc-grid" style="margin-bottom:14px"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:28px;margin-bottom:12px">
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
<button class="btn-secondary dc-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
<span class="llm-free-badge llm-free-local">100% Local</span>
<span class="mdi mdi-cloud-outline"></span>
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Cloud APIs</h4>
<span class="llm-free-badge">Free tiers</span>
</div>
<button class="btn-primary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
</div>
<div id="dc-grid-tts" class="dc-grid tts-local-engines-grid"></div>
<div class="llm-local-grid tts-local-engines-grid">
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🐟</span>
<span class="llm-local-name">Fish-Speech (S2-Pro)</span>
<span class="llm-local-compat">Clone + Emotion</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~0.4&times; GPU</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> 1&ndash;4 s</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Premium</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> ~4 GB VRAM</span>
</div>
<p class="llm-local-desc">Clones a saved WAV voice <strong>and</strong> honours inline emotion markers like <code>(angry)</code>, <code>(whispering)</code>, <code>(excited)</code> per line &mdash; consistent speaker identity with real tone control. The only backend that is both WAV-anchored and style-aware.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:38080" data-llm-local-key="fishspeech" data-llm-local-default="http://host.docker.internal:38080" spellcheck="false">
<button class="llm-local-ping" data-ping-key="fishspeech" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-fishspeech-tts" type="button"><span class="mdi mdi-content-copy"></span> Use as TTS</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="cd /home/sparky/Docker/fishaudio/s1 && docker compose up -d">Copy</button>
</div>
<pre>cd fishaudio/s1
docker compose up -d # S2-Pro API on :38080</pre>
</div>
<a class="llm-local-link" href="https://github.com/fishaudio/fish-speech" target="_blank" rel="noopener">github.com/fishaudio/fish-speech <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-speaker-outline"></span></span>
<span class="llm-local-name">Piper TTS</span>
<span class="llm-local-compat">Fast &middot; offline</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~1× CPU realtime</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> &lt;50 ms</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Good (VITS)</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> ~50 MB RAM</span>
</div>
<p class="llm-local-desc">Lightning-fast offline TTS. Runs on CPU in real time. 50+ language voices available. Uses Wyoming protocol (port 10200) &mdash; not directly OpenAI-compatible.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="localhost:10200" data-llm-local-key="piper" data-llm-local-default="localhost:10200" spellcheck="false">
<button class="llm-local-ping" data-ping-key="piper" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-piper-tts" type="button"><span class="mdi mdi-content-copy"></span> Use as TTS</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 10200:10200 rhasspy/wyoming-piper --voice en_US-lessac-medium">Copy</button>
</div>
<pre>docker run -p 10200:10200 \
rhasspy/wyoming-piper \
--voice en_US-lessac-medium</pre>
</div>
<a class="llm-local-link" href="https://github.com/rhasspy/piper" target="_blank" rel="noopener">github.com/rhasspy/piper <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🌸</span>
<span class="llm-local-name">Kokoro FastAPI</span>
<span class="llm-local-compat">OpenAI-compat TTS</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~0.1&times; CPU RTF</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> ~200 ms</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> High (82M)</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> 300 MB CPU</span>
</div>
<p class="llm-local-desc">Kokoro-82M running behind an OpenAI-compatible TTS endpoint. 11 built-in voices (af_bella, bf_emma, am_adam&hellip;). Drop-in replacement for OpenAI&rsquo;s TTS API.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8880/v1" data-llm-local-key="kokoro" data-llm-local-default="http://localhost:8880/v1" spellcheck="false">
<button class="llm-local-ping" data-ping-key="kokoro" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-kokoro-tts" type="button" title="Copy URL to Settings → Kokoro FastAPI URL and enable Kokoro in Try It Out"><span class="mdi mdi-content-copy"></span> Use as TTS</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2">Copy</button>
</div>
<pre>docker run -p 8880:8880 \
ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2
# GPU:
docker run -p 8880:8880 --gpus all \
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.2</pre>
</div>
<a class="llm-local-link" href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener">github.com/remsky/Kokoro-FastAPI <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon">🗣️</span>
<span class="llm-local-name">XTTS v2</span>
<span class="llm-local-compat">Voice cloning</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> ~0.5× GPU RTF</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> 1–3 s</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> High (WAV clone)</span>
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> ~5 GB</span>
</div>
<p class="llm-local-desc">Coqui XTTS &mdash; multilingual voice cloning from a 6-second sample. 17 languages. Compatible with this app&rsquo;s voice library.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:8020/tts_to_audio" data-llm-local-key="xtts" data-llm-local-default="http://localhost:8020/tts_to_audio" spellcheck="false">
<button class="llm-local-ping" data-ping-key="xtts" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-xtts-tts" type="button"><span class="mdi mdi-content-copy"></span> Use as TTS</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Docker</span>
<button class="llm-copy-btn" data-copy="docker run -p 8020:80 --gpus all -v /voices:/voices daswer123/xtts-api-server:latest">Copy</button>
</div>
<pre>docker run -p 8020:80 --gpus all \
-v /voices:/voices \
daswer123/xtts-api-server:latest</pre>
</div>
<a class="llm-local-link" href="https://github.com/daswer123/xtts-api-server" target="_blank" rel="noopener">xtts-api-server <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
<div class="llm-local-card">
<div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-microphone-variant"></span></span>
<span class="llm-local-name">VibeVoice</span>
<span class="llm-local-compat">Simple REST TTS</span>
</div>
<div class="llm-local-metrics">
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> Lightweight</span>
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> Low latency</span>
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> No auth</span>
<span class="llm-metric-chip"><span class="mdi mdi-lan-connect"></span> LAN service</span>
</div>
<p class="llm-local-desc">Lightweight local TTS service with a minimal REST API &mdash; POST text, get audio. No API key, no model field, no OpenAI wrapper needed. Set the URL in Settings and pick <em>VibeVoice</em> as the backend in Try It Out or a routing rule.</p>
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp" type="text" placeholder="http://192.168.178.8:8027" data-llm-local-key="vibevoice" data-llm-local-default="http://192.168.178.8:8027" spellcheck="false">
<button class="llm-local-ping" data-ping-key="vibevoice" title="Test connection">Connect</button>
</div>
<div class="llm-local-actions">
<button class="llm-use-btn" id="llm-use-vibevoice-tts" type="button" title="Copy URL to Settings → VibeVoice URL and enable VibeVoice backend"><span class="mdi mdi-content-copy"></span> Use as TTS</button>
</div>
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>Direct REST test</span>
<button class="llm-copy-btn" data-copy="curl -X POST http://192.168.178.8:8027/tts \&#10; -H &quot;Content-Type: application/json&quot; \&#10; -d '{&quot;text&quot;:&quot;Hello from VibeVoice&quot;}' \&#10; --output vibevoice-test.wav">Copy</button>
</div>
<pre>curl -X POST http://192.168.178.8:8027/tts \
-H "Content-Type: application/json" \
-d '{"text":"Hello from VibeVoice"}' \
--output vibevoice-test.wav</pre>
</div>
<a class="llm-local-link" href="https://github.com/Mekopa/VibeVoice" target="_blank" rel="noopener">github.com/Mekopa/VibeVoice <span class="mdi mdi-open-in-new link-icon"></span></a>
</div>
</div>
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px"><span class="mdi mdi-earth"></span> Cloud APIs <span class="llm-free-badge" style="margin-left:8px;vertical-align:middle">Free tiers</span></h4>
<div class="llm-service-grid">
<div class="llm-card llm-card-highlight">
@ -829,14 +519,53 @@ docker run -p 8880:8880 --gpus all \
</div>
</div>
</div>
</div> <!-- CLOSE llm-service-grid -->
<div class="llm-local-howto">
<div class="llm-howto-icon"><span class="mdi mdi-lightbulb-outline"></span></div>
<div class="llm-howto-body">
<strong>Adding a local service to this app</strong>
<p>All OpenAI-compatible services work the same way: open <em>App Routing</em> in the sidebar, add a new backend entry with the local URL, leave the API key field empty, and set it as the default backend for the voice type you want.</p>
<h4 class="llm-local-cat" style="margin-top: 40px; text-transform: uppercase;">Additional Settings</h4>
<div class="card" style="margin-bottom: 24px;">
<div class="s-page-head">
<h3>TTS Playback & API Behavior</h3>
<p>Controls how previews play and how OpenAI-compatible requests are shaped.</p>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>TTS preview playback</label>
<select id="s-tts-stream-mode">
<option value="auto">Stream when available, then fall back</option>
<option value="streaming">Streaming only</option>
<option value="buffered">Buffered WAV only</option>
</select>
<span class="s-hint">Buffered keeps Save WAV available. Streaming starts sooner.</span>
</div>
<div class="s-field">
<label>OpenAI-compatible request style</label>
<select id="s-tts-backend">
<option value="openai">Qwen3-TTS / OpenAI</option>
<option value="localai">LocalAI (/tts)</option>
<option value="pocket">Pocket-TTS</option>
<option value="nvidia_magpie">NVIDIA Magpie / OpenAI</option>
</select>
<span class="s-hint">Controls payload shape for the normal TTS API URL.</span>
</div>
</div>
<div class="card" style="margin-bottom: 24px;">
<div class="s-page-head">
<h3>Default Playback Voice</h3>
<p>Pre-selected voice in the STT-TTS panel</p>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>Default voice</label>
<select id="s-captures-default-voice">
<option value="">None — select manually</option>
</select>
</div>
</div>
<div class="s-page-actions" style="margin-top: 15px;">
<button class="btn-primary s-save-btn">Save settings</button>
<button class="btn-secondary s-reload-btn">Reload settings</button>
</div>
</div><!-- /tts -->

View File

@ -9,7 +9,7 @@
<div class="bench-tabs" role="tablist" aria-label="Benchmark modes">
<button class="bench-tab" type="button" data-bench-tab="stt"><span class="mdi mdi-microphone-outline"></span> STT <span>Speech to Text</span></button>
<button class="bench-tab active" type="button" data-bench-tab="tts"><span class="mdi mdi-text-to-speech"></span> TTS <span>Text to Speech</span></button>
<button class="bench-tab active" type="button" data-bench-tab="tts"><span class="mdi mdi-account-voice"></span> TTS <span>Text to Speech</span></button>
<button class="bench-tab" type="button" data-bench-tab="turn"><span class="mdi mdi-swap-horizontal"></span> Turn <span>STT &rarr; TTS</span></button>
</div>
@ -75,26 +75,25 @@
<div class="card">
<h2>Run benchmark</h2>
<p class="card-subtitle">Pick a backend and voice, set run count, then measure synthesis latency and real-time factor (RTF). RTF &lt; 1.0 means the backend generates faster than real-time.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Backend</label>
<select id="perf-backend-select"><option value="">Checking backends…</option></select>
</div>
<div class="field">
<label>Voice</label>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="perf-fetch-voices-btn" type="button">Fetch voices</button>
<select id="perf-voice-select"><option value="">— select after fetch —</option></select>
<div class="engine-setup-row" style="margin-bottom:12px">
<div class="engine-setup-col" style="flex:2">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="perf-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
<button class="btn-secondary" id="perf-fetch-voices-btn" type="button" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
<select id="perf-voice-select" aria-label="Voice"><option value="">— select after fetch —</option></select>
</div>
</div>
<div class="field">
<label>Runs</label>
<select id="perf-runs">
<option value="1">1 run</option>
<option value="3" selected>3 runs</option>
<option value="5">5 runs</option>
<option value="10">10 runs</option>
</select>
<div class="engine-setup-col no-border" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Benchmark Options</label>
<div class="engine-setup-controls">
<select id="perf-runs" aria-label="Runs">
<option value="1">1 run</option>
<option value="3" selected>3 runs</option>
<option value="5">5 runs</option>
<option value="10">10 runs</option>
</select>
</div>
</div>
</div>
<div class="field">
@ -138,19 +137,23 @@
<div class="card">
<h2>Batch benchmark</h2>
<p class="card-subtitle">Benchmark multiple voices in one run. Pre-populated from your active My Voices — or reload from the backend. Results are sorted fastest first and saved to History.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Backend</label>
<select id="batch-backend-select"><option value="">Checking backends…</option></select>
<div class="engine-setup-row" style="margin-bottom:12px">
<div class="engine-setup-col" style="flex:2">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="batch-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
</div>
</div>
<div class="field">
<label>Runs per voice</label>
<select id="batch-runs">
<option value="1" selected>1 run</option>
<option value="3">3 runs</option>
<option value="5">5 runs</option>
<option value="10">10 runs</option>
</select>
<div class="engine-setup-col no-border" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Benchmark Options</label>
<div class="engine-setup-controls">
<select id="batch-runs" aria-label="Runs per voice">
<option value="1" selected>1 run</option>
<option value="3">3 runs</option>
<option value="5">5 runs</option>
<option value="10">10 runs</option>
</select>
</div>
</div>
</div>
<p class="note" id="batch-benchmark-help" style="margin:4px 0 8px">Uses the sample text and Runs value from the single-voice form above. Check one voice, a few voices, or Select all, then run them together.</p>
@ -218,25 +221,27 @@
<p>Runs the same STT &rarr; LLM &rarr; TTS pipeline as Conversation Playground and records turn latency.</p>
</div>
</div>
<div class="conv-config-row">
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
<select id="bench-turn-stt"><option value="configured">Checking...</option></select>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
<div class="bench-inline-controls">
<input id="bench-turn-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
<button class="btn-secondary" id="bench-turn-fetch-llm" type="button" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="bench-turn-llm-model"><option value="">Fetch models</option></select>
<div class="engine-setup-row" style="margin-bottom:12px; margin-top:12px">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-microphone"></span> Speech to Text <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('stt'); return false;" title="Configure Speech to Text settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="bench-turn-stt" aria-label="STT backend"><option value="configured">Checking...</option></select>
</div>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
<div class="bench-inline-controls">
<select id="bench-turn-tts-backend"><option value="">Checking...</option></select>
<div class="engine-setup-col" style="flex:1.5">
<label class="engine-setup-label"><span class="mdi mdi-brain"></span> Language Model <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;" title="Configure Language Model settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<input id="bench-turn-llm-url" type="text" placeholder="http://localhost:11434/v1" spellcheck="false" aria-label="LLM URL">
<button class="btn-secondary" id="bench-turn-fetch-llm" type="button" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="bench-turn-llm-model" aria-label="LLM Model"><option value="">Fetch models</option></select>
</div>
</div>
<div class="engine-setup-col no-border" style="flex:1.5">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="bench-turn-tts-backend" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="bench-turn-fetch-voices" type="button" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
<select id="bench-turn-voice"><option value="">Fetch voices</option></select>
<select id="bench-turn-voice" aria-label="Voice"><option value="">Fetch voices</option></select>
</div>
</div>
</div>
@ -270,7 +275,7 @@
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-microphone-outline"></span> STT</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-stt"></div></div><div class="conv-pipe-val" id="bench-turn-val-stt">—</div></div>
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-timer-outline"></span> LLM first token</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-ttft"></div></div><div class="conv-pipe-val" id="bench-turn-val-ttft">—</div></div>
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-brain"></span> LLM total</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-llm"></div></div><div class="conv-pipe-val" id="bench-turn-val-llm">—</div></div>
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-text-to-speech"></span> TTS</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-tts"></div></div><div class="conv-pipe-val" id="bench-turn-val-tts">—</div></div>
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-account-voice"></span> TTS</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-tts"></div></div><div class="conv-pipe-val" id="bench-turn-val-tts">—</div></div>
<div class="conv-pipe-step conv-pipe-total"><div class="conv-pipe-label"><span class="mdi mdi-timer-check-outline"></span> Total</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-total" style="background:var(--accent)"></div></div><div class="conv-pipe-val" id="bench-turn-val-total">—</div></div>
</div>
<div class="conv-stats-head" style="margin-top:14px">Turn history</div>

View File

@ -8,36 +8,39 @@
<div class="tab-content" id="tab-reader">
<div id="reader-main-view">
<!-- ① 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 class="card" style="padding:14px">
<div class="engine-setup-row" style="margin-bottom:8px">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="reader-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="reader-fetch-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
<select id="reader-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
</div>
</div>
<div class="engine-setup-col no-border" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Synthesis Options</label>
<div class="engine-setup-controls" style="flex-wrap:nowrap">
<input type="text" id="reader-instruct" placeholder="Optional style / tone" autocomplete="off" style="width:140px">
<select id="reader-chunk-mode" title="Voice consistency" style="width:120px">
<option value="sentence">Per sentence</option>
<option value="paragraph">Per paragraph</option>
<option value="page">Per page</option>
</select>
<label class="reader-ctl reader-norm-toggle" title="Even out loudness" style="margin:0; font-size:11px">
<input type="checkbox" id="reader-normalize" checked> Norm
</label>
</div>
</div>
</div>
<div class="reader-controls" style="margin-top:12px; background:var(--bg); border:1px solid var(--border); padding:10px 12px; border-radius:8px">
<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">
@ -46,25 +49,18 @@
<label>Temperature <span class="note">(0–1)</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">
<div class="field reader-ctl reader-ctl-sm">
<label>Native Speed <span class="note">(0.5–2)</span></label>
<input type="number" id="reader-tts-speed" min="0.5" max="2" step="0.05" value="1" placeholder="1.0" autocomplete="off">
</div>
<label class="btn-primary reader-import-btn" title="Import a PDF or text file" style="margin-left:auto">
<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 class="reader-backend-hint" id="reader-backend-hint" style="padding-left:14px; margin-top:8px"></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">
@ -143,5 +139,18 @@
<!-- ⑥ Document viewport ─────────────────────────────────────────── -->
<div class="reader-doc" id="reader-doc"></div>
</div><!-- /reader-main-view -->
<!-- ⑦ Audiobook Casting Panel ──────────────────────────────────── -->
<div id="reader-audiobook-panel" hidden></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>
</div><!-- /tab-reader -->

View File

@ -162,12 +162,14 @@
<div class="card">
<h2><span class="mdi mdi-account-group-outline"></span> Cast your characters</h2>
<p class="card-subtitle">Assign a TTS voice or your mic to each character. Add a speaking-style note per character so the voice matches their personality.</p>
<div class="reh-cast2-backend">
<div class="field" style="flex:1;min-width:160px">
<label>TTS backend</label>
<select id="reh-backend-select"><option value="">Checking…</option></select>
<div class="engine-setup-row" style="margin-bottom:12px; padding:10px 12px">
<div class="engine-setup-col no-border" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="reh-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="reh-fetch-voices-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
</div>
</div>
<button class="btn-secondary" id="reh-fetch-voices-btn" style="align-self:flex-end"><span class="mdi mdi-refresh"></span> Fetch voices</button>
</div>
<!-- Narrator is now a regular cast entry — see NARRATOR row in cast list -->
@ -178,27 +180,29 @@
<strong id="reh-autocast-title">Match characters with voices</strong>
<span class="note">— let an LLM cast every character automatically</span>
</div>
<div class="reh-autodesign-row">
<div class="field" style="flex:1;min-width:150px;margin:0">
<label for="reh-llm-url" style="font-size:11px">LLM endpoint</label>
<select id="reh-llm-url-sel" style="display:none" aria-hidden="true"></select>
<input type="text" id="reh-llm-url" placeholder="http://localhost:11434/v1" list="reh-llm-url-list" aria-label="LLM endpoint URL">
<datalist id="reh-llm-url-list"></datalist>
<div class="engine-setup-row" style="margin-top:12px; margin-bottom:12px; padding:10px 12px; background:var(--bg); border:1px solid var(--border)">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-brain"></span> Language Model <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;" title="Configure Language Model settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="reh-llm-url-sel" style="display:none" aria-hidden="true"></select>
<input type="text" id="reh-llm-url" placeholder="http://localhost:11434/v1" list="reh-llm-url-list" aria-label="LLM endpoint URL" title="OpenAI-compatible endpoint (Ollama, LM Studio, vLLM...)">
<datalist id="reh-llm-url-list"></datalist>
<button class="btn-secondary" id="reh-llm-refresh" title="List available models" aria-label="Refresh model list"><span class="mdi mdi-refresh"></span></button>
<select id="reh-llm-model" aria-label="LLM model"><option value="">— default —</option></select>
</div>
</div>
<div class="field" style="flex:1;min-width:150px;margin:0">
<label for="reh-llm-model" style="font-size:11px">Model <button class="reh-llm-refresh" id="reh-llm-refresh" title="List available models" aria-label="Refresh model list">⟳</button></label>
<select id="reh-llm-model" aria-label="LLM model"><option value="">— default —</option></select>
</div>
<div class="field" style="min-width:90px;margin:0">
<label for="reh-design-lang" style="font-size:11px">Language</label>
<select id="reh-design-lang" aria-label="Voice language">
<option value="English">English</option>
<option value="German">German</option>
<option value="Auto">Auto</option>
<option value="French">French</option>
<option value="Spanish">Spanish</option>
<option value="Italian">Italian</option>
</select>
<div class="engine-setup-col no-border" style="flex:0.3">
<label class="engine-setup-label"><span class="mdi mdi-earth"></span> Language</label>
<div class="engine-setup-controls">
<select id="reh-design-lang" aria-label="Voice language">
<option value="English">English</option>
<option value="German">German</option>
<option value="Auto">Auto</option>
<option value="French">French</option>
<option value="Spanish">Spanish</option>
<option value="Italian">Italian</option>
</select>
</div>
</div>
</div>
@ -287,7 +291,14 @@
<!-- Director's console -->
<div class="reh-console" id="reh-transport-bar">
<div class="reh-cast-strip" id="reh-cast-strip"></div>
<div class="reh-cast-row" id="reh-cast-row">
<button class="reh-cast-toggle" id="reh-cast-toggle" title="Show or hide the character list" aria-expanded="true">
<span class="mdi mdi-account-group"></span>
<span id="reh-cast-toggle-label">Characters</span>
<span class="mdi mdi-chevron-down reh-cast-chev"></span>
</button>
<div class="reh-cast-strip" id="reh-cast-strip"></div>
</div>
<div class="reh-console-transport">
<button class="reh-tb-btn" id="reh-tb-prev" title="Previous line"><span class="mdi mdi-skip-previous"></span></button>
@ -329,6 +340,10 @@
<button class="btn-secondary btn-sm" id="reh-tb-save" title="Save to library"><span class="mdi mdi-content-save-outline"></span> Save</button>
<button class="btn-secondary btn-sm" id="reh-tb-export" title="Export .reh"><span class="mdi mdi-export"></span></button>
<button class="btn-secondary btn-sm" id="reh-fountain-btn" title="Export as .fountain (plain text)"><span class="mdi mdi-file-document-outline"></span></button>
<span class="reh-fontsize-ctl" title="Adjust the play's text size">
<button class="btn-secondary btn-sm reh-fontsize-btn" id="reh-font-dec" title="Smaller text" aria-label="Decrease font size">A<span class="reh-fontsize-minus">−</span></button>
<button class="btn-secondary btn-sm reh-fontsize-btn" id="reh-font-inc" title="Larger text" aria-label="Increase font size">A<span class="reh-fontsize-plus">+</span></button>
</span>
<button class="btn-secondary btn-sm" id="reh-page-mode-btn" title="Switch between A4 pages / scroll / PDF pages"><span class="mdi mdi-file-document-outline"></span> A4 pages</button>
<button class="btn-secondary btn-sm" id="reh-train-open-btn" title="Train mode — practice your lines with cue playback &amp; speech recognition"><span class="mdi mdi-school-outline"></span> Train</button>
<button class="btn-secondary btn-sm" id="reh-exit-btn">Exit</button>

View File

@ -2,7 +2,7 @@
<span class="section-icon"><span class="mdi mdi-cog-outline"></span></span>
<div class="section-title">
<h2>Settings</h2>
<p>Configure backend URLs, API keys, voice folders, and playback preferences.</p>
<p>Configure general app behaviour, default parameters, and persistent storage settings.</p>
</div>
</div>
@ -24,172 +24,10 @@
</select>
<span class="s-hint">Switch between light and dark interface. Saved instantly.</span>
</div>
</div>
</div>
</div>
<!-- ── Connections ─────────────────────────────────────────── -->
<div class="s-settings-page" data-page="connections">
<div class="card">
<div class="s-page-head">
<div class="s-page-head-row">
<div>
<h3>Connections</h3>
<p>Service URLs for each backend. Change these first when setting up.</p>
</div>
<span class="s-stack-badge"><span class="mdi mdi-check-circle-outline"></span> Local stack</span>
</div>
</div>
<div class="s-group">
<div class="s-group-head">
<strong>TTS — Text to Speech</strong>
<span>Qwen3 engines: clone, design, custom, streaming &middot; Kokoro FastAPI</span>
</div>
<div class="settings-grid compact">
<div class="s-field">
<label>Voice Clone / Base URL <span class="s-label-note">(WAV voices)</span></label>
<input type="text" id="s-tts-url" placeholder="http://host.docker.internal:8020">
<span class="s-hint">Uploaded/cloned WAV voices. Expected: <code>POST /v1/audio/speech</code>.</span>
</div>
<div class="s-field">
<label>Voice Design URL <span class="s-label-note">(instruction voices)</span></label>
<input type="text" id="s-voice-design-url" placeholder="http://host.docker.internal:8021">
<span class="s-hint">Prompt-designed voices and <code>vd_...</code> virtual voices.</span>
</div>
<div class="s-field">
<label>CustomVoice URL</label>
<input type="text" id="s-customvoice-url" placeholder="http://host.docker.internal:8022">
<span class="s-hint">Style over configured speakers such as Ryan, Vivian, Serena.</span>
</div>
<div class="s-field">
<label>Streaming URL</label>
<input type="text" id="s-tts-stream-url" placeholder="http://host.docker.internal:8023">
<span class="s-hint">Progressive low-latency WAV playback.</span>
</div>
<div class="s-field">
<label>Kokoro FastAPI URL <span class="s-label-note">(82M &middot; CPU-friendly)</span></label>
<input type="text" id="s-kokoro-url" placeholder="http://host.docker.internal:8880/v1">
<span class="s-hint">OpenAI-compatible TTS. Built-in voices: af_bella, bf_emma, am_adam&hellip; ~300 MB RAM.</span>
</div>
<div class="s-field">
<label>VibeVoice URL <span class="s-label-note">(vibevoice-service)</span></label>
<input type="text" id="s-vibevoice-url" placeholder="http://192.168.178.8:8027">
<span class="s-hint">Simple text-in audio-out TTS. POST /tts with {"text":"..."}.</span>
</div>
<div class="s-field">
<label>XTTS v2 URL <span class="s-label-note">(xtts-api-server)</span></label>
<input type="text" id="s-xtts-url" placeholder="http://host.docker.internal:8024">
<span class="s-hint">XTTS v2 via daswer123/xtts-api-server. Supports <code>GET /speakers</code> and <code>POST /v1/audio/speech</code>.</span>
</div>
</div>
</div>
<div class="s-group">
<div class="s-group-head">
<strong>NVIDIA speech stack</strong>
<span>router, Magpie TTS, Parakeet ASR, clone NIM</span>
</div>
<div class="settings-grid compact">
<div class="s-field">
<label>NVIDIA router URL <span class="s-label-note">(TTS + STT)</span></label>
<input type="text" id="s-nvidia-router-url" placeholder="http://host.docker.internal:8090">
<span class="s-hint">OpenAI-compatible base URL for the NVIDIA speech router.</span>
</div>
<div class="s-field">
<label>NVIDIA Magpie TTS URL</label>
<input type="text" id="s-nvidia-tts-url" placeholder="http://host.docker.internal:8091">
<span class="s-hint">Direct Magpie endpoint. Fixed speakers, not WAV cloning.</span>
</div>
<div class="s-field">
<label>NVIDIA Parakeet ASR URL</label>
<input type="text" id="s-nvidia-asr-url" placeholder="http://host.docker.internal:8092">
<span class="s-hint">Direct Parakeet endpoint for transcription.</span>
</div>
<div class="s-field">
<label>NVIDIA Zeroshot NIM URL</label>
<input type="text" id="s-nvidia-zeroshot-url" placeholder="http://host.docker.internal:8093">
<span class="s-hint">Magpie Zeroshot clone endpoint with <code>audio_prompt</code>.</span>
</div>
<div class="s-field">
<label>NVIDIA Flow NIM URL</label>
<input type="text" id="s-nvidia-flow-url" placeholder="http://host.docker.internal:8094">
<span class="s-hint">Magpie Flow clone endpoint with <code>audio_prompt</code> and transcript.</span>
</div>
</div>
</div>
<div class="s-group">
<div class="s-group-head">
<strong>STT — Speech to Text</strong>
<span>Whisper, faster-whisper, whisper.cpp, Groq, Parakeet, NVIDIA router</span>
</div>
<div class="settings-grid stt-settings-grid">
<div class="s-field stt-url-field">
<label>Active STT URL</label>
<input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010">
<span class="s-hint">Default recognition endpoint. Expected: <code>POST /v1/audio/transcriptions</code>.</span>
<div class="btn-row settings-mini-actions">
<button class="btn-secondary" id="s-use-parakeet-asr" type="button">Use Parakeet</button>
<button class="btn-secondary" id="s-use-nvidia-router" type="button">Use router</button>
<button class="btn-secondary" id="s-use-faster-whisper" type="button">Use faster-whisper</button>
<button class="btn-secondary" id="s-use-whisper-cpp" type="button">Use whisper.cpp</button>
</div>
</div>
<div class="s-field">
<label>faster-whisper-server URL <span class="s-label-note">(CTranslate2 GPU)</span></label>
<input type="text" id="s-faster-whisper-url" placeholder="http://host.docker.internal:8000">
<span class="s-hint">GPU-accelerated Whisper via CTranslate2. ~70&times; RT &middot; 1.5 GB VRAM.</span>
</div>
<div class="s-field">
<label>whisper.cpp URL <span class="s-label-note">(CPU/CUDA)</span></label>
<input type="text" id="s-whisper-cpp-url" placeholder="http://host.docker.internal:8080">
<span class="s-hint">Lightweight C++ Whisper server. ~8&ndash;15&times; RT CPU &middot; ~1 GB RAM.</span>
</div>
<div class="s-field">
<label>Groq API key <span class="s-label-note">(Groq Whisper &amp; LLM)</span></label>
<div class="s-key-row">
<input type="password" id="s-groq-api-key" placeholder="gsk_&hellip;" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-groq-api-key" aria-label="Toggle API key visibility"><span class="mdi mdi-eye-outline"></span></button>
</div>
<span class="s-hint">Used for Groq Whisper STT and LLM. Free: 2 000 req/day.</span>
</div>
</div>
</div>
<div class="s-page-actions">
<button class="btn-primary s-save-btn">Save settings</button>
<button class="btn-secondary s-reload-btn">Reload settings</button>
</div>
</div>
</div>
<!-- ── Playback ─────────────────────────────────────────────── -->
<div class="s-settings-page" data-page="playback">
<div class="card">
<div class="s-page-head">
<h3>Playback</h3>
<p>Controls how previews play and how OpenAI-compatible requests are shaped.</p>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>TTS preview playback</label>
<select id="s-tts-stream-mode">
<option value="auto">Stream when available, then fall back</option>
<option value="streaming">Streaming only</option>
<option value="buffered">Buffered WAV only</option>
</select>
<span class="s-hint">Buffered keeps Save WAV available. Streaming starts sooner.</span>
</div>
<div class="s-field">
<label>OpenAI-compatible request style</label>
<select id="s-tts-backend">
<option value="openai">Qwen3-TTS / OpenAI</option>
<option value="localai">LocalAI (/tts)</option>
<option value="pocket">Pocket-TTS</option>
<option value="nvidia_magpie">NVIDIA Magpie / OpenAI</option>
</select>
<span class="s-hint">Controls payload shape for the normal TTS API URL.</span>
<div class="s-field" style="grid-column: 1 / -1">
<label>Default Seed Finder text</label>
<textarea id="s-seed-finder-text" placeholder="Leave empty for language-specific defaults..." style="min-height:60px; font-family:inherit; resize:vertical;"></textarea>
<span class="s-hint">Custom test sentence used when opening the Seed Finder.</span>
</div>
</div>
<div class="s-page-actions">
@ -199,140 +37,11 @@
</div>
</div>
<!-- ── Captures ────────────────────────────────────────────── -->
<div class="s-settings-page" data-page="captures">
<div class="card">
<div class="s-page-head">
<h3>Captures</h3>
<p>Default behaviour for STT transcription, LLM text refinement, and playback.</p>
</div>
<div class="s-group">
<div class="s-group-head">
<strong>Transcription</strong>
<span>Default language and preferred STT backend</span>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>Default language</label>
<select id="s-stt-language">
<option value="">Auto-detect</option>
<option value="en">English</option>
<option value="de">German</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="nl">Dutch</option>
<option value="ru">Russian</option>
<option value="ja">Japanese</option>
<option value="zh">Chinese</option>
<option value="hi">Hindi</option>
</select>
<span class="s-hint">Language hint passed to the STT backend. Auto-detect works well in most cases.</span>
</div>
<div class="s-field">
<label>Preferred STT backend</label>
<select id="s-stt-preferred-backend">
<option value="">Use active STT URL (from Connections)</option>
<option value="groq">Groq Whisper — cloud, fastest</option>
<option value="faster-whisper">faster-whisper-server — GPU local</option>
<option value="whisper-cpp">whisper.cpp — CPU/CUDA local</option>
<option value="parakeet">NVIDIA Parakeet — GPU local</option>
</select>
<span class="s-hint">Overrides the active STT URL for STT-TTS panel captures.</span>
</div>
</div>
</div>
<div class="s-group">
<div class="s-group-head">
<strong>LLM server</strong>
<span>Base URL for persona rewrite, text refinement, and other local LLM features</span>
</div>
<div class="settings-grid compact">
<div class="s-field">
<label>LLM base URL</label>
<input type="text" id="s-llm-url" placeholder="http://localhost:11434/v1">
<span class="s-hint">OpenAI-compatible endpoint. Used for persona rewrites, voice design, and auto-refine.</span>
</div>
</div>
</div>
<div class="s-group">
<div class="s-group-head">
<strong>LLM refinement defaults</strong>
<span>Automatic text cleanup after transcription</span>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>Auto-refine after transcription</label>
<select id="s-auto-refine">
<option value="off">Off — show raw transcription</option>
<option value="on">On — refine automatically</option>
</select>
<span class="s-hint">When on, the LLM refinement runs immediately after each capture.</span>
</div>
<div class="s-field">
<label>Refinement model</label>
<input type="text" id="s-refine-model" placeholder="e.g. llama3.2, qwen3:8b, gpt-4o-mini">
<span class="s-hint">Model name sent to the LLM URL. Leave empty to use the app's current default.</span>
</div>
</div>
<div class="s-toggle-grid">
<label class="s-toggle-row">
<input type="checkbox" id="s-refine-fillers">
<div>
<span class="s-toggle-title">Remove filler words</span>
<span class="s-hint">Strips um, uh, like, you know, sort of.</span>
</div>
</label>
<label class="s-toggle-row">
<input type="checkbox" id="s-refine-repetitions">
<div>
<span class="s-toggle-title">Remove repetitions</span>
<span class="s-hint">Collapses repeated words and phrases.</span>
</div>
</label>
<label class="s-toggle-row">
<input type="checkbox" id="s-refine-corrections">
<div>
<span class="s-toggle-title">Fix grammar &amp; spelling</span>
<span class="s-hint">Light corrections without changing meaning.</span>
</div>
</label>
<label class="s-toggle-row">
<input type="checkbox" id="s-refine-punctuation">
<div>
<span class="s-toggle-title">Fix punctuation</span>
<span class="s-hint">Adds missing commas, periods, and sentence casing.</span>
</div>
</label>
</div>
</div>
<div class="s-group">
<div class="s-group-head">
<strong>Default playback voice</strong>
<span>Pre-selected voice in the STT-TTS panel</span>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>Default voice</label>
<select id="s-captures-default-voice">
<option value="">None — select manually</option>
</select>
<span class="s-hint">Pre-selects this voice in the STT-TTS panel on load.</span>
</div>
</div>
</div>
<div class="s-page-actions">
<button class="btn-primary s-save-btn">Save settings</button>
<button class="btn-secondary s-reload-btn">Reload settings</button>
</div>
</div>
</div>
<!-- ── Payloads ─────────────────────────────────────────────── -->
<div class="s-settings-page" data-page="payloads">
@ -405,14 +114,36 @@
<div class="settings-grid">
<div class="s-field">
<label>Voice scan directory</label>
<input type="text" id="s-voices-scan-dir" placeholder="/voices">
<div class="s-key-row">
<input type="text" id="s-voices-scan-dir" placeholder="/voices">
<button type="button" class="btn-secondary s-browse-dir-btn" data-target="s-voices-scan-dir" title="Browse container folders"><span class="mdi mdi-folder-search-outline"></span></button>
</div>
<span class="s-hint">Contains <code>active_voices</code>, <code>hidden_voices</code>, sounds, and metadata.</span>
</div>
<div class="s-field">
<label>Active voices directory</label>
<input type="text" id="s-output-dir" placeholder="/voices/active_voices">
<div class="s-key-row">
<input type="text" id="s-output-dir" placeholder="/voices/active_voices">
<button type="button" class="btn-secondary s-browse-dir-btn" data-target="s-output-dir" title="Browse container folders"><span class="mdi mdi-folder-search-outline"></span></button>
</div>
<span class="s-hint">New cloned/exported voices are saved here.</span>
</div>
<div class="s-field">
<label>Seed Finder Sample Files</label>
<div class="s-key-row">
<input type="text" id="s-seed-finder-dir" placeholder="/voices/seed_finder">
<button type="button" class="btn-secondary s-browse-dir-btn" data-target="s-seed-finder-dir" title="Browse container folders"><span class="mdi mdi-folder-search-outline"></span></button>
</div>
<span class="s-hint">Directory containing generated seed finder samples.</span>
</div>
<div class="s-field">
<label>PT Files</label>
<div class="s-key-row">
<input type="text" id="s-pt-dir" placeholder="/voices/pt_files">
<button type="button" class="btn-secondary s-browse-dir-btn" data-target="s-pt-dir" title="Browse container folders"><span class="mdi mdi-folder-search-outline"></span></button>
</div>
<span class="s-hint">Directory for saved embedding models (.pt files).</span>
</div>
</div>
<div class="s-page-actions">
<button class="btn-primary s-save-btn">Save settings</button>

View File

@ -10,31 +10,29 @@
<div class="tab-content" id="tab-generation">
<!-- ① Voice selection ─────────────────────────────────────────── -->
<div class="card">
<h2><span class="mdi mdi-account-voice"></span> Voice &amp; backend</h2>
<div class="tryout-voice-row">
<div class="field" style="min-width:0">
<label>Backend</label>
<select id="tts-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
</div>
<div class="field" style="min-width:0">
<label>Voice</label>
<div class="tryout-voice-pick">
<select id="tts-voice-select" aria-label="Voice"><option value="">— fetch or pick a voice —</option></select>
<button class="btn-secondary btn-sm" id="fetch-tts-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
<div class="card" style="padding:14px">
<div class="engine-setup-row" style="margin-bottom:8px">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="tts-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="fetch-tts-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
<select id="tts-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
</div>
</div>
<div class="field" style="min-width:0">
<label>Playback</label>
<select id="preview-playback-mode" aria-label="Playback mode">
<option value="settings">Settings default</option>
<option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option>
<option value="buffered">Buffered WAV</option>
</select>
<div class="engine-setup-col no-border" style="flex:0.5">
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Playback</label>
<div class="engine-setup-controls">
<select id="preview-playback-mode" aria-label="Playback mode">
<option value="settings">Settings default</option>
<option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option>
<option value="buffered">Buffered WAV</option>
</select>
</div>
</div>
</div>
<div class="backend-help" id="tts-backend-help" aria-live="polite" style="margin-top:10px">
<div class="backend-help" id="tts-backend-help" aria-live="polite" style="padding-left:14px">
<strong>Checking available TTS backends…</strong>
</div>
<!-- Reference voice panel (shown when the selected voice matches the library) -->
@ -73,6 +71,10 @@
<span class="mdi mdi-alert-outline"></span> The selected backend has <strong>weak style support</strong> — this instruction may be ignored. Switch to a <em>style-aware</em> backend (CustomVoice, VoiceDesign) for full effect.
</span>
</div>
<div class="field" style="margin-top:10px;display:flex;align-items:center;gap:12px">
<label style="white-space:nowrap;font-weight:500">Native Speed <span class="note">(0.5–2)</span></label>
<input type="number" id="preview-native-speed" min="0.5" max="2" step="0.05" value="1" placeholder="1.0" autocomplete="off" style="width:80px">
</div>
<div class="btn-row" style="gap:20px;margin-top:8px">
<label class="chunk-toggle-label" title="Rewrite text through this voice's character persona before generating">
<input type="checkbox" id="preview-persona-toggle">
@ -226,15 +228,13 @@
<div class="card" id="refine-card">
<h2>Refine with LLM</h2>
<p class="card-subtitle">Clean up the transcription using a local language model — remove fillers, fix repetitions, and normalise punctuation.</p>
<div class="refine-config">
<div class="field" style="flex:1;min-width:180px">
<label>LLM URL</label>
<input type="text" id="refine-llm-url" placeholder="http://localhost:11434/v1" spellcheck="false">
<span class="note">OpenAI-compatible endpoint (Ollama, LM Studio, vLLM&hellip;)</span>
</div>
<div class="field" style="flex:0 0 160px">
<label>Model <span style="font-weight:400">(optional)</span></label>
<input type="text" id="refine-model" placeholder="auto-detect">
<div class="engine-setup-row" style="margin-bottom:12px; padding:10px 12px">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-brain"></span> Language Model <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;" title="Configure Language Model settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<input type="text" id="refine-llm-url" placeholder="http://localhost:11434/v1" spellcheck="false" title="OpenAI-compatible endpoint (Ollama, LM Studio, vLLM...)">
<input type="text" id="refine-model" placeholder="auto-detect model" style="max-width:140px">
</div>
</div>
</div>
<div class="refine-toggles">
@ -252,31 +252,30 @@
<div class="card">
<h2>Synthesize transcription</h2>
<p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Backend</label>
<select id="stt-tts-backend-select" aria-label="TTS backend"><option value="">Checking backends...</option></select>
</div>
<div class="backend-help" id="stt-tts-backend-help" aria-live="polite">
<strong>Checking available TTS backends...</strong>
</div>
<div class="field">
<label>Backend voice</label>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="stt-tts-fetch-voices-btn">Fetch voices</button>
<select id="stt-tts-voice-select" aria-label="Voice"><option value="">-- select after fetch --</option></select>
<div class="engine-setup-row" style="margin-bottom:12px; padding:10px 12px">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="stt-tts-backend-select" aria-label="TTS backend"><option value="">Checking backends...</option></select>
<button class="btn-secondary" id="stt-tts-fetch-voices-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
<select id="stt-tts-voice-select" aria-label="Voice"><option value="">-- select voices --</option></select>
</div>
</div>
<div class="field">
<label>Playback</label>
<select id="stt-tts-playback-mode" aria-label="Playback mode">
<option value="settings">Settings default</option>
<option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option>
<option value="buffered">Buffered WAV</option>
</select>
<div class="engine-setup-col no-border" style="flex:0.5">
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Playback</label>
<div class="engine-setup-controls">
<select id="stt-tts-playback-mode" aria-label="Playback mode">
<option value="settings">Settings default</option>
<option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option>
<option value="buffered">Buffered WAV</option>
</select>
</div>
</div>
</div>
<div class="backend-help" id="stt-tts-backend-help" aria-live="polite" style="margin-bottom:10px">
<strong>Checking available TTS backends...</strong>
</div>
<div class="field">
<label>Style Instruction <span style="font-weight:400">(optional)</span></label>
<input type="text" id="stt-tts-style-instruction" placeholder="Optional style/emotion instruction">

View File

@ -74,7 +74,7 @@
<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-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="voice-table-edit-btn" class="vl-sort-dir-btn" title="Toggle table edit mode" style="display:none"><span class="mdi mdi-pencil-box-outline"></span></button>
</div>
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
@ -101,7 +101,7 @@
</div>
<div class="vl-table-header" id="vl-table-header">
<div></div>
<div style="display:flex; justify-content:center; align-items:center;"><input type="checkbox" id="vl-select-all-header" title="Select all visible voices"></div>
<div>Img</div>
<div>Play</div>
<div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div>

View File

@ -42,6 +42,23 @@ button .mdi, a .mdi { font-size: 15px; }
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--sidebar-width: 220px;
}
[data-theme="dark"] {
--bg: #111827;
--surface: #1F2937;
--panel: #374151;
--border: #4B5563;
--text: #F9FAFB;
--subtext: #9CA3AF;
--placehld: #6B7280;
--accent: #3B82F6;
--green: #22C55E;
--red: #EF4444;
--yellow: #F59E0B;
--mauve: #A855F7;
--teal: #14B8A6;
--on-accent:#FFFFFF;
--shadow: 0 4px 12px rgba(0,0,0,0.5);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* stop iOS auto-inflating text */
body {
@ -125,6 +142,36 @@ body {
.nav-tree-item.nav-tree-tag { padding-left: 38px; }
.nav-tree-item.nav-tree-tag .mdi { font-size: 13px; opacity: .8; }
.ntc { font-size: 14px; color: var(--subtext); font-weight: 500; }
/* ── Collapsible group headers (Voice Actions · Speak · Setup) ───────────── */
.nav-group-head {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 10px 16px 4px; margin-top: 2px;
font-size: 10px; font-weight: 800; color: var(--subtext);
text-transform: uppercase; letter-spacing: 0.09em;
user-select: none;
}
.nav-group-head .nav-label { flex: 1; }
.nav-group-head:hover { color: var(--text); }
.nav-group-head .nav-chevron { font-size: 13px; }
/* ── Sub-headers nested inside a tree (Tags · Script Rehearsal · Library ·
Engines · Integrations · Settings) — look like items, carry a chevron ── */
.nav-tree .nav-tree-head.nav-subhead {
display: flex; align-items: center; gap: 7px; cursor: pointer;
padding: 5px 16px 5px 32px; font-size: 12.5px; color: var(--subtext);
transition: background .1s, color .1s;
}
.nav-tree .nav-tree-head.nav-subhead .nav-icon .mdi,
.nav-tree .nav-tree-head.nav-subhead > .mdi { font-size: 14px; flex-shrink: 0; }
.nav-tree .nav-tree-head.nav-subhead .nav-label { flex: 1; }
.nav-tree .nav-tree-head.nav-subhead:hover { background: var(--panel); color: var(--text); }
.nav-tree .nav-tree-head.nav-subhead.active { color: var(--accent); font-weight: 700; }
/* Nested trees indent one extra level per depth */
.nav-tree .nav-tree .nav-tree-item,
.nav-tree .nav-tree .nav-tree-head.nav-subhead { padding-left: 44px; }
.nav-tree .nav-tree .nav-tree .nav-tree-item { padding-left: 56px; }
.nav-tree-item.is-active .ntc { color: var(--accent); }
.nav-item {
@ -406,6 +453,32 @@ audio { width: 100%; }
.s-engines-page { display:none; flex-direction:column; gap:16px; }
.s-engines-page.is-active { display:flex; }
/* Combined Library — tabs + panels */
.lib-tabs { display:flex; gap:6px; margin:4px 0 16px; flex-wrap:wrap; }
.lib-tab {
display:inline-flex; align-items:center; gap:6px; padding:8px 14px;
border:1px solid var(--border); border-radius:8px; background:var(--card);
color:var(--subtext); font-size:13px; font-weight:500; cursor:pointer;
transition:background .14s ease, color .14s ease, border-color .14s ease;
}
.lib-tab:hover { color:var(--text); border-color:var(--accent); }
.lib-tab.is-active { background:var(--accent); border-color:var(--accent); color:#fff; }
.lib-panel { display:none; }
.lib-panel.is-active { display:block; }
.lib-empty { display:flex; flex-direction:column; align-items:center; gap:8px; padding:40px 0; color:var(--subtext); text-align:center; }
.lib-empty .mdi { font-size:42px; opacity:.4; }
.lib-empty p { margin:0; font-size:13px; line-height:1.5; }
.lib-empty-hint { opacity:.8; }
/* Character production tags */
.cl-card-tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:8px; }
.cl-tag-chip {
display:inline-flex; align-items:center; gap:3px; padding:2px 8px;
border-radius:10px; background:var(--chip-bg, rgba(127,127,127,.16));
color:var(--subtext); font-size:11px; font-weight:500;
}
.cl-tag-chip .mdi { font-size:12px; opacity:.7; }
/* Page card head */
.s-page-head { border-bottom:1px solid var(--border); padding-bottom:14px; }
.s-page-head h3 { font-size:16px; font-weight:700; margin:0 0 4px; color:var(--text); }
@ -485,9 +558,9 @@ audio { width: 100%; }
.s-log-filter { border:1px solid var(--border); background:transparent; color:var(--subtext); border-radius:999px; padding:3px 10px; font-size:12px; cursor:pointer; transition:all .15s; }
.s-log-filter:hover { border-color:var(--accent); color:var(--accent); }
.s-log-filter.is-active { background:var(--accent); border-color:var(--accent); color:#fff; }
.s-log-viewer { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; font-family:monospace; font-size:12px; line-height:1.6; max-height:440px; overflow-y:auto; display:flex; flex-direction:column; gap:1px; }
.s-log-viewer { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; font-family:monospace; font-size:12px; line-height:1.6; max-height:75vh; overflow-y:auto; display:flex; flex-direction:column; gap:1px; }
.s-log-empty { color:var(--subtext); font-family:inherit; text-align:center; padding:24px 0; }
.s-log-row { display:grid; grid-template-columns:130px 56px 1fr; gap:8px; align-items:baseline; padding:2px 0; border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent); }
.s-log-row { display:grid; grid-template-columns:max-content max-content 1fr; gap:12px; align-items:baseline; padding:2px 0; border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent); }
.s-log-row:last-child { border-bottom:none; }
.s-log-ts { color:var(--subtext); font-size:11px; white-space:nowrap; }
.s-log-level { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
@ -1248,6 +1321,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.vl-row.vl-bulk-selected { background: rgba(37,99,235,.06); }
/* ── Benchmark statuses ──────────────────────────────────────────────────── */
.vl-row.benchmarking-pending { background: rgba(245, 158, 11, 0.08); border-color: rgba(245, 158, 11, 0.4); transition: background 0.3s; }
.vl-row.benchmarking-active { background: rgba(37, 99, 235, 0.12); border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,0.2); transition: background 0.3s; }
.vl-row.benchmarking-done { background: rgba(34, 197, 94, 0.08); border-color: rgba(34, 197, 94, 0.4); transition: background 0.8s; }
[data-theme="dark"] .vl-row.benchmarking-pending { background: rgba(245, 158, 11, 0.15); border-color: rgba(245, 158, 11, 0.5); }
[data-theme="dark"] .vl-row.benchmarking-active { background: rgba(37, 99, 235, 0.25); box-shadow: 0 0 0 2px rgba(37,99,235,0.4); }
[data-theme="dark"] .vl-row.benchmarking-done { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.5); }
.voice-groups-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
padding: 7px 10px; border-bottom: 1px solid var(--border);
@ -1289,6 +1371,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.voices-list-pane .vl-row:last-child { border-bottom: none !important; }
.voices-list-pane .vl-row:hover { background: var(--panel) !important; }
.voices-list-pane .vl-row.vr-selected { background: rgba(37,99,235,.07) !important; border-left-color: var(--accent) !important; }
.voices-list-pane .vl-row.benchmarking-pending { background: rgba(245, 158, 11, 0.08) !important; border-left-color: rgba(245, 158, 11, 0.8) !important; }
.voices-list-pane .vl-row.benchmarking-active { background: rgba(37, 99, 235, 0.15) !important; border-left-color: var(--accent) !important; }
.voices-list-pane .vl-row.benchmarking-done { background: rgba(34, 197, 94, 0.08) !important; border-left-color: rgba(34, 197, 94, 0.8) !important; }
[data-theme="dark"] .voices-list-pane .vl-row.benchmarking-pending { background: rgba(245, 158, 11, 0.15) !important; }
[data-theme="dark"] .voices-list-pane .vl-row.benchmarking-active { background: rgba(37, 99, 235, 0.25) !important; }
[data-theme="dark"] .voices-list-pane .vl-row.benchmarking-done { background: rgba(34, 197, 94, 0.15) !important; }
.voices-list-pane .vl-row.vr-disabled { opacity: .5; }
/* Hide everything except the compact div */
.voices-list-pane .vl-row .vr-main-row,
@ -1332,12 +1420,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Separator dot between type and gender */
.vl-type-label + .vl-gender-label::before { content: '·'; color: var(--border); margin-right: 6px; font-weight: 400; }
/* Gender label — no background, just colored symbol + text */
.vl-gender-label { font-size: 10px; font-weight: 600; }
.vl-gender-label.g-f { color: #e91e8c; }
.vl-gender-label.g-m { color: #0ea5e9; }
.vl-gender-label.g-n { color: #8b5cf6; }
.vl-gender-letter {
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 4px;
font-weight: 800; font-size: 11px;
}
.vl-gender-letter.g-f { background: rgba(236, 72, 153, 0.15); color: #ec4899; }
.vl-gender-letter.g-m { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
.vl-gender-letter.g-n { background: rgba(156, 163, 175, 0.15); color: #9ca3af; }
.vl-flag { font-size: 14px; color: var(--subtext); }
.vl-meta .vr-rating {
font-size: 13px;
min-height: unset;
gap: 0;
}
/* Benchmark speed chip — compact list row + inspector header */
.vl-meta .vl-bench-chip { margin-left: 2px; }
.vl-bench-chip {
@ -1396,7 +1494,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.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-name,
.voices-workbench.table-view .vl-name-input { 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; }
@ -1411,6 +1510,31 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.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; }
.voices-workbench.table-edit-mode .vl-compact {
background: rgba(37,99,235,0.03);
border-bottom: 1px solid var(--border);
}
.vl-inline-edit {
background: var(--surface-light);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
outline: none;
transition: border-color 0.15s;
}
.vl-inline-edit:focus {
border-color: var(--accent);
}
.vl-inline-edit.vl-name-input {
font-size: 13px;
font-weight: 600;
width: 100%;
}
/* Synth mode panel (segmented control + preview input) */
.vl-synth-panel { background: var(--surface); }
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }
@ -1791,6 +1915,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* vr-main-row in inspector: always hidden (metadata shown in header grid) */
.inspector-body .vr-main-row { display: none !important; }
.inspector-avatar.drag-over {
opacity: 0.8;
outline: 3px dashed var(--accent);
outline-offset: -4px;
transition: all 0.2s;
}
.vl-avatar.drag-over {
opacity: 0.8;
outline: 2px dashed var(--accent);
outline-offset: -2px;
transition: all 0.2s;
}
/* ── AI Backends / LLMs section ──────────────────────────────────────────── */
/* Category pill tabs */
@ -1854,7 +1992,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Cloud service cards grid ─────────────────────────── */
.llm-service-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); gap: 14px;
margin-bottom: 8px;
}
@ -2041,7 +2179,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Connect / Disconnect — primary CTA, prominent blue ─────────────────── */
.llm-local-ping {
padding: 6px 16px; font-size: 12.5px; font-weight: 700; border-radius: 6px;
padding: 5px 12px; font-size: 11.5px; font-weight: 700; border-radius: 5px;
border: 1.5px solid #2563eb; background: #2563eb; color: #fff;
cursor: pointer; font-family: inherit;
display: inline-flex; align-items: center; gap: 5px;
@ -2063,11 +2201,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Docker action buttons (Stop / Start / Restart) ─────────────────────── */
.llm-use-btn {
font-size: 11px; padding: 4px 11px; border-radius: 5px;
border: 1px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
font-size: 11.5px; padding: 5px 10px; border-radius: 5px;
border: 1.5px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
color: #115E59; cursor: pointer; font-family: inherit; font-weight: 600;
transition: background .15s, border-color .15s;
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0;
}
.llm-use-btn:hover:not(:disabled) { background: rgba(13,148,136,.15); border-color: var(--teal); }
.llm-use-btn:active { background: rgba(13,148,136,.25); }
@ -2076,15 +2214,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── "Use as TTS / STT" — activate action, right-aligned, clearly distinct ─ */
.dc-use-btn {
margin-left: auto;
padding: 6px 16px; font-size: 12px; font-weight: 700; border-radius: 6px;
border: 1.5px solid rgba(13,148,136,.55); background: rgba(13,148,136,.12);
color: var(--teal); cursor: pointer; font-family: inherit;
padding: 5px 12px; font-size: 11.5px; font-weight: 700; border-radius: 5px;
border: 1.5px solid #2563eb; background: #2563eb;
color: #fff; cursor: pointer; font-family: inherit;
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; flex-shrink: 0;
transition: background .15s, border-color .15s, box-shadow .15s;
}
.dc-use-btn:hover:not(:disabled) {
background: rgba(13,148,136,.22); border-color: var(--teal);
box-shadow: 0 1px 6px rgba(13,148,136,.25);
background: #1d4ed8; border-color: #1d4ed8;
box-shadow: 0 2px 8px rgba(37,99,235,.35);
}
.dc-use-btn.active {
background: rgba(13,148,136,.22); border-color: var(--teal);
@ -2287,7 +2425,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.dc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
gap: 14px;
margin-bottom: 8px;
}
@ -2827,6 +2965,48 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Conversation Playground ─────────────────────────────────────────────── */
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
/* Conversation: stack STT · LLM · TTS · System prompt full-width, one per row */
.conv-config-bar .engine-setup-row { flex-direction: column; flex-wrap: nowrap; gap: 12px; }
.conv-config-bar .engine-setup-col {
flex: 1 1 auto; width: 100%; min-width: 0;
border-right: none; padding-right: 0;
border-bottom: 1px solid var(--border); padding-bottom: 12px;
}
.conv-config-bar .engine-setup-col:last-child { border-bottom: none; padding-bottom: 0; }
.conv-config-bar .conv-prompt-row { margin-top: 12px; }
.conv-config-bar .conv-system-textarea { width: 100%; }
/* ── Unified Engine Config Rows (used across tools) ───────────────── */
.engine-setup-row {
display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;
margin-bottom: 16px; padding: 12px 14px; background: rgba(0,0,0,0.02);
border: 1px solid var(--border); border-radius: 8px;
}
.engine-setup-col {
flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 6px;
border-right: 1px solid var(--border); padding-right: 16px;
}
.engine-setup-col:last-child, .engine-setup-col.no-border {
border-right: none; padding-right: 0;
}
.engine-setup-label {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--subtext);
display: flex; align-items: center; gap: 6px; margin-bottom: 2px;
}
.engine-setup-label .mdi { font-size: 14px; }
.engine-setup-link {
color: var(--subtext); opacity: 0.6; transition: opacity 0.15s, color 0.15s;
margin-left: auto; /* Push gear to the right */
}
.engine-setup-link:hover { opacity: 1; color: var(--accent); }
.engine-setup-controls {
display: flex; gap: 6px; align-items: center; width: 100%; flex-wrap: wrap;
}
.engine-setup-controls select, .engine-setup-controls input[type="text"] {
flex: 1; min-width: 0;
background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: var(--radius); padding: 8px 12px; font-size: 14px;
}
.conv-config-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
.conv-config-group { display: flex; flex-direction: column; gap: 4px; }
.conv-config-group--actions { margin-left: auto; }
@ -3423,48 +3603,53 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Proper screenplay A4 formatting ─────────────────────────────────── */
/* Use Courier New throughout — 1 page ≈ 1 minute of screen time */
/* Base play font size. Everything below is expressed in `em` so the whole
play scales together via --reh-stage-scale (the +A / -A control). */
.reh-a4-page {
font-family: 'Courier New', Courier, monospace !important;
font-size: 12pt;
font-size: calc(12pt * var(--reh-stage-scale, 1));
line-height: 1.6;
}
.reh-page-title {
font-family: 'Courier New', Courier, monospace;
font-size: 14pt;
font-size: 1.17em;
}
/* Scene headings: all-caps, bold, with spacing */
.reh-scene {
font-size: 12pt;
font-size: 1em;
letter-spacing: .04em;
}
/* Action text: normal weight, left-aligned */
.reh-action-block { font-size: 12pt; }
/* Narrator / action text and spoken dialog share ONE size (1em) so the play
reads evenly — no narrator-vs-dialogue size mismatch. */
.reh-action-block { font-size: 1em; }
.reh-action-text { font-size: 1em; }
/* Act headings: larger, extra top space */
.reh-act { font-size: 13pt; }
.reh-act { font-size: 1.08em; }
/* Transitions: right-aligned */
.reh-transition { font-size: 12pt; }
.reh-transition { font-size: 1em; }
/* Dialog block — screenplay format */
.reh-block-name {
font-size: 11pt;
font-size: 0.92em;
letter-spacing: .1em;
}
.reh-block-dialog {
font-size: 12pt;
font-size: 1em;
padding-left: 32px; /* ~1.5" dialog indent */
max-width: 440px; /* ~3.5" dialog column width */
}
/* Parenthetical directions: centered-ish */
.reh-direction {
font-size: 11pt;
font-size: 0.92em;
padding-left: 80px;
color: #444;
}
/* Script editor modal */
.reh-modal-overlay { position:fixed; inset:0; z-index:2000; background:rgba(0,0,0,.45); display:flex; align-items:center; justify-content:center; padding:16px; }
.reh-modal-overlay[hidden] { display: none !important; }
.reh-modal-box { background:var(--bg); border-radius:10px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; display:flex; flex-direction:column; }
.reh-modal-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid var(--border); font-weight:700; }
@ -3549,6 +3734,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Cast strip takes its own full row inside the console */
.reh-console .reh-cast-strip { flex-basis: 100%; order: -1; padding-bottom: 5px; border-bottom: 1px solid var(--border); margin-bottom: 0; }
/* Collapsible character list (Stage) */
.reh-console .reh-cast-row { flex-basis: 100%; order: -1; display: flex; align-items: flex-start; gap: 10px; padding-bottom: 5px; border-bottom: 1px solid var(--border); }
.reh-console .reh-cast-row .reh-cast-strip { flex-basis: auto; order: 0; flex: 1; padding-bottom: 0; border-bottom: none; min-width: 0; }
.reh-cast-toggle { flex-shrink: 0; display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 16px; background: var(--panel); color: var(--text); font-size: 11px; font-weight: 700; cursor: pointer; white-space: nowrap; height: 28px; }
.reh-cast-toggle:hover { background: var(--border); }
.reh-cast-toggle .reh-cast-chev { transition: transform .2s; font-size: 14px; }
.reh-cast-row.reh-cast-collapsed .reh-cast-strip { display: none; }
.reh-cast-row.reh-cast-collapsed .reh-cast-chev { transform: rotate(-90deg); }
/* Play font-size stepper (+A / -A) */
.reh-fontsize-ctl { display: inline-flex; gap: 2px; }
.reh-fontsize-btn { font-weight: 700; padding: 2px 7px; }
.reh-fontsize-btn .reh-fontsize-plus, .reh-fontsize-btn .reh-fontsize-minus { font-size: 9px; vertical-align: super; margin-left: 1px; }
/* Act heading on A4 page */
.reh-act {
font-family: 'Courier New', Courier, monospace; font-size: 13px; font-weight: 700;
@ -3580,15 +3779,16 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.reh-scene .mdi { font-size: 11px; opacity: .5; flex-shrink: 0; }
/* Action / description block */
/* Action / description block — 1em so narrator matches spoken dialog and
scales with the +A / -A control. */
.reh-action-block {
font-family: 'Courier New', Courier, monospace; font-size: 13px; line-height: 1.75;
font-family: 'Courier New', Courier, monospace; font-size: 1em; line-height: 1.75;
color: #2c2c3e; padding: 2px 0 10px; cursor: pointer;
}
/* Transition line */
.reh-transition {
font-family: 'Courier New', Courier, monospace; font-size: 11.5px; font-weight: 700;
font-family: 'Courier New', Courier, monospace; font-size: 0.75em; font-weight: 700;
text-align: right; text-transform: uppercase; letter-spacing: .1em;
color: #555; padding: 8px 0;
}
@ -3829,7 +4029,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.reh-paper {
width: min(794px, 100%); background: #fff; color: #1a1a2e;
padding: 56px 72px 40px; box-shadow: 0 4px 24px rgba(0,0,0,.18);
font-family: 'Courier New', Courier, monospace; font-size: 12pt; line-height: 1.6;
font-family: 'Courier New', Courier, monospace; font-size: calc(12pt * var(--reh-stage-scale, 1)); line-height: 1.6;
border-radius: 2px; position: relative; box-sizing: border-box;
min-height: min(1123px, calc((100vw - 32px) * 1.414));
}
@ -3936,21 +4136,34 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.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;
position: relative; padding: 18px 16px 16px 24px; border: 1px solid var(--border);
border-radius: 4px 14px 14px 4px; background: var(--surface); cursor: pointer;
transition: border-color .15s, transform .1s, box-shadow .15s;
display: flex; flex-direction: column; aspect-ratio: 1 / 1.35;
box-shadow: inset 5px 0 10px rgba(0,0,0,.03), -1px 0 0 rgba(0,0,0,.06), 0 3px 8px rgba(0,0,0,.05);
}
.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::before {
content: ''; position: absolute; top: 0; bottom: 0; left: 14px; width: 2px;
background: rgba(0,0,0,.04); border-left: 1px solid rgba(255,255,255,.5);
}
.reader-book:hover {
border-color: var(--accent);
box-shadow: inset 5px 0 10px rgba(0,0,0,.03), -1px 0 0 var(--accent), 0 8px 20px rgba(0,0,0,.08);
transform: translateY(-2px);
}
.reader-book-icon { font-size: 28px; color: var(--accent); line-height: 1; z-index: 1; }
.reader-book-title { font-weight: 800; font-size: 15px; margin-top: 10px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; line-height: 1.3; z-index: 1; }
.reader-book-meta { font-size: 11px; color: var(--subtext); margin-top: 6px; z-index: 1; }
.reader-book-bar { position: relative; height: 6px; border-radius: 4px; background: var(--panel); margin-top: auto; margin-bottom: 5px; overflow: hidden; border: 1px solid var(--border); z-index: 1; }
.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-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); z-index: 1; }
.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;
position: absolute; top: 8px; right: 8px; border: none; background: rgba(0,0,0,.06);
color: var(--subtext); border-radius: 6px; width: 28px; height: 28px; cursor: pointer; opacity: 0; transition: opacity .15s;
display: flex; align-items: center; justify-content: center; z-index: 5;
}
.reader-book-del .mdi { font-size: 15px; }
.reader-book:hover .reader-book-del { opacity: 1; }
.reader-book-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.1); }
@ -3977,40 +4190,141 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* 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;
width: min(880px, 96vw); height: 50vh; 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;
box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden; resize: both; min-height: 300px; min-width: 400px;
}
.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); cursor: default; }
.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg); cursor: move; }
.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-castpanel-bar { margin: 0; border-radius: 0; flex-shrink: 0; }
.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--border); background: var(--bg); flex-shrink: 0; z-index: 10; position: relative; }
.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 { width: auto; max-width: 320px; height: auto; min-height: auto; resize: none; }
.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-castpanel-inline {
height: calc(100vh - 140px);
min-height: 400px;
display: flex;
flex-direction: column;
}
.ab-cv-body { display: grid; grid-template-columns: 1fr 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; }
.ab-cv-feed-wrap { position: relative; min-height: 0; display: flex; flex-direction: column; }
.ab-cv-feed { flex: 1; min-height: 0; 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-jump-btn {
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 4px;
padding: 5px 14px; font-size: 12px; font-weight: 700;
background: var(--accent); color: #fff; border: none; border-radius: 20px;
cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.25); z-index: 5;
white-space: nowrap;
}
.ab-cv-jump-btn:hover { filter: brightness(1.1); }
.ab-cv-row {
display: block;
font-family: 'Courier New', Courier, monospace;
font-size: 13.5px;
line-height: 1.5;
padding: 8px 12px;
margin-bottom: 4px;
}
.ab-cv-row.is-narr { opacity: .85; }
.ab-cv-spk {
display: block;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 2px;
}
.ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-spk {
padding-left: 120px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-spk:hover {
background: rgba(37,99,235,0.08);
}
.ab-cv-row.is-assigning {
background: rgba(245,158,11,0.1) !important;
outline: 2px dashed rgba(245,158,11,0.5);
outline-offset: -2px;
border-radius: 6px;
}
.ab-cv-row.is-assigning .ab-cv-spk {
background: rgba(245,158,11,0.2);
color: #d97706 !important;
}
.ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-txt {
display: block;
padding-left: 60px;
max-width: 500px;
}
.ab-cv-row.is-narr .ab-cv-spk {
display: block;
font-weight: 600;
padding-left: 0;
font-size: 11px;
}
.ab-cv-row.is-processing {
font-family: inherit;
display: block;
padding: 4px 8px;
}
.ab-cv-llm-head { display: flex; align-items: center; gap: 6px; }
.ab-cv-row.is-processing .ab-cv-spk {
display: inline-block;
padding-left: 0;
letter-spacing: normal;
text-transform: none;
font-size: 12px;
margin: 0;
}
.ab-cv-expand-btn {
border: none; background: none; cursor: pointer;
color: var(--subtext); padding: 0 2px; line-height: 1;
font-size: 15px; border-radius: 4px;
}
.ab-cv-expand-btn:hover { color: var(--text); background: var(--panel); }
.ab-cv-llm-preview {
font-size: 12px; color: var(--subtext); font-style: italic;
padding: 2px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ab-cv-llm-full { margin-top: 6px; }
.ab-cv-llm-pre {
font-family: inherit; font-size: 12px; color: var(--text);
white-space: pre-wrap; word-break: break-word;
background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; margin: 0;
max-height: 260px; overflow-y: auto;
}
.ab-cv-pagemark {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 700; letter-spacing: .06em;
color: var(--subtext); text-transform: uppercase;
padding: 6px 4px 2px;
border-top: 1px dashed var(--border);
margin: 4px 0 2px;
}
.ab-cv-pagemark .mdi { font-size: 13px; opacity: .7; }
.ab-cv-txt { color: var(--text); white-space: pre-wrap; word-wrap: break-word; }
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); font-family: sans-serif; }
.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; min-height: 0; }
.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 { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 20px; cursor: pointer; transition: background 0.1s; }
.ab-chip:hover { background: var(--panel); border-color: var(--accent); }
.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); }
@ -4058,6 +4372,35 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.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); }
.cs-row-voice dt .mdi { color: var(--accent); }
.cs-row-voice dd { font-style: italic; }
/* ── Character library section ─────────────────────────────────────────── */
.cl-toolbar { margin-bottom: 14px; }
.cl-tool-field { display: flex; flex-direction: column; gap: 4px; }
.cl-tool-label { font-size: 10.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--subtext); }
.cl-tool-field select, .cl-tool-field input { padding: 7px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 13px; }
.cl-grid { display: flex; flex-direction: column; gap: 20px; }
.cl-book-group { }
.cl-book-head { display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: 14px; padding: 4px 0 10px; border-bottom: 2px solid var(--border); margin-bottom: 12px; }
.cl-book-head .mdi { color: var(--accent); }
.cl-book-count { margin-left: 6px; font-size: 11px; font-weight: 700; color: var(--subtext); border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; }
.cl-book-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; align-items: start; }
.cl-card-wrap { position: relative; }
.cl-card-tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; z-index: 2; opacity: 0; transition: opacity .15s; }
.cl-card-wrap:hover .cl-card-tools { opacity: 1; }
.cl-card-tools .btn-sm { font-size: 11px; padding: 2px 7px; }
.cl-empty { text-align: center; padding: 60px 20px; color: var(--subtext); }
.cl-empty p { margin: 8px 0 0; }
.cl-empty-hint { font-size: 12.5px; }
.cl-edit-box { max-width: 620px; width: 92vw; }
.cl-edit-body { display: flex; flex-direction: column; gap: 10px; max-height: 70vh; overflow-y: auto; padding: 4px 2px; }
.cl-edit-row { display: flex; flex-direction: column; gap: 3px; }
.cl-edit-row > span { font-size: 11px; font-weight: 700; color: var(--subtext); }
.cl-edit-row input, .cl-edit-row textarea, .cl-edit-row select { padding: 7px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; resize: vertical; }
/* ── Recast unknown: gap between non-contiguous passages ───────────────── */
.ab-cv-divider { text-align: center; color: var(--subtext); font-size: 20px; letter-spacing: .35em; line-height: 1; padding: 10px 0 6px; opacity: .55; user-select: none; }
.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; }
@ -4095,15 +4438,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.reader-leg-reading::before { background: rgba(37,99,235,.6); }
.reader-doc {
margin-top: 14px; max-height: 72vh; overflow-y: auto;
margin-top: 14px; max-height: 72vh; overflow-y: auto; overflow-x: 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;
padding: 16px; display: flex; flex-direction: column; 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-parsing { display: flex; align-items: center; gap: 10px; padding: 24px; color: var(--subtext); font-size: 13px; margin: 0 auto; }
.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;
position: relative; flex: 0 0 auto; margin: 0 auto;
box-shadow: 0 2px 10px rgba(0,0,0,.12); background: #fff;
}
.reader-canvas { display: block; }