Compare commits
No commits in common. "fb7dcb52f85ca540f41cc7e8995eaa34ae56c625" and "a325f53e62785a060086342b1f9f8d0c09c2a8e7" have entirely different histories.
fb7dcb52f8
...
a325f53e62
133
CHANGELOG.md
133
CHANGELOG.md
@ -9,136 +9,6 @@ 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
|
## [1.8.0] — 2026-06-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -523,8 +393,7 @@ Initial feature-complete release.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.1...HEAD
|
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...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.8.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.7.0...v1.8.0
|
||||||
[1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0
|
[1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0
|
||||||
[1.1.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.0.0...v1.1.0
|
[1.1.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.0.0...v1.1.0
|
||||||
|
|||||||
@ -35,10 +35,10 @@ _SETTINGS_KEYS = {
|
|||||||
"refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation",
|
"refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation",
|
||||||
"captures_default_voice",
|
"captures_default_voice",
|
||||||
"client_voice_bindings",
|
"client_voice_bindings",
|
||||||
"llm_url", "llm_model", "llm_api_key",
|
"llm_url", "llm_model",
|
||||||
# Browser-persistent UI state
|
# Browser-persistent UI state
|
||||||
|
"engine_local_urls", "engine_container_names", "custom_engine_cards",
|
||||||
"refine_llm_url", "conv_llm_url", "seed_finder_text",
|
"refine_llm_url", "conv_llm_url", "seed_finder_text",
|
||||||
"seed_finder_dir", "pt_dir", "audiobook_prompt",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── TTS stability defaults ────────────────────────────────────────────────────
|
# ── TTS stability defaults ────────────────────────────────────────────────────
|
||||||
@ -269,7 +269,6 @@ def _load_settings() -> dict:
|
|||||||
"captures_default_voice": "",
|
"captures_default_voice": "",
|
||||||
"client_voice_bindings": {},
|
"client_voice_bindings": {},
|
||||||
"llm_url": "http://localhost:11434/v1",
|
"llm_url": "http://localhost:11434/v1",
|
||||||
"llm_api_key": "",
|
|
||||||
"llm_model": "",
|
"llm_model": "",
|
||||||
"engine_local_urls": {},
|
"engine_local_urls": {},
|
||||||
"engine_container_names": {},
|
"engine_container_names": {},
|
||||||
@ -277,8 +276,6 @@ def _load_settings() -> dict:
|
|||||||
"refine_llm_url": "",
|
"refine_llm_url": "",
|
||||||
"conv_llm_url": "",
|
"conv_llm_url": "",
|
||||||
"seed_finder_text": "",
|
"seed_finder_text": "",
|
||||||
"seed_finder_dir": "",
|
|
||||||
"pt_dir": "",
|
|
||||||
}
|
}
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -18,7 +18,6 @@ services:
|
|||||||
- ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw
|
- ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw
|
||||||
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
|
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
|
||||||
- ./logs:/logs:rw # application logs
|
- ./logs:/logs:rw # application logs
|
||||||
- /home/sparky/Docker/tts-dgx-spark-faster-qwen3-tts/config:/tts-config:rw
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./static:/app/static:ro
|
- ./static:/app/static:ro
|
||||||
- ./server.py:/app/server.py:ro
|
- ./server.py:/app/server.py:ro
|
||||||
@ -32,8 +31,7 @@ services:
|
|||||||
- VOICES_DIR=/voices
|
- VOICES_DIR=/voices
|
||||||
- OUTPUT_DIR=/voices/active_voices
|
- OUTPUT_DIR=/voices/active_voices
|
||||||
# Containers that load WAV files and need a restart after voice changes
|
# Containers that load WAV files and need a restart after voice changes
|
||||||
- TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone
|
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
|
||||||
- TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming
|
|
||||||
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
||||||
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
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.
|
|
||||||
@ -33,7 +33,6 @@ services:
|
|||||||
- /home/sparky/Media/_Sounds/TTS_Voices:/voices:rw
|
- /home/sparky/Media/_Sounds/TTS_Voices:/voices:rw
|
||||||
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/config:/home/app/.config/tts-voice-creator:rw
|
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/config:/home/app/.config/tts-voice-creator:rw
|
||||||
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/logs:/logs:rw
|
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/logs:/logs:rw
|
||||||
- /home/sparky/Docker/tts-dgx-spark-faster-qwen3-tts/config:/tts-config:rw
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
# ── Hot-reload mounts (changes apply on container recreate, not restart) ──
|
# ── 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
|
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/server.py:/app/server.py:ro
|
||||||
@ -48,8 +47,8 @@ services:
|
|||||||
- VOICES_DIR=/voices
|
- VOICES_DIR=/voices
|
||||||
- OUTPUT_DIR=/voices/active_voices
|
- OUTPUT_DIR=/voices/active_voices
|
||||||
- TTS_STREAM_URL=http://host.docker.internal:8023
|
- TTS_STREAM_URL=http://host.docker.internal:8023
|
||||||
- TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone
|
- TTS_CONTAINER_NAME=faster-qwen3-tts-voiceclone
|
||||||
- TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming
|
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
|
||||||
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
||||||
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@ -64,7 +64,6 @@ def _sentence_split(buf: str) -> int:
|
|||||||
|
|
||||||
def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str:
|
def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str:
|
||||||
"""Inline synchronous persona rewrite; raises RuntimeError on failure."""
|
"""Inline synchronous persona rewrite; raises RuntimeError on failure."""
|
||||||
settings = _load_settings()
|
|
||||||
system = (
|
system = (
|
||||||
f"Rephrase the user's text as if spoken by this character: {persona}\n"
|
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. "
|
"Keep the same meaning but adapt vocabulary, tone, and style to the character. "
|
||||||
@ -83,12 +82,11 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str
|
|||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{llm_url.rstrip('/')}/chat/completions",
|
f"{llm_url.rstrip('/')}/chat/completions",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
|
headers={"Authorization": "Bearer no-key"},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
_msg = resp.json()["choices"][0]["message"]
|
result = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
|
||||||
if result.startswith('"') and result.endswith('"'):
|
if result.startswith('"') and result.endswith('"'):
|
||||||
result = result[1:-1].strip()
|
result = result[1:-1].strip()
|
||||||
return result
|
return result
|
||||||
@ -151,12 +149,11 @@ async def refine_text(request: Request):
|
|||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{llm_url}/chat/completions",
|
f"{llm_url}/chat/completions",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
|
headers={"Authorization": "Bearer no-key"},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
_msg = resp.json()["choices"][0]["message"]
|
refined = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
refined = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
|
||||||
if refined.startswith('"') and refined.endswith('"'):
|
if refined.startswith('"') and refined.endswith('"'):
|
||||||
refined = refined[1:-1].strip()
|
refined = refined[1:-1].strip()
|
||||||
return {"text": refined, "original": text}
|
return {"text": refined, "original": text}
|
||||||
@ -212,12 +209,11 @@ async def rewrite_with_persona(request: Request):
|
|||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{llm_url}/chat/completions",
|
f"{llm_url}/chat/completions",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
|
headers={"Authorization": "Bearer no-key"},
|
||||||
timeout=600,
|
timeout=60,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
_msg = resp.json()["choices"][0]["message"]
|
result = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
|
||||||
if result.startswith('"') and result.endswith('"'):
|
if result.startswith('"') and result.endswith('"'):
|
||||||
result = result[1:-1].strip()
|
result = result[1:-1].strip()
|
||||||
return {"text": result, "original": text, "persona": persona}
|
return {"text": result, "original": text, "persona": persona}
|
||||||
@ -295,12 +291,11 @@ async def analyze_characters(request: Request):
|
|||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{llm_url}/chat/completions",
|
f"{llm_url}/chat/completions",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"},
|
headers={"Authorization": "Bearer no-key"},
|
||||||
timeout=600,
|
timeout=180,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
_msg = resp.json()["choices"][0]["message"]
|
raw = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
|
||||||
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||||
for candidate in (content, _extract_json_block(content)):
|
for candidate in (content, _extract_json_block(content)):
|
||||||
if not candidate:
|
if not candidate:
|
||||||
@ -394,11 +389,10 @@ async def match_characters_voices(request: Request):
|
|||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{llm_url}/chat/completions", json=payload,
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
headers={"Authorization": "Bearer no-key"}, timeout=180,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
_msg = resp.json()["choices"][0]["message"]
|
raw = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
|
||||||
break
|
break
|
||||||
except requests.exceptions.ConnectionError as e:
|
except requests.exceptions.ConnectionError as e:
|
||||||
# llama-swap (and similar) often drop the first request while swapping/loading
|
# llama-swap (and similar) often drop the first request while swapping/loading
|
||||||
@ -466,9 +460,9 @@ async def character_sheets(request: Request):
|
|||||||
|
|
||||||
lang_hint = f" The text language is {language}; write the sheet in that language." if language else ""
|
lang_hint = f" The text language is {language}; write the sheet in that language." if language else ""
|
||||||
system = (
|
system = (
|
||||||
"You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich "
|
"You are an expert dramaturge and tabletop RPG game master building character sheets that FILL UP "
|
||||||
"character sheets passage by passage as a book is read. Extract playable, action-oriented sheets "
|
"as a book is read passage by passage. Read this passage and extract playable, action-oriented "
|
||||||
"an actor can use to immediately know how to PLAY the character.\n"
|
"character sheets — sheets an actor can use to immediately know how to PLAY the character.\n"
|
||||||
"PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any "
|
"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 "
|
"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 "
|
"blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty "
|
||||||
@ -478,37 +472,22 @@ async def character_sheets(request: Request):
|
|||||||
"For each character output these fields:\n"
|
"For each character output these fields:\n"
|
||||||
"- name, aliases\n"
|
"- name, aliases\n"
|
||||||
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
|
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
|
||||||
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
|
"- physical: age, build, vocal quality, posture. Use ONLY the metric system for any height/weight.\n"
|
||||||
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
|
|
||||||
"- alignment: strict moral code + the one line they will never cross\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"
|
"- 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"
|
"- 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"
|
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
|
||||||
"- secret: dark secret or fatal flaw\n"
|
"- secret: dark secret or fatal flaw\n"
|
||||||
"- conflict_style: fight, flight, or manipulate — how they act when cornered\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"
|
"- win_condition: the specific event that would make them feel they have won\n"
|
||||||
"- tier: 'main' or 'supporting'\n"
|
"- tier: 'main' or 'supporting'\n"
|
||||||
"- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, "
|
"- sources: array of {page, quote} — the page number from the nearest [p.N] marker and a short "
|
||||||
"a short verbatim quote that supports the sheet (1-5 entries), and a brief label (e.g. 'appearance', 'alignment'). "
|
"verbatim quote that supports the sheet (1-3 entries). Use null page if unknown.\n"
|
||||||
"Use null page if unknown.\n"
|
|
||||||
"Reuse the EXACT names from the known-characters list for returning characters. "
|
"Reuse the EXACT names from the known-characters list for returning characters. "
|
||||||
"Respond with STRICT JSON only:\n"
|
"Respond with STRICT JSON only:\n"
|
||||||
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","clothing":"",'
|
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","alignment":"",'
|
||||||
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
|
'"attribute_high":"","attribute_low":"","skills":"","inventory":[],"secret":"",'
|
||||||
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
|
'"conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":""}]}]}\n/no-think'
|
||||||
'"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 = (
|
user = (
|
||||||
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
||||||
@ -521,18 +500,17 @@ async def character_sheets(request: Request):
|
|||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
],
|
],
|
||||||
"temperature": 0.4,
|
"temperature": 0.4,
|
||||||
"max_tokens": 4096,
|
"max_tokens": 3500,
|
||||||
}
|
}
|
||||||
if model:
|
if model:
|
||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{llm_url}/chat/completions", json=payload,
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
headers={"Authorization": "Bearer no-key"}, timeout=240,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
_msg = resp.json()["choices"][0]["message"]
|
raw = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
|
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
|
||||||
|
|
||||||
@ -561,110 +539,14 @@ async def character_sheets(request: Request):
|
|||||||
elif not isinstance(inv, list):
|
elif not isinstance(inv, list):
|
||||||
inv = []
|
inv = []
|
||||||
src = s.get("sources") if isinstance(s.get("sources"), list) else []
|
src = s.get("sources") if isinstance(s.get("sources"), list) else []
|
||||||
# Clamp moral alignment score
|
s.update({"name": name, "inventory": inv[:3],
|
||||||
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",
|
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
|
||||||
"sources": src[:5],
|
"sources": src[:3]})
|
||||||
"moral_alignment_score": mas,
|
|
||||||
"arc_direction": arc,
|
|
||||||
})
|
|
||||||
clean.append(s)
|
clean.append(s)
|
||||||
names.append(name)
|
names.append(name)
|
||||||
return {"sheets": clean, "characters": names}
|
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")
|
@router.post("/api/attribute-dialogue")
|
||||||
async def attribute_dialogue(request: Request):
|
async def attribute_dialogue(request: Request):
|
||||||
"""Split a prose passage into attributed segments for a multi-voice audiobook.
|
"""Split a prose passage into attributed segments for a multi-voice audiobook.
|
||||||
@ -686,17 +568,14 @@ async def attribute_dialogue(request: Request):
|
|||||||
raise HTTPException(400, "No text provided")
|
raise HTTPException(400, "No text provided")
|
||||||
|
|
||||||
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
|
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
|
||||||
|
system = (
|
||||||
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. "
|
"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"
|
"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. "
|
"- 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"
|
"Use 'Unknown' ONLY as an absolute last resort.\n"
|
||||||
"- type: 'narration' or 'dialogue'\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"
|
"- 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"
|
"- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration\n"
|
||||||
f"{lang_hint}\n"
|
f"{lang_hint}\n"
|
||||||
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\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 „...“; "
|
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
|
||||||
@ -718,23 +597,13 @@ async def attribute_dialogue(request: Request):
|
|||||||
"- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts "
|
"- 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"
|
"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"
|
"- 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."
|
"Respond with STRICT JSON only:\n"
|
||||||
)
|
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}\n/no-think'
|
||||||
else:
|
|
||||||
if lang_hint:
|
|
||||||
base_prompt += f"\n\n{lang_hint}"
|
|
||||||
|
|
||||||
system = (
|
|
||||||
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 = (
|
user = (
|
||||||
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
("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 "")
|
+ ("Recent dialogue (the immediately preceding lines — continue the same conversation/turn-taking):\n" + recent + "\n\n" if recent else "")
|
||||||
+ "Passage to attribute:\n" + text + "\n\n"
|
+ "Passage:\n" + text
|
||||||
+ "Return ONLY valid JSON. Do not include markdown blocks or any other text."
|
|
||||||
)
|
)
|
||||||
payload: dict = {
|
payload: dict = {
|
||||||
"messages": [
|
"messages": [
|
||||||
@ -742,31 +611,23 @@ async def attribute_dialogue(request: Request):
|
|||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
],
|
],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"max_tokens": min(8192, max(2048, len(text) + 1000)),
|
"max_tokens": min(4096, max(700, len(text) // 2 + 800)),
|
||||||
}
|
}
|
||||||
if model:
|
if model:
|
||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{llm_url}/chat/completions", json=payload,
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
headers={"Authorization": "Bearer no-key"}, timeout=180,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
msg = resp.json()["choices"][0]["message"]
|
raw = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
raw = (msg.get("content") or msg.get("reasoning_content") or "").strip()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"LLM attribution failed: {e}")
|
raise HTTPException(502, f"LLM attribution failed: {e}")
|
||||||
|
|
||||||
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||||
segments = []
|
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:
|
if not cand:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@ -776,13 +637,6 @@ async def attribute_dialogue(request: Request):
|
|||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
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
|
# Normalise + collect speaker roster
|
||||||
clean, chars = [], []
|
clean, chars = [], []
|
||||||
for seg in segments:
|
for seg in segments:
|
||||||
@ -1252,23 +1106,14 @@ async def mcp_sse(request: Request):
|
|||||||
# ── Conversation playground ───────────────────────────────────────────────────
|
# ── Conversation playground ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/conversation/llm-models")
|
@router.get("/api/conversation/llm-models")
|
||||||
async def conversation_llm_models(url: str = "", api_key: str = ""):
|
async def conversation_llm_models(url: str = ""):
|
||||||
"""List models from a local LLM endpoint (Ollama / vLLM / LM Studio)."""
|
"""List models from a local LLM endpoint (Ollama / vLLM / LM Studio)."""
|
||||||
from core.validation import _validate_http_url
|
from core.validation import _validate_http_url
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
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:
|
try:
|
||||||
base = _validate_http_url(base, allow_private=True)
|
base = _validate_http_url(base, allow_private=True)
|
||||||
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
|
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": "Bearer no-key"})
|
||||||
|
|
||||||
# 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:
|
if r.status_code == 200:
|
||||||
payload = r.json()
|
payload = r.json()
|
||||||
data = payload.get("data", []) if isinstance(payload, dict) else []
|
data = payload.get("data", []) if isinstance(payload, dict) else []
|
||||||
@ -1277,10 +1122,9 @@ async def conversation_llm_models(url: str = "", api_key: str = ""):
|
|||||||
for item in data if item
|
for item in data if item
|
||||||
]
|
]
|
||||||
return {"models": models, "url": base}
|
return {"models": models, "url": base}
|
||||||
else:
|
except Exception:
|
||||||
return {"models": [], "url": base, "error": r.text}
|
pass
|
||||||
except Exception as e:
|
return {"models": [], "url": base}
|
||||||
return {"models": [], "url": base, "error": str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_tts_task(
|
def _make_tts_task(
|
||||||
@ -1398,7 +1242,7 @@ async def conversation_turn(
|
|||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{eff_llm_url}/chat/completions", json=llm_payload,
|
f"{eff_llm_url}/chat/completions", json=llm_payload,
|
||||||
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, stream=True, timeout=120,
|
headers={"Authorization": "Bearer no-key"}, stream=True, timeout=120,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
for raw_line in resp.iter_lines():
|
for raw_line in resp.iter_lines():
|
||||||
|
|||||||
@ -19,24 +19,18 @@ _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": "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-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": "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": "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": "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": "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": "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-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-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-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-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": "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": "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": "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": "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": "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-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-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": "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": "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": "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": "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."},
|
{"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."},
|
||||||
@ -116,15 +110,14 @@ async def restart_local_container(name: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/probe-url")
|
@router.get("/api/probe-url")
|
||||||
async def probe_url(url: str, type: str = "", api_key: str = ""):
|
async def probe_url(url: str, type: str = ""):
|
||||||
"""Server-side API probe — checks service-specific endpoints and validates JSON responses."""
|
"""Server-side API probe — checks service-specific endpoints and validates JSON responses."""
|
||||||
base = _normalize_service_url(url).rstrip("/")
|
base = _normalize_service_url(url).rstrip("/")
|
||||||
for _suffix in ("/v1", "/api/v1"):
|
for _suffix in ("/v1", "/api/v1"):
|
||||||
if base.endswith(_suffix):
|
if base.endswith(_suffix):
|
||||||
base = base[: -len(_suffix)]
|
base = base[: -len(_suffix)]
|
||||||
break
|
break
|
||||||
key_to_use = api_key if api_key else "sk-dummy-key"
|
hdrs = {"User-Agent": "TTS-Voice-Creator/probe"}
|
||||||
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"}
|
|
||||||
|
|
||||||
if type == "llm":
|
if type == "llm":
|
||||||
checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)]
|
checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)]
|
||||||
@ -163,7 +156,7 @@ async def probe_url(url: str, type: str = "", api_key: str = ""):
|
|||||||
return {"ok": False, "error": last_err}
|
return {"ok": False, "error": last_err}
|
||||||
|
|
||||||
@router.get("/api/engine-models")
|
@router.get("/api/engine-models")
|
||||||
async def engine_models(url: str, type: str = "", api_key: str = ""):
|
async def engine_models(url: str, type: str = ""):
|
||||||
"""Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser."""
|
"""Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser."""
|
||||||
base = _normalize_service_url(url).rstrip("/")
|
base = _normalize_service_url(url).rstrip("/")
|
||||||
if not base:
|
if not base:
|
||||||
@ -178,10 +171,9 @@ async def engine_models(url: str, type: str = "", api_key: str = ""):
|
|||||||
last_err = "No response"
|
last_err = "No response"
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
models: list[str] = []
|
models: list[str] = []
|
||||||
key_to_use = api_key if api_key else "sk-dummy-key"
|
|
||||||
for endpoint in candidates:
|
for endpoint in candidates:
|
||||||
try:
|
try:
|
||||||
r = requests.get(endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"})
|
r = requests.get(endpoint, timeout=6, headers={"Authorization": "Bearer no-key", "User-Agent": "TTS-Voice-Creator/models"})
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
last_err = f"HTTP {r.status_code} from {endpoint}"
|
last_err = f"HTTP {r.status_code} from {endpoint}"
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -641,22 +641,8 @@ async def rename_voice(request: Request):
|
|||||||
if backup and backup.exists():
|
if backup and backup.exists():
|
||||||
if backup.resolve() != new_backup.resolve():
|
if backup.resolve() != new_backup.resolve():
|
||||||
backup.rename(new_backup)
|
backup.rename(new_backup)
|
||||||
|
|
||||||
# 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)
|
new_meta = _load_meta(new_audio)
|
||||||
if backup and backup.exists():
|
|
||||||
new_meta["original_backup"] = str(new_backup)
|
new_meta["original_backup"] = str(new_backup)
|
||||||
new_meta["needs_tts_restart"] = True
|
|
||||||
_save_meta(new_audio, new_meta)
|
_save_meta(new_audio, new_meta)
|
||||||
return {"new_id": new_id, "path": str(new_audio), "file_type": new_audio.suffix.lower().lstrip(".")}
|
return {"new_id": new_id, "path": str(new_audio), "file_type": new_audio.suffix.lower().lstrip(".")}
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,6 @@ async def reader_list_docs():
|
|||||||
continue
|
continue
|
||||||
meta["id"] = d.name
|
meta["id"] = d.name
|
||||||
meta["synthCount"] = len(_audio_indices(d))
|
meta["synthCount"] = len(_audio_indices(d))
|
||||||
meta["hasCover"] = (d / "cover.jpg").is_file()
|
|
||||||
docs.append(meta)
|
docs.append(meta)
|
||||||
docs.sort(key=lambda m: m.get("updated") or "", reverse=True)
|
docs.sort(key=lambda m: m.get("updated") or "", reverse=True)
|
||||||
return {"docs": docs}
|
return {"docs": docs}
|
||||||
@ -171,21 +170,3 @@ async def reader_get_audio(doc_id: str, idx: int):
|
|||||||
if not f.is_file():
|
if not f.is_file():
|
||||||
raise HTTPException(404, "Audio not found")
|
raise HTTPException(404, "Audio not found")
|
||||||
return FileResponse(str(f), media_type="audio/mpeg")
|
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"})
|
|
||||||
|
|||||||
@ -160,7 +160,7 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"{base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
|
r = requests.get(f"{base}/v1/models", timeout=3)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
ok = True
|
ok = True
|
||||||
payload = r.json()
|
payload = r.json()
|
||||||
|
|||||||
@ -470,14 +470,6 @@ async def tts_preview(request: Request):
|
|||||||
_overrides[_k] = int(_v) if _k == "seed" else float(_v)
|
_overrides[_k] = int(_v) if _k == "seed" else float(_v)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
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:
|
if _overrides:
|
||||||
from core.config import _tts_extra_params
|
from core.config import _tts_extra_params
|
||||||
settings = dict(settings)
|
settings = dict(settings)
|
||||||
|
|||||||
@ -23,7 +23,6 @@ const MAIN = [
|
|||||||
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
||||||
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
||||||
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||||
'characters-library', 'library',
|
|
||||||
].map(n => join(jsDir, n + '.js'));
|
].map(n => join(jsDir, n + '.js'));
|
||||||
|
|
||||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
||||||
|
|||||||
15
server.py
15
server.py
@ -5,7 +5,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
@ -19,19 +18,7 @@ logger = logging.getLogger("uvicorn.error")
|
|||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@asynccontextmanager
|
app = FastAPI(title="TTS Voice Creator - Clone and Design")
|
||||||
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 ──────────────────────────────────────────────────────
|
# ── Response compression ──────────────────────────────────────────────────────
|
||||||
# Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the
|
# Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.10.1">
|
<link rel="stylesheet" href="/static/style.css?v=1.8.0-3">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -119,69 +119,40 @@
|
|||||||
<div class="nav-group-label">Voices</div>
|
<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')">
|
<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-icon"><span class="mdi mdi-account-voice"></span></span>
|
||||||
<span class="nav-label">Library</span>
|
<span class="nav-label">My Voices</span>
|
||||||
<span class="nav-badge" id="nav-voices-count"></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>
|
<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>
|
||||||
<div class="nav-tree open" id="nav-voices-tree">
|
<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 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="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="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="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="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> Tools</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-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>
|
||||||
<div class="nav-tree" id="nav-tags-tree"></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>
|
<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-group-head" id="nav-actions-head" onclick="toggleNavTree('nav-actions-tree','nav-actions-chevron')">
|
<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>
|
||||||
<span class="nav-label">Voice Actions</span>
|
<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>
|
||||||
<span class="nav-chevron" id="nav-actions-chevron"><span class="mdi mdi-chevron-down"></span></span>
|
<div class="nav-item nav-tree-head" data-nav-section="s-rehearser" id="nav-rehearser-head" onclick="navTo('s-rehearser')">
|
||||||
</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-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-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-icon"><span class="mdi mdi-theater"></span></span>
|
||||||
<span class="nav-label">Script Rehearsal</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>
|
<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>
|
||||||
<div class="nav-tree" id="nav-rehearser-tree">
|
<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 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" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
|
||||||
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
|
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-tree-head nav-subhead" data-nav-section="s-library" id="nav-library-head" onclick="navTo('s-library')">
|
<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>
|
||||||
<span class="nav-icon"><span class="mdi mdi-bookshelf"></span></span>
|
<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>
|
||||||
<span class="nav-label">Library</span>
|
<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>
|
||||||
<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-group-head" id="nav-setup-head" onclick="toggleNavTree('nav-setup-tree','nav-setup-chevron')">
|
<div class="nav-group-label" style="margin-top:6px">Setup</div>
|
||||||
<span class="nav-label">Setup</span>
|
<div class="nav-item nav-tree-head" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')">
|
||||||
<span class="nav-chevron" id="nav-setup-chevron"><span class="mdi mdi-chevron-down"></span></span>
|
|
||||||
</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-icon"><span class="mdi mdi-brain"></span></span>
|
||||||
<span class="nav-label">Engines</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>
|
<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>
|
||||||
@ -189,24 +160,20 @@
|
|||||||
<div class="nav-tree" id="nav-engines-tree">
|
<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 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="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
|
||||||
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-account-voice"></span> Text to Speech</div>
|
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-text-to-speech"></span> Text to Speech</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-tree-head nav-subhead" id="nav-integrations-head" onclick="toggleNavTree('nav-integrations-tree','nav-integrations-chevron')">
|
<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>
|
||||||
<span class="nav-icon"><span class="mdi mdi-transit-connection-variant"></span></span>
|
<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>
|
||||||
<span class="nav-label">Integrations</span>
|
<div class="nav-item nav-tree-head" data-nav-section="s-settings" id="nav-settings-head" onclick="navTo('s-settings')">
|
||||||
<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-icon"><span class="mdi mdi-cog-outline"></span></span>
|
||||||
<span class="nav-label">Settings</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>
|
<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>
|
||||||
<div class="nav-tree" id="nav-settings-tree">
|
<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="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="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="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="apikeys" onclick="navSettingsCat('apikeys')"><span class="mdi mdi-key-outline"></span> API Keys</div>
|
||||||
@ -214,7 +181,6 @@
|
|||||||
<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="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-item" data-settings-cat="about" onclick="navSettingsCat('about')"><span class="mdi mdi-information-outline"></span> About</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@ -297,7 +263,6 @@
|
|||||||
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
||||||
<section class="page-section" id="s-reader" style="display:none"></section>
|
<section class="page-section" id="s-reader" style="display:none"></section>
|
||||||
<section class="page-section" id="s-conversation" style="display:none"></section>
|
<section class="page-section" id="s-conversation" style="display:none"></section>
|
||||||
<section class="page-section" id="s-library" style="display:none"></section>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div><!-- /app-shell -->
|
</div><!-- /app-shell -->
|
||||||
@ -305,34 +270,12 @@
|
|||||||
<div id="toast" aria-live="polite" aria-atomic="true"></div>
|
<div id="toast" aria-live="polite" aria-atomic="true"></div>
|
||||||
<div id="status-bar" role="status" aria-live="polite" aria-atomic="true">Loading…</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) -->
|
<!-- WaveSurfer — self-hosted, loaded synchronously (voice waveform requires it) -->
|
||||||
<script src="/static/vendor/wavesurfer.min.js"></script>
|
<script src="/static/vendor/wavesurfer.min.js"></script>
|
||||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.10.1"></script>
|
<script src="/static/loader.js?v=1.8.0-2"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -57,11 +57,10 @@
|
|||||||
return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
|
return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeUrl(rawUrl, type = '', apiKey = '') {
|
async function probeUrl(rawUrl, type = '') {
|
||||||
const url = normalizeProbeUrl(rawUrl);
|
const url = normalizeProbeUrl(rawUrl);
|
||||||
const params = { url };
|
const params = { url };
|
||||||
if (type) params.type = type;
|
if (type) params.type = type;
|
||||||
if (apiKey) params.api_key = apiKey;
|
|
||||||
const r = await fetch('/api/probe-url?' + new URLSearchParams(params));
|
const r = await fetch('/api/probe-url?' + new URLSearchParams(params));
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@ -220,31 +219,14 @@
|
|||||||
statusEl.className = 'llm-active-status' + (cls ? ' ' + cls : '');
|
statusEl.className = 'llm-active-status' + (cls ? ' ' + cls : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchModels(url, apiKey) {
|
async function fetchModels(url) {
|
||||||
url = (url || urlInp.value.trim());
|
url = (url || urlInp.value.trim());
|
||||||
if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; }
|
if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; }
|
||||||
const want = (_appSettings && _appSettings.llm_model) || '';
|
const want = (_appSettings && _appSettings.llm_model) || '';
|
||||||
setStatus('Loading models…');
|
setStatus('Loading models…');
|
||||||
if (refreshBtn) refreshBtn.disabled = true;
|
if (refreshBtn) refreshBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
const d = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url)).then(r => r.json());
|
||||||
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 || [];
|
const models = d.models || [];
|
||||||
modelSel.innerHTML = models.length
|
modelSel.innerHTML = models.length
|
||||||
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
|
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
|
||||||
@ -273,40 +255,6 @@
|
|||||||
// Also react when user picks a datalist suggestion (fires 'change')
|
// Also react when user picks a datalist suggestion (fires 'change')
|
||||||
urlInp.addEventListener('change', commitUrl);
|
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', () => {
|
modelSel.addEventListener('change', () => {
|
||||||
applyAndSaveSettings({ llm_model: modelSel.value });
|
applyAndSaveSettings({ llm_model: modelSel.value });
|
||||||
if (_appSettings) _appSettings.llm_model = modelSel.value;
|
if (_appSettings) _appSettings.llm_model = modelSel.value;
|
||||||
@ -316,10 +264,9 @@
|
|||||||
refreshBtn?.addEventListener('click', () => fetchModels());
|
refreshBtn?.addEventListener('click', () => fetchModels());
|
||||||
|
|
||||||
// Exposed so the "Use as LLM" card buttons can sync this panel
|
// Exposed so the "Use as LLM" card buttons can sync this panel
|
||||||
window.syncActiveLlmPanel = function (url, apiKey) {
|
window.syncActiveLlmPanel = function (url) {
|
||||||
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
|
|
||||||
urlInp.value = url || '';
|
urlInp.value = url || '';
|
||||||
fetchModels(url, apiKey);
|
fetchModels(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial state — restore saved endpoint + fetch its models
|
// Initial state — restore saved endpoint + fetch its models
|
||||||
@ -333,13 +280,8 @@
|
|||||||
else { _appSettings.llm_url = s.llm_url || _appSettings.llm_url; _appSettings.llm_model = s.llm_model || _appSettings.llm_model; }
|
else { _appSettings.llm_url = s.llm_url || _appSettings.llm_url; _appSettings.llm_model = s.llm_model || _appSettings.llm_model; }
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait a tick for docker cards to render so options are available
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
|
|
||||||
urlInp.value = url || '';
|
urlInp.value = url || '';
|
||||||
if (url) fetchModels(url);
|
if (url) fetchModels(url);
|
||||||
}, 100);
|
|
||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,23 +2,16 @@
|
|||||||
//
|
//
|
||||||
// Actor-facing, RPG-style character sheets extracted by the user's LLM from a
|
// 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
|
// document (Read Aloud) or a script (Script Rehearser). Each claim cites a
|
||||||
// source (page + short quote + category hint). Shared by both sections via one overlay.
|
// source (page + short quote). Shared by both sections via one overlay.
|
||||||
|
//
|
||||||
|
// Reuses: /api/character-sheets (LLM), readerState/readerScopeIndices (reader.js),
|
||||||
|
// rehState/stripMarkdown/rehDefaultLlmUrl (rehearser.js), splitTextIntoChunks
|
||||||
|
// (generation.js), $ / escHtml / toast (utils.js).
|
||||||
|
|
||||||
const CS_CHUNK_CHARS = 4000;
|
const CS_CHUNK_CHARS = 4000;
|
||||||
const _cs = { running: false, cancel: false, cache: {} };
|
const _cs = { running: false, cancel: false, cache: {} };
|
||||||
|
|
||||||
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
|
function csLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
|
||||||
// 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 csLlmModel() { return $('reh-llm-model')?.value || ''; }
|
||||||
function csLang() { return $('reh-design-lang')?.value || ''; }
|
function csLang() { return $('reh-design-lang')?.value || ''; }
|
||||||
|
|
||||||
@ -51,41 +44,29 @@ function csRehearserText() {
|
|||||||
|
|
||||||
// ── Generation (chunked + merged by character) ───────────────────────────────
|
// ── Generation (chunked + merged by character) ───────────────────────────────
|
||||||
|
|
||||||
const CS_SCALAR_FIELDS = ['aliases', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
|
// Compact summary of the sheets built so far → tells the LLM what's known and
|
||||||
'attribute_high', 'attribute_low', 'skills', 'capabilities',
|
// which fields each character still needs, so it fills gaps instead of restarting.
|
||||||
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
|
|
||||||
'secret', 'conflict_style', 'win_condition'];
|
|
||||||
|
|
||||||
function csExistingSummary(map) {
|
function csExistingSummary(map) {
|
||||||
if (!map.size) return '';
|
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 => {
|
return [...map.values()].slice(0, 30).map(s => {
|
||||||
const missing = CS_SCALAR_FIELDS.filter(f => !(s[f] || '').trim());
|
const missing = FIELDS.filter(f => !(s[f] || '').trim());
|
||||||
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? ` — ${s.archetype}` : ''}`
|
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? ` — ${s.archetype}` : ''}`
|
||||||
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
|
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function csMerge(map, sheets) {
|
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) {
|
for (const s of sheets) {
|
||||||
const name = (s.name || '').trim(); if (!name) continue;
|
const name = (s.name || '').trim(); if (!name) continue;
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
if (!map.has(key)) {
|
if (!map.has(key)) { map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }); continue; }
|
||||||
map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const e = map.get(key);
|
const e = map.get(key);
|
||||||
CS_SCALAR_FIELDS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
|
SCALARS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
|
||||||
if (s.tier === 'main') e.tier = 'main';
|
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.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
|
||||||
(s.sources || []).forEach(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); });
|
||||||
if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,95 +125,7 @@ function csProgress(total) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Alignment bar ─────────────────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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) {
|
function csField(label, value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@ -241,50 +134,25 @@ function csField(label, value) {
|
|||||||
|
|
||||||
function csCardHtml(s) {
|
function csCardHtml(s) {
|
||||||
const inv = (s.inventory || []).filter(Boolean);
|
const inv = (s.inventory || []).filter(Boolean);
|
||||||
const invHtml = inv.length
|
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>` : '';
|
||||||
? `<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)
|
const attrs = (s.attribute_high || s.attribute_low)
|
||||||
? `<div class="cs-row"><dt>Core Attributes</dt><dd>▲ ${escHtml(s.attribute_high || '—')} · ▼ ${escHtml(s.attribute_low || '—')}</dd></div>`
|
? `<div class="cs-row"><dt>Core Attributes</dt><dd>▲ ${escHtml(s.attribute_high || '—')} · ▼ ${escHtml(s.attribute_low || '—')}</dd></div>` : '';
|
||||||
: '';
|
|
||||||
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
|
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
|
||||||
const srcHtml = sources.length
|
const srcHtml = sources.length
|
||||||
? `<div class="cs-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${
|
? `<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>` : '';
|
||||||
sources.map(x =>
|
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}">
|
||||||
`${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">
|
<div class="cs-head">
|
||||||
<span class="cs-name">${escHtml(s.name)}</span>
|
<span class="cs-name">${escHtml(s.name)}</span>
|
||||||
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
||||||
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</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>
|
</div>
|
||||||
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</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">
|
<dl class="cs-fields">
|
||||||
${csField('Physical', s.physical)}
|
${csField('Physical', s.physical)}
|
||||||
${csField('Clothing & Appearance', s.clothing)}
|
${csField('Alignment & Ethos', s.alignment)}
|
||||||
${csField('Alignment & Ethos', s.alignment)}
|
|
||||||
${attrs}
|
${attrs}
|
||||||
${csField('Trained Skills', s.skills)}
|
${csField('Trained Skills', s.skills)}
|
||||||
${csField('Capabilities', s.capabilities)}
|
|
||||||
${invHtml}
|
${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 & Speech</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
|
|
||||||
${csField('Dark Secret / Fatal Flaw', s.secret)}
|
${csField('Dark Secret / Fatal Flaw', s.secret)}
|
||||||
${csField('Conflict Style', s.conflict_style)}
|
${csField('Conflict Style', s.conflict_style)}
|
||||||
${csField('Win Condition', s.win_condition)}
|
${csField('Win Condition', s.win_condition)}
|
||||||
@ -303,44 +171,28 @@ function csToMarkdown(sheets) {
|
|||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
|
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
|
||||||
if (s.aliases) md += `*aka ${s.aliases}*\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` : '';
|
const f = (l, v) => v ? `- **${l}:** ${v}\n` : '';
|
||||||
md += f('Physical', s.physical) + f('Clothing', s.clothing)
|
md += f('Physical', s.physical) + f('Alignment & Ethos', s.alignment)
|
||||||
+ f('Alignment & Ethos', s.alignment)
|
|
||||||
+ f('Core Attributes', [s.attribute_high && '▲ ' + s.attribute_high, s.attribute_low && '▼ ' + s.attribute_low].filter(Boolean).join(' · '))
|
+ f('Core Attributes', [s.attribute_high && '▲ ' + s.attribute_high, s.attribute_low && '▼ ' + s.attribute_low].filter(Boolean).join(' · '))
|
||||||
+ f('Trained Skills', s.skills) + f('Capabilities', s.capabilities)
|
+ f('Trained Skills', s.skills) + f('Signature Inventory', (s.inventory || []).join(', '))
|
||||||
+ f('Backstory & Origin', s.backstory) + f('Relationships', s.relationships)
|
+ f('Dark Secret / Fatal Flaw', s.secret) + f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
|
||||||
+ f('Motivation', s.motivation) + f('Fears', s.fears)
|
const src = (s.sources || []).filter(x => x && x.quote).map(x => `${x.page != null ? 'p.' + x.page + ' ' : ''}“${x.quote}”`).join('; ');
|
||||||
+ 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`;
|
if (src) md += `- *Sources:* ${src}\n`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return md.trim();
|
return md.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function csShow(sheets, title, sourceText) {
|
function csShow(sheets, title) {
|
||||||
document.getElementById('cs-overlay')?.remove();
|
document.getElementById('cs-overlay')?.remove();
|
||||||
const ov = document.createElement('div');
|
const ov = document.createElement('div');
|
||||||
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
|
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
|
||||||
const main = sheets.filter(s => s.tier === 'main');
|
const main = sheets.filter(s => s.tier === 'main');
|
||||||
const supp = sheets.filter(s => s.tier !== 'main');
|
const supp = sheets.filter(s => s.tier !== 'main');
|
||||||
const group = (label, list) => list.length
|
const group = (label, list) => list.length ? `<div class="cs-group-label">${label}</div>` + list.map(csCardHtml).join('') : '';
|
||||||
? `<div class="cs-group-label">${label}</div>` + list.map(csCardHtml).join('')
|
|
||||||
: '';
|
|
||||||
ov.innerHTML = `<div class="audiobook-box cs-box">
|
ov.innerHTML = `<div class="audiobook-box cs-box">
|
||||||
<div class="cs-titlebar">
|
<div class="cs-titlebar">
|
||||||
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></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>
|
||||||
${escHtml(title || 'Character sheets')}
|
|
||||||
<span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span>
|
|
||||||
</span>
|
|
||||||
<span style="flex:1"></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-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
|
||||||
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
|
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
|
||||||
@ -350,56 +202,30 @@ function csShow(sheets, title, sourceText) {
|
|||||||
document.body.appendChild(ov);
|
document.body.appendChild(ov);
|
||||||
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
|
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
|
||||||
ov.querySelector('#cs-copy').addEventListener('click', () => {
|
ov.querySelector('#cs-copy').addEventListener('click', () => {
|
||||||
navigator.clipboard?.writeText(csToMarkdown(sheets))
|
navigator.clipboard?.writeText(csToMarkdown(sheets)).then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
|
||||||
.then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
|
|
||||||
});
|
});
|
||||||
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
|
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) ────────────────────────────────────────
|
// ── 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() {
|
async function csForReader() {
|
||||||
const text = csReaderText();
|
|
||||||
const book = readerState.title || 'Untitled book';
|
|
||||||
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
|
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
|
||||||
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets', text); return; }
|
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets'); return; }
|
||||||
const sheets = await csGenerate(text, key);
|
const sheets = await csGenerate(csReaderText(), key);
|
||||||
if (!sheets) return;
|
if (!sheets) return;
|
||||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||||
csSaveToLibrary(book, sheets);
|
csShow(sheets, readerState.title || 'Character sheets');
|
||||||
csShow(sheets, readerState.title || 'Character sheets', text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function csForRehearser() {
|
async function csForRehearser() {
|
||||||
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
|
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);
|
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
||||||
if (_cs.cache[key]) { csShow(_cs.cache[key], title, text); return; }
|
if (_cs.cache[key]) { csShow(_cs.cache[key], title); return; }
|
||||||
const sheets = await csGenerate(text, key);
|
const sheets = await csGenerate(csRehearserText(), key);
|
||||||
if (!sheets) return;
|
if (!sheets) return;
|
||||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||||
csSaveToLibrary(book, sheets);
|
csShow(sheets, title);
|
||||||
csShow(sheets, title, text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
|
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
|
||||||
|
|||||||
@ -1,334 +0,0 @@
|
|||||||
// ── 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 & 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;
|
|
||||||
@ -808,59 +808,6 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Send turn via SSE ─────────────────────────────────────────────────────
|
// ── 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) {
|
async function processBlob(blob) {
|
||||||
autoMicGeneration++; // cancel any pending auto-mic from previous turn
|
autoMicGeneration++; // cancel any pending auto-mic from previous turn
|
||||||
clearAudio();
|
clearAudio();
|
||||||
@ -885,13 +832,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
let assistantText = '';
|
let assistantText = '';
|
||||||
let lastStats = null;
|
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();
|
const form = new FormData();
|
||||||
form.append('audio', upBlob, upName);
|
form.append('audio', blob, 'audio.webm');
|
||||||
form.append('stt_backend', sttSel?.value || 'configured');
|
form.append('stt_backend', sttSel?.value || 'configured');
|
||||||
form.append('llm_url', llmUrlInp?.value.trim() || '');
|
form.append('llm_url', llmUrlInp?.value.trim() || '');
|
||||||
form.append('llm_model', llmModelSel?.value || '');
|
form.append('llm_model', llmModelSel?.value || '');
|
||||||
|
|||||||
@ -201,10 +201,10 @@ function saveCustomEngineCards(cards) {
|
|||||||
|
|
||||||
// Settings key per docker container name; role fallback for custom cards
|
// Settings key per docker container name; role fallback for custom cards
|
||||||
const DC_USE_MAP = {
|
const DC_USE_MAP = {
|
||||||
'tts-faster-qwen3-tts-voiceclone': { settingKey: 'tts_url', label: 'Use as 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' },
|
'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' },
|
'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-qwen3-tts-streaming': { settingKey: 'tts_stream_url', label: 'Use as Streaming TTS' },
|
||||||
'faster-whisper': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
|
'faster-whisper': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
|
||||||
'fish-speech-api': { settingKey: 'fishspeech_url', label: 'Use as Fish-Speech' },
|
'fish-speech-api': { settingKey: 'fishspeech_url', label: 'Use as Fish-Speech' },
|
||||||
'nemotron-asr-streaming': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
|
'nemotron-asr-streaming': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
|
||||||
@ -212,12 +212,9 @@ const DC_USE_MAP = {
|
|||||||
'parakeet-stt-multi-9011': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
|
'parakeet-stt-multi-9011': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
|
||||||
'magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' },
|
'magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' },
|
||||||
'nvidia-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 router' },
|
'nvidia-parakeet-asr': { settingKey: 'nvidia_router_url', label: 'Use as STT Router' },
|
||||||
'parakeet-rnnt-nim': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
|
'parakeet-rnnt-nim': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
|
||||||
'nvidia-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' },
|
'vibevoice-msft-tts': { settingKey: 'vibevoice_url', label: 'Use as VibeVoice' },
|
||||||
'xtts-dgx-spark': { settingKey: 'xtts_url', label: 'Use as XTTS' },
|
'xtts-dgx-spark': { settingKey: 'xtts_url', label: 'Use as XTTS' },
|
||||||
'whisperx-blackwell': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
|
'whisperx-blackwell': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
|
||||||
@ -314,7 +311,7 @@ async function loadLocalContainers() {
|
|||||||
const loading = '<div class="dc-loading">Checking container status…</div>';
|
const loading = '<div class="dc-loading">Checking container status…</div>';
|
||||||
if (gridTts) gridTts.innerHTML = loading;
|
if (gridTts) gridTts.innerHTML = loading;
|
||||||
if (gridStt) gridStt.innerHTML = loading;
|
if (gridStt) gridStt.innerHTML = loading;
|
||||||
if (gridLlm) gridLlm.innerHTML = loading;
|
if (gridLlm) gridLlm.innerHTML = '';
|
||||||
const custom = loadCustomEngineCards();
|
const custom = loadCustomEngineCards();
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/local-containers');
|
const r = await fetch('/api/local-containers');
|
||||||
@ -336,10 +333,10 @@ function renderLocalContainers(containers) {
|
|||||||
|
|
||||||
const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM' };
|
const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM' };
|
||||||
const DC_ICONS = {
|
const DC_ICONS = {
|
||||||
'tts-faster-qwen3-tts-voiceclone': '<span class="mdi mdi-speaker-outline"></span>',
|
'faster-qwen3-tts-voiceclone': '<span class="mdi mdi-speaker-outline"></span>',
|
||||||
'tts-faster-qwen3-tts-voicedesign': '<span class="mdi mdi-auto-fix"></span>',
|
'faster-qwen3-tts-voicedesign': '<span class="mdi mdi-auto-fix"></span>',
|
||||||
'tts-faster-qwen3-tts-customvoice': '🎭',
|
'faster-qwen3-tts-customvoice': '🎭',
|
||||||
'tts-faster-qwen3-tts-streaming': '<span class="mdi mdi-lightning-bolt"></span>',
|
'faster-qwen3-tts-streaming': '<span class="mdi mdi-lightning-bolt"></span>',
|
||||||
'parakeet-asr': '🦜',
|
'parakeet-asr': '🦜',
|
||||||
'magpie-tts': '🐦',
|
'magpie-tts': '🐦',
|
||||||
'parakeet-rnnt-nim': '🦜',
|
'parakeet-rnnt-nim': '🦜',
|
||||||
@ -348,10 +345,6 @@ function renderLocalContainers(containers) {
|
|||||||
'nvidia-magpie-tts': '<span class="mdi mdi-speaker-wireless"></span>',
|
'nvidia-magpie-tts': '<span class="mdi mdi-speaker-wireless"></span>',
|
||||||
'nvidia-parakeet-asr': '<span class="mdi mdi-microphone-variant"></span>',
|
'nvidia-parakeet-asr': '<span class="mdi mdi-microphone-variant"></span>',
|
||||||
'nvidia-parakeet-rnnt-nim': '<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>',
|
'vibevoice-msft-tts': '<span class="mdi mdi-account-voice"></span>',
|
||||||
'xtts-dgx-spark': '<span class="mdi mdi-account-music-outline"></span>',
|
'xtts-dgx-spark': '<span class="mdi mdi-account-music-outline"></span>',
|
||||||
'litellm': '<span class="mdi mdi-router-network"></span>',
|
'litellm': '<span class="mdi mdi-router-network"></span>',
|
||||||
@ -364,10 +357,10 @@ function renderLocalContainers(containers) {
|
|||||||
const _q = (v) => `<span class="mdi mdi-star-outline"></span>`;
|
const _q = (v) => `<span class="mdi mdi-star-outline"></span>`;
|
||||||
const _m = (v) => `<span class="mdi mdi-memory"></span>`;
|
const _m = (v) => `<span class="mdi mdi-memory"></span>`;
|
||||||
const DC_METRICS = {
|
const DC_METRICS = {
|
||||||
'tts-faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium clone'],[_m(),'6–8 GB VRAM']],
|
'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']],
|
'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']],
|
'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']],
|
'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']],
|
'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-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']],
|
'parakeet-rnnt-nim': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-1B'], [_m(),'2 GB VRAM']],
|
||||||
@ -403,7 +396,7 @@ function renderLocalContainers(containers) {
|
|||||||
// URL row — input only, no inline Connect button
|
// URL row — input only, no inline Connect button
|
||||||
const defaultUrl = isCustom
|
const defaultUrl = isCustom
|
||||||
? (c.url || '')
|
? (c.url || '')
|
||||||
: (c.port ? `http://host.docker.internal:${c.port}${c.role === 'llm' ? '/v1' : ''}` : '');
|
: (c.port ? `http://host.docker.internal:${c.port}` : '');
|
||||||
const urlRowHtml = `
|
const urlRowHtml = `
|
||||||
<div class="llm-local-url">
|
<div class="llm-local-url">
|
||||||
<span class="llm-local-url-label">URL</span>
|
<span class="llm-local-url-label">URL</span>
|
||||||
@ -427,19 +420,6 @@ function renderLocalContainers(containers) {
|
|||||||
data-cn-key="${n}">
|
data-cn-key="${n}">
|
||||||
</div>`;
|
</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
|
// Use-as button — right-aligned, clearly labelled as the activation action
|
||||||
const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role];
|
const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role];
|
||||||
const useBtn = useEntry
|
const useBtn = useEntry
|
||||||
@ -478,14 +458,9 @@ function renderLocalContainers(containers) {
|
|||||||
|
|
||||||
const portNum = c.port || '';
|
const portNum = c.port || '';
|
||||||
const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`;
|
const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`;
|
||||||
let snippetCode = '';
|
const snippetCode = c.role === 'tts'
|
||||||
if (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`
|
||||||
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`;
|
: `curl -s "${snippetBaseUrl}/v1/audio/transcriptions" \\\n -F "file=@audio.wav" \\\n -F "model=whisper-1"`;
|
||||||
} 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 snippetCopy = escHtml(snippetCode);
|
||||||
const snippetHtml = (portNum || isCustom) ? `
|
const snippetHtml = (portNum || isCustom) ? `
|
||||||
<div class="llm-local-snippet">
|
<div class="llm-local-snippet">
|
||||||
@ -504,7 +479,7 @@ function renderLocalContainers(containers) {
|
|||||||
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
|
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
${metricsHtml}
|
${metricsHtml}
|
||||||
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${apiKeyRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
|
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -596,17 +571,15 @@ function renderLocalContainers(containers) {
|
|||||||
const key = btn.dataset.dcUrlKey;
|
const key = btn.dataset.dcUrlKey;
|
||||||
const card = btn.closest('.llm-local-card');
|
const card = btn.closest('.llm-local-card');
|
||||||
const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`);
|
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 useBtn = card?.querySelector('.dc-use-btn');
|
||||||
const rawUrl = inp?.value.trim() || inp?.placeholder;
|
const rawUrl = inp?.value.trim() || inp?.placeholder;
|
||||||
const apiKey = apikeyInp?.value.trim() || '';
|
|
||||||
if (!rawUrl) { toast('Enter a URL first', 'error'); return; }
|
if (!rawUrl) { toast('Enter a URL first', 'error'); return; }
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
|
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
|
||||||
try {
|
try {
|
||||||
if (window.probeUrl) {
|
if (window.probeUrl) {
|
||||||
const type = card ? cardType(card) : '';
|
const type = card ? cardType(card) : '';
|
||||||
const d = await window.probeUrl(rawUrl, type, apiKey);
|
const d = await window.probeUrl(rawUrl, type);
|
||||||
if (d.ok) {
|
if (d.ok) {
|
||||||
setConnected(btn, card, useBtn, true);
|
setConnected(btn, card, useBtn, true);
|
||||||
localStorage.setItem('dc-con-' + key, '1');
|
localStorage.setItem('dc-con-' + key, '1');
|
||||||
@ -615,9 +588,7 @@ function renderLocalContainers(containers) {
|
|||||||
const settingKey = useBtn.dataset.dcUseKey;
|
const settingKey = useBtn.dataset.dcUseKey;
|
||||||
const url = inp?.value.trim() || inp?.placeholder || '';
|
const url = inp?.value.trim() || inp?.placeholder || '';
|
||||||
if (settingKey && url) {
|
if (settingKey && url) {
|
||||||
const patch = { [settingKey]: url };
|
await window.applyAndSaveSettings({ [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');
|
toast(`✓ Connected and set as active ${useBtn.textContent.replace('Use as ', '').trim()}`, 'success');
|
||||||
} else {
|
} else {
|
||||||
toast('✓ Service reachable', 'success');
|
toast('✓ Service reachable', 'success');
|
||||||
@ -647,17 +618,11 @@ function renderLocalContainers(containers) {
|
|||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const settingKey = btn.dataset.dcUseKey;
|
const settingKey = btn.dataset.dcUseKey;
|
||||||
const urlKey = btn.dataset.dcName;
|
const urlKey = btn.dataset.dcName;
|
||||||
const card = btn.closest('.llm-local-card');
|
const inp = btn.closest('.llm-local-card')?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(urlKey)}"]`);
|
||||||
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 url = inp?.value.trim() || inp?.placeholder || '';
|
||||||
const apiKey = apikeyInp?.value.trim() || '';
|
|
||||||
if (!url) { toast('Enter a URL first', 'error'); return; }
|
if (!url) { toast('Enter a URL first', 'error'); return; }
|
||||||
if (window.applyAndSaveSettings) {
|
if (window.applyAndSaveSettings) {
|
||||||
const patch = { [settingKey]: url };
|
await window.applyAndSaveSettings({ [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');
|
btn.classList.add('active');
|
||||||
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
|
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,14 +81,14 @@ function splitTextIntoChunks(text, maxLen = 800) {
|
|||||||
return chunks.length ? chunks : [text];
|
return chunks.length ? chunks : [text];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateChunkedTts(voice, text, backend, instruct, extra = null) {
|
async function generateChunkedTts(voice, text, backend, instruct) {
|
||||||
const chunks = splitTextIntoChunks(text);
|
const chunks = splitTextIntoChunks(text);
|
||||||
const prog = $('preview-chunk-progress');
|
const prog = $('preview-chunk-progress');
|
||||||
if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}…`; }
|
if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}…`; }
|
||||||
const blobs = [];
|
const blobs = [];
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}…`;
|
if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}…`;
|
||||||
blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend, false, extra));
|
blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend));
|
||||||
}
|
}
|
||||||
if (prog) prog.textContent = 'Merging…';
|
if (prog) prog.textContent = 'Merging…';
|
||||||
const merged = await mergeWavBlobs(blobs);
|
const merged = await mergeWavBlobs(blobs);
|
||||||
|
|||||||
@ -1,299 +0,0 @@
|
|||||||
// ── 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
22
static/js/pdf/pdf.min.js
vendored
File diff suppressed because one or more lines are too long
22
static/js/pdf/pdf.worker.min.js
vendored
22
static/js/pdf/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@ -258,45 +258,24 @@ async function readerLoadPdf(file) {
|
|||||||
const nPages = pdf.numPages;
|
const nPages = pdf.numPages;
|
||||||
const progress = readerShowParseProgress(nPages);
|
const progress = readerShowParseProgress(nPages);
|
||||||
|
|
||||||
// Pre-fetch page 1 geometry to define scale/zoom quickly for skeletons
|
// Sentences/units are built incrementally per page (page-local) and appended,
|
||||||
const page1 = await pdf.getPage(1);
|
// so there is no big synchronous spike at the end and memory work is spread
|
||||||
if (seq !== readerState._seq) { progress.done(); return; }
|
// across yields — keeps huge books responsive. Earlier indices never shift
|
||||||
const base1 = page1.getViewport({ scale: 1 });
|
// (later pages only append), so caches/status stay aligned.
|
||||||
|
|
||||||
// Render empty skeleton pages instantly so the user can scroll
|
|
||||||
for (let p = 1; p <= nPages; p++) {
|
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)
|
||||||
|
|
||||||
const pageDiv = document.createElement('div');
|
const pageDiv = document.createElement('div');
|
||||||
pageDiv.className = 'reader-page';
|
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');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'reader-overlay';
|
overlay.className = 'reader-overlay';
|
||||||
pageDiv.appendChild(overlay);
|
pageDiv.appendChild(overlay);
|
||||||
doc.appendChild(pageDiv);
|
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply zoom and rasterize immediately so the user sees the book!
|
const pageIdx = readerState.pages.length;
|
||||||
readerApplyZoom('fit-width');
|
readerState.pages.push({ pageDiv, overlay, page, base, rendered: false, renderTask: null });
|
||||||
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
|
// Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later
|
||||||
const content = await page.getTextContent();
|
const content = await page.getTextContent();
|
||||||
@ -329,21 +308,24 @@ async function readerLoadPdf(file) {
|
|||||||
for (let u = startUnit; u < readerState.sentences.length; u++) idxs.push(u);
|
for (let u = startUnit; u < readerState.sentences.length; u++) idxs.push(u);
|
||||||
readerState.unitsByPage.set(pageIdx, idxs);
|
readerState.unitsByPage.set(pageIdx, idxs);
|
||||||
|
|
||||||
// Yield to the event loop EVERY page so scrolling remains 60fps
|
// Yield to the event loop every few pages so the browser stays responsive
|
||||||
|
// on big books (prevents the "page is not responding" prompt).
|
||||||
progress.update(p);
|
progress.update(p);
|
||||||
await new Promise(r => setTimeout(r, 0));
|
if (p % 5 === 0) await new Promise(r => setTimeout(r, 0));
|
||||||
}
|
}
|
||||||
progress.done();
|
progress.done();
|
||||||
|
if (seq !== readerState._seq) return;
|
||||||
|
readerApplyZoom('fit-width');
|
||||||
|
readerSetupLazyRaster();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightweight floating parse-progress line shown in the document pane for big PDFs.
|
// Lightweight parse-progress line shown in the document pane for big PDFs.
|
||||||
function readerShowParseProgress(total) {
|
function readerShowParseProgress(total) {
|
||||||
const doc = $('reader-doc'); if (!doc) return { update() {}, done() {} };
|
const doc = $('reader-doc'); if (!doc) return { update() {}, done() {} };
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'reader-parsing';
|
el.className = 'reader-parsing';
|
||||||
el.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:16px;"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
|
el.innerHTML = '<span class="reh-imsdb-spinner"></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.appendChild(el);
|
||||||
doc.insertBefore(el, doc.firstChild);
|
|
||||||
const label = el.querySelector('span:last-child');
|
const label = el.querySelector('span:last-child');
|
||||||
return {
|
return {
|
||||||
update(p) { if (label) label.textContent = 'Reading PDF… page ' + p + ' / ' + total; },
|
update(p) { if (label) label.textContent = 'Reading PDF… page ' + p + ' / ' + total; },
|
||||||
@ -355,23 +337,14 @@ function readerShowParseProgress(total) {
|
|||||||
|
|
||||||
function readerComputeScale(mode) {
|
function readerComputeScale(mode) {
|
||||||
const doc = $('reader-doc');
|
const doc = $('reader-doc');
|
||||||
if (!doc || !readerState.pages.length) return 1;
|
const base = readerState.pages[0]?.base;
|
||||||
|
if (!doc || !base) 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 padW = 36, gap = 16;
|
||||||
const availW = (doc.clientWidth || 900) - padW;
|
const availW = (doc.clientWidth || 900) - padW;
|
||||||
const availH = (doc.clientHeight || 600) - 36;
|
const availH = (doc.clientHeight || 600) - 36;
|
||||||
if (mode === 'fit-width') return Math.max(0.2, availW / maxW);
|
if (mode === 'fit-width') return Math.max(0.2, availW / base.width);
|
||||||
if (mode === 'fit-height') return Math.max(0.2, availH / maxH);
|
if (mode === 'fit-height') return Math.max(0.2, availH / base.height);
|
||||||
if (mode === 'two') return Math.max(0.2, (availW - gap) / 2 / maxW);
|
if (mode === 'two') return Math.max(0.2, (availW - gap) / 2 / base.width);
|
||||||
return readerState.scale;
|
return readerState.scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -851,10 +824,8 @@ function readerGenParams() {
|
|||||||
const out = {};
|
const out = {};
|
||||||
const seed = $('reader-seed')?.value.trim();
|
const seed = $('reader-seed')?.value.trim();
|
||||||
const temp = $('reader-temp')?.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 (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10);
|
||||||
if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp);
|
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;
|
return Object.keys(out).length ? out : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1106,7 +1077,6 @@ async function readerSaveLibrary() {
|
|||||||
chunkMode: readerState.chunkMode,
|
chunkMode: readerState.chunkMode,
|
||||||
seed: $('reader-seed')?.value.trim() || '',
|
seed: $('reader-seed')?.value.trim() || '',
|
||||||
temperature: $('reader-temp')?.value.trim() || '',
|
temperature: $('reader-temp')?.value.trim() || '',
|
||||||
tts_speed: parseFloat($('reader-tts-speed')?.value) || 1,
|
|
||||||
normalize: readerState.normalize,
|
normalize: readerState.normalize,
|
||||||
sentenceCount: readerState.sentences.length,
|
sentenceCount: readerState.sentences.length,
|
||||||
pageCount: readerState.pages.length,
|
pageCount: readerState.pages.length,
|
||||||
@ -1172,7 +1142,6 @@ async function readerOpenLibraryDoc(id) {
|
|||||||
if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
|
if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
|
||||||
if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
|
if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
|
||||||
if ($('reader-temp')) $('reader-temp').value = rec.temperature || '';
|
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;
|
readerState.normalize = rec.normalize !== false;
|
||||||
if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
|
if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
|
||||||
if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);
|
if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);
|
||||||
@ -1227,8 +1196,6 @@ async function readerOpenLibraryDoc(id) {
|
|||||||
readerUpdateProgress();
|
readerUpdateProgress();
|
||||||
readerHighlightSentence(readerState.sentences[readerState.idx]);
|
readerHighlightSentence(readerState.sentences[readerState.idx]);
|
||||||
toast('Opened "' + rec.title + '" · resuming at sentence ' + (readerState.idx + 1), 'success');
|
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) {
|
function readerSetSelectValue(id, value) {
|
||||||
@ -1244,127 +1211,33 @@ async function readerRenderLibrary() {
|
|||||||
if (!card || !list) return;
|
if (!card || !list) return;
|
||||||
let all = [];
|
let all = [];
|
||||||
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
|
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
|
||||||
if (!all.length) { list.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; }
|
if (!all.length) { card.hidden = true; list.innerHTML = ''; return; }
|
||||||
|
card.hidden = false;
|
||||||
list.innerHTML = all.map(rec => {
|
list.innerHTML = all.map(rec => {
|
||||||
const total = rec.sentenceCount || 0;
|
const total = rec.sentenceCount || 0;
|
||||||
const synth = rec.synthCount || 0;
|
const synth = rec.synthCount || 0;
|
||||||
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
|
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
|
||||||
const synthPct = total ? Math.round((synth / total) * 100) : 0;
|
const synthPct = total ? Math.round((synth / total) * 100) : 0;
|
||||||
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
|
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
|
||||||
|
return `<div class="reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}">
|
||||||
let h = 0;
|
<button class="reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
|
||||||
const titleStr = rec.title || 'Untitled';
|
<div class="reader-book-icon"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span></div>
|
||||||
for (let i = 0; i < titleStr.length; i++) h = (h * 31 + titleStr.charCodeAt(i)) % 360;
|
<div class="reader-book-title">${escHtml(rec.title)}</div>
|
||||||
const h2 = (h + 40) % 360;
|
<div class="reader-book-meta">${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''} · ${date}</div>
|
||||||
const c1 = `hsl(${h}, 55%, 42%)`, c2 = `hsl(${h2}, 58%, 30%)`;
|
<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>
|
||||||
// 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>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
list.querySelectorAll('.reader-book').forEach(el => el.addEventListener('click', e => {
|
||||||
list.querySelectorAll('.reader-book').forEach(el => {
|
|
||||||
el.addEventListener('click', e => {
|
|
||||||
if (e.target.closest('.reader-book-del')) return;
|
if (e.target.closest('.reader-book-del')) return;
|
||||||
readerOpenLibraryDoc(el.dataset.id);
|
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 => {
|
list.querySelectorAll('.reader-book-del').forEach(btn => btn.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm('Delete this saved document and its audio?')) readerDeleteLibraryDoc(btn.dataset.del);
|
if (confirm('Delete this saved document and its audio?')) readerDeleteLibraryDoc(btn.dataset.del);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Wiring ───────────────────────────────────────────────────────────────────
|
// ── Wiring ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$('reader-file-input')?.addEventListener('change', function () {
|
$('reader-file-input')?.addEventListener('change', function () {
|
||||||
@ -1449,35 +1322,3 @@ 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
@ -54,13 +54,10 @@ function parseScript(text) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF page-break marker — preserved from importPDFScript / audiobook casting
|
// PDF page-break marker — preserved from importPDFScript
|
||||||
// Format: bare \f or \f<number> (e.g. \f3 = page 3)
|
if (line === '\f') {
|
||||||
if (line.startsWith('\f')) {
|
|
||||||
flushDialog(); flushAction();
|
flushDialog(); flushAction();
|
||||||
const pnStr = line.slice(1).trim();
|
result.push({ type: 'pagebreak', speaker: '', text: '', isDirection: true });
|
||||||
const pageNum = pnStr && /^\d+$/.test(pnStr) ? parseInt(pnStr, 10) : null;
|
|
||||||
result.push({ type: 'pagebreak', speaker: '', text: '', page: pageNum, isDirection: true });
|
|
||||||
state = 'action'; currentSpeaker = null;
|
state = 'action'; currentSpeaker = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -204,7 +204,7 @@ function linesToScriptText() {
|
|||||||
case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n';
|
case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n';
|
||||||
case 'direction':
|
case 'direction':
|
||||||
return line.speaker ? line.text + '\n' : '\n' + line.text + '\n';
|
return line.speaker ? line.text + '\n' : '\n' + line.text + '\n';
|
||||||
case 'pagebreak': return line.page ? `\n\f${line.page}\n` : '\n\f\n';
|
case 'pagebreak': return '\n\f\n';
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}).join('').trim();
|
}).join('').trim();
|
||||||
@ -223,8 +223,6 @@ async function saveToLibrary() {
|
|||||||
}
|
}
|
||||||
toast('Saved to library', 'success');
|
toast('Saved to library', 'success');
|
||||||
rehState._keepSavedIdOnce = true;
|
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();
|
await renderLibraryList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,11 +371,12 @@ async function loadPdfJs() {
|
|||||||
if (window.pdfjsLib) return;
|
if (window.pdfjsLib) return;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = '/static/js/pdf/pdf.min.js';
|
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||||||
s.onload = resolve; s.onerror = reject;
|
s.onload = resolve; s.onerror = reject;
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js';
|
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract a screenplay from a PDF, reconstructing element types from the
|
// Extract a screenplay from a PDF, reconstructing element types from the
|
||||||
@ -452,7 +451,7 @@ async function importPDFScript(file) {
|
|||||||
|
|
||||||
for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) {
|
for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) {
|
||||||
// Emit a page-break marker between PDF pages so the rehearser can paginate exactly
|
// Emit a page-break marker between PDF pages so the rehearser can paginate exactly
|
||||||
if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); }
|
if (pageIdx > 0) { blank(); out.push('\f'); blank(); }
|
||||||
|
|
||||||
const pageLns = allLinesByPage[pageIdx] || [];
|
const pageLns = allLinesByPage[pageIdx] || [];
|
||||||
for (const ln of pageLns) {
|
for (const ln of pageLns) {
|
||||||
@ -562,7 +561,6 @@ function loadRecord(rec) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
renderCastList();
|
renderCastList();
|
||||||
rehApplySharedCast(rec.title);
|
|
||||||
refreshRehBackends().then(() => {
|
refreshRehBackends().then(() => {
|
||||||
if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
|
if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
|
||||||
if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice;
|
if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice;
|
||||||
@ -570,32 +568,6 @@ function loadRecord(rec) {
|
|||||||
showPhase(2);
|
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 ─────────────────────────────────────────────────────────────
|
// ── Library UI ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Deterministic cover gradient from the title string
|
// Deterministic cover gradient from the title string
|
||||||
@ -679,37 +651,10 @@ async function renderLibraryList() {
|
|||||||
|
|
||||||
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
|
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (!confirm('Delete this rehearsal from the library?')) return;
|
||||||
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));
|
await rehDbDelete(parseInt(btn.dataset.id));
|
||||||
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
|
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
|
||||||
renderLibraryList();
|
renderLibraryList();
|
||||||
});
|
|
||||||
|
|
||||||
bookEl.appendChild(confirmOverlay);
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2158,48 +2103,6 @@ $('reh-start-btn')?.addEventListener('click', () => {
|
|||||||
highlightCurrentLine();
|
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 ─────────────────────────────────────────────────────────
|
// ── A4 Script page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildScriptPage() {
|
function buildScriptPage() {
|
||||||
@ -2209,8 +2112,7 @@ function buildScriptPage() {
|
|||||||
// Cast strip
|
// Cast strip
|
||||||
const castStrip = $('reh-cast-strip');
|
const castStrip = $('reh-cast-strip');
|
||||||
if (castStrip) {
|
if (castStrip) {
|
||||||
const names = Object.keys(rehState.cast);
|
castStrip.innerHTML = Object.keys(rehState.cast).map(sp => {
|
||||||
castStrip.innerHTML = names.map(sp => {
|
|
||||||
const c = rehState.cast[sp], isMe = c.voice === 'me';
|
const c = rehState.cast[sp], isMe = c.voice === 'me';
|
||||||
return `<div class="reh-strip-char" title="${escHtml(sp)} — ${isMe?'me':(c.voice||'no voice')}">
|
return `<div class="reh-strip-char" title="${escHtml(sp)} — ${isMe?'me':(c.voice||'no voice')}">
|
||||||
${voiceAvatarHtml(isMe?'':c.voice, c.color, 28)}
|
${voiceAvatarHtml(isMe?'':c.voice, c.color, 28)}
|
||||||
@ -2218,11 +2120,7 @@ function buildScriptPage() {
|
|||||||
${isMe?'<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>':''}
|
${isMe?'<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>':''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const lbl = $('reh-cast-toggle-label');
|
|
||||||
if (lbl) lbl.textContent = `Characters (${names.length})`;
|
|
||||||
}
|
}
|
||||||
rehApplyStageFont();
|
|
||||||
rehApplyCastCollapsed();
|
|
||||||
|
|
||||||
const linesEl = $('reh-script-lines'); if (!linesEl) return;
|
const linesEl = $('reh-script-lines'); if (!linesEl) return;
|
||||||
|
|
||||||
@ -2268,10 +2166,8 @@ function buildScriptPage() {
|
|||||||
${editBtn} ${synthDot}
|
${editBtn} ${synthDot}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
case 'pagebreak': {
|
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">— Page break —</span></div>`;
|
||||||
return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">${pbLabel}</span></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'transition':
|
case 'transition':
|
||||||
return `<div class="reh-transition${lineFlags}" data-index="${i}">${bulkCheck}${escHtml(line.text)}</div>`;
|
return `<div class="reh-transition${lineFlags}" data-index="${i}">${bulkCheck}${escHtml(line.text)}</div>`;
|
||||||
|
|||||||
@ -7,14 +7,11 @@
|
|||||||
|
|
||||||
// Test sentence: umlauts (ä ö ü ß), dates/numbers, and English words — reveals a
|
// Test sentence: umlauts (ä ö ü ß), dates/numbers, and English words — reveals a
|
||||||
// voice's character per seed. Used for every voice (incl. the batch run).
|
// voice's character per seed. Used for every voice (incl. the batch run).
|
||||||
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_DE = 'Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Schöne Grüße! — Madonna - Träume beginnt mit einem positiven Mindset.';
|
||||||
const SEED_FINDER_TEXT_EN = SEED_FINDER_TEXT_DE;
|
const SEED_FINDER_TEXT_EN = SEED_FINDER_TEXT_DE;
|
||||||
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE;
|
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE;
|
||||||
|
|
||||||
function _seedFinderDefaultText(voiceId) {
|
function _seedFinderDefaultText(voiceId) {
|
||||||
if (window._appSettings && window._appSettings.seed_finder_text) {
|
|
||||||
return window._appSettings.seed_finder_text;
|
|
||||||
}
|
|
||||||
const lc = (voiceId || '').toLowerCase();
|
const lc = (voiceId || '').toLowerCase();
|
||||||
if (lc.startsWith('de_')) return SEED_FINDER_TEXT_MIXED;
|
if (lc.startsWith('de_')) return SEED_FINDER_TEXT_MIXED;
|
||||||
if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN;
|
if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN;
|
||||||
@ -431,13 +428,10 @@ function attachSeedFinder(voiceId, body) {
|
|||||||
// writes to the same cache the per-voice Seed Finder reads, so opening any voice
|
// writes to the same cache the per-voice Seed Finder reads, so opening any voice
|
||||||
// later shows its samples instantly.
|
// later shows its samples instantly.
|
||||||
function seedFinderBatchAll() {
|
function seedFinderBatchAll() {
|
||||||
const _sfUseSelected = typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0;
|
const ids = (typeof activeVoiceIds === 'function')
|
||||||
const ids = _sfUseSelected
|
|
||||||
? [..._bulkSelected]
|
|
||||||
: (typeof activeVoiceIds === 'function')
|
|
||||||
? activeVoiceIds()
|
? activeVoiceIds()
|
||||||
: (window._voices || []).filter(v => v.enabled !== false).map(v => v.id);
|
: (window._voices || []).filter(v => v.enabled !== false).map(v => v.id);
|
||||||
if (!ids.length) { if (typeof toast === 'function') toast('No voices selected', 'error'); return; }
|
if (!ids.length) { if (typeof toast === 'function') toast('No active voices', 'error'); return; }
|
||||||
|
|
||||||
// In-app config dialog (no browser prompts): pick the seed range, then Start.
|
// In-app config dialog (no browser prompts): pick the seed range, then Start.
|
||||||
document.getElementById('seedbatch-overlay')?.remove();
|
document.getElementById('seedbatch-overlay')?.remove();
|
||||||
@ -445,7 +439,7 @@ function seedFinderBatchAll() {
|
|||||||
ov.className = 'audiobook-overlay'; ov.id = 'seedbatch-overlay';
|
ov.className = 'audiobook-overlay'; ov.id = 'seedbatch-overlay';
|
||||||
ov.innerHTML = `<div class="audiobook-box">
|
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-title"><span class="mdi mdi-dice-multiple-outline"></span> Batch seed generation</div>
|
||||||
<div class="audiobook-msg">Generate & 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="audiobook-msg">Generate & cache Seed Finder samples for all <b>${ids.length}</b> active voices. Already-cached seeds are skipped, so it's resumable; you can cancel anytime.</div>
|
||||||
<div class="seed-finder-row seed-finder-params" style="margin:12px 0">
|
<div class="seed-finder-row seed-finder-params" style="margin:12px 0">
|
||||||
<div class="seed-finder-field">
|
<div class="seed-finder-field">
|
||||||
<label>Seeds: from</label>
|
<label>Seeds: from</label>
|
||||||
|
|||||||
@ -328,7 +328,6 @@ const SETTINGS_FIELD_HELP = {
|
|||||||
's-llm-url': ['LLM base URL', 'OpenAI-compatible chat endpoint used for rewrites, casting, refinement, and voice-design assistance.'],
|
'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-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-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-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-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.'],
|
's-tts-extra-streaming': ['Streaming params', 'Advanced JSON sent to the streaming backend. Keep close to Voice Clone params for comparable sound.'],
|
||||||
@ -343,10 +342,7 @@ 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-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-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-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.']
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -463,12 +459,6 @@ async function loadSettings() {
|
|||||||
$('s-vd-key').value = s.voice_design_api_key || '';
|
$('s-vd-key').value = s.voice_design_api_key || '';
|
||||||
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
|
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
|
||||||
$('s-output-dir').value = s.output_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');
|
const themeEl = $('s-theme-select');
|
||||||
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
|
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
|
||||||
// Captures settings
|
// Captures settings
|
||||||
@ -567,8 +557,6 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
|
|||||||
voice_design_api_key: $('s-vd-key').value,
|
voice_design_api_key: $('s-vd-key').value,
|
||||||
voices_scan_dir: $('s-voices-scan-dir').value,
|
voices_scan_dir: $('s-voices-scan-dir').value,
|
||||||
output_dir: $('s-output-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_language: $('s-stt-language')?.value || '',
|
||||||
stt_preferred_backend: $('s-stt-preferred-backend')?.value || '',
|
stt_preferred_backend: $('s-stt-preferred-backend')?.value || '',
|
||||||
auto_refine: $('s-auto-refine')?.value || 'off',
|
auto_refine: $('s-auto-refine')?.value || 'off',
|
||||||
@ -578,7 +566,6 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
|
|||||||
refine_corrections: $('s-refine-corrections')?.checked ?? true,
|
refine_corrections: $('s-refine-corrections')?.checked ?? true,
|
||||||
refine_punctuation: $('s-refine-punctuation')?.checked ?? true,
|
refine_punctuation: $('s-refine-punctuation')?.checked ?? true,
|
||||||
captures_default_voice: $('s-captures-default-voice')?.value || '',
|
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.tts_stream_url = $('s-tts-stream-url').value;
|
||||||
_appSettings.customvoice_url = $('s-customvoice-url').value;
|
_appSettings.customvoice_url = $('s-customvoice-url').value;
|
||||||
@ -592,7 +579,6 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
|
|||||||
_appSettings.vibevoice_url = $('s-vibevoice-url').value;
|
_appSettings.vibevoice_url = $('s-vibevoice-url').value;
|
||||||
_appSettings.xtts_url = $('s-xtts-url')?.value || '';
|
_appSettings.xtts_url = $('s-xtts-url')?.value || '';
|
||||||
_appSettings.tts_stream_mode = $('s-tts-stream-mode').value;
|
_appSettings.tts_stream_mode = $('s-tts-stream-mode').value;
|
||||||
_appSettings.seed_finder_text = $('s-seed-finder-text')?.value || '';
|
|
||||||
_ttsStreamHealth = null;
|
_ttsStreamHealth = null;
|
||||||
await refreshTtsBackendAvailability($('tts-backend-select')?.value || '');
|
await refreshTtsBackendAvailability($('tts-backend-select')?.value || '');
|
||||||
markSettingsSeen();
|
markSettingsSeen();
|
||||||
@ -632,70 +618,3 @@ $('helper-apply-btn')?.addEventListener('click', () => {
|
|||||||
$('voice-id-input').dispatchEvent(new Event('input'));
|
$('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;
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|||||||
@ -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); }
|
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||||
return await r.blob();
|
return await r.blob();
|
||||||
}
|
}
|
||||||
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false, extra = null) {
|
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false) {
|
||||||
const mode = effectiveTtsPlaybackMode(modeOverride);
|
const mode = effectiveTtsPlaybackMode(modeOverride);
|
||||||
if (backend !== 'streaming' || mode === 'buffered') {
|
if (backend !== 'streaming' || mode === 'buffered') {
|
||||||
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra);
|
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona);
|
||||||
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
|
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'};
|
return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mode === 'streaming') throw e;
|
if (mode === 'streaming') throw e;
|
||||||
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra);
|
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona);
|
||||||
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
|
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -246,15 +246,6 @@ function _onPreviewGenerated(source, voice, text, backend) {
|
|||||||
if (typeof historyPush === 'function' && source.blob) historyPush(voice, text, backend, source.blob, source.url);
|
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 () => {
|
$('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 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;
|
const applyPersona = $('preview-persona-toggle')?.checked || false;
|
||||||
@ -264,14 +255,12 @@ $('preview-btn').addEventListener('click', async () => {
|
|||||||
$('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true;
|
$('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($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true;
|
||||||
if($('effects-apply-btn')) $('effects-apply-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 {
|
try {
|
||||||
const audio = $('preview-audio');
|
const audio = $('preview-audio');
|
||||||
const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
|
const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
|
||||||
const source = useChunked
|
const source = useChunked
|
||||||
? await generateChunkedTts(voice, text, backend, instruct, _extra)
|
? await generateChunkedTts(voice, text, backend, instruct)
|
||||||
: await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra);
|
: await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona);
|
||||||
previewBlob = source.blob;
|
previewBlob = source.blob;
|
||||||
window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
|
window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
|
||||||
audio.src = source.url;
|
audio.src = source.url;
|
||||||
|
|||||||
@ -45,7 +45,6 @@ function selectVoice(wrap) {
|
|||||||
const langCode = v.lang || wrap.dataset.lang || '';
|
const langCode = v.lang || wrap.dataset.lang || '';
|
||||||
const flagCc = v.flag || wrap.dataset.flagCc || langCode;
|
const flagCc = v.flag || wrap.dataset.flagCc || langCode;
|
||||||
const rating = v.rating || 0;
|
const rating = v.rating || 0;
|
||||||
const curGender = v.gender || 'N';
|
|
||||||
|
|
||||||
const nameParts = voiceId.split('_');
|
const nameParts = voiceId.split('_');
|
||||||
// Prefer an explicit display name (e.g. the character name for Rehearser voices);
|
// Prefer an explicit display name (e.g. the character name for Rehearser voices);
|
||||||
@ -59,14 +58,11 @@ function selectVoice(wrap) {
|
|||||||
const inspFlagIcon = inspFlagCc
|
const inspFlagIcon = inspFlagCc
|
||||||
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
|
? `<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 inspIconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 48) : null;
|
||||||
const avatarHtml = picSrcInsp
|
const avatarHtml = picSrcInsp
|
||||||
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
|
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
|
||||||
: inspIconHtml || silHtml;
|
: inspIconHtml || inspFlagIcon || initial;
|
||||||
const avatarBgStyle = (picSrcInsp || inspIconHtml) ? '' : `style="background:${color}"`;
|
const avatarBgStyle = (picSrcInsp || inspIconHtml || inspFlagIcon) ? '' : `style="background:${color}"`;
|
||||||
|
|
||||||
const flagIconHtml = inspFlagIcon || '';
|
const flagIconHtml = inspFlagIcon || '';
|
||||||
|
|
||||||
@ -84,6 +80,7 @@ function selectVoice(wrap) {
|
|||||||
const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
||||||
const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
||||||
const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
||||||
|
const curGender = v.gender || 'N';
|
||||||
const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
|
const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
|
||||||
|
|
||||||
inspector.innerHTML = `
|
inspector.innerHTML = `
|
||||||
|
|||||||
@ -107,7 +107,7 @@ function toggleSortDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncSortHeaders() {
|
function syncSortHeaders() {
|
||||||
document.querySelectorAll('.vl-header [data-sort], .vl-table-header [data-sort]').forEach(el => {
|
document.querySelectorAll('.vl-header [data-sort]').forEach(el => {
|
||||||
el.classList.remove('sort-asc', 'sort-desc');
|
el.classList.remove('sort-asc', 'sort-desc');
|
||||||
if (el.dataset.sort === _sortField)
|
if (el.dataset.sort === _sortField)
|
||||||
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
|
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
|
||||||
@ -134,35 +134,19 @@ document.addEventListener('click', e => {
|
|||||||
const icon = btn?.querySelector('.mdi');
|
const icon = btn?.querySelector('.mdi');
|
||||||
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
||||||
renderVoiceList();
|
renderVoiceList();
|
||||||
} else if (e.target.closest('#voice-table-view-btn')) {
|
}
|
||||||
|
if (e.target.closest('#voice-table-view-btn')) {
|
||||||
window._voiceTableView = !window._voiceTableView;
|
window._voiceTableView = !window._voiceTableView;
|
||||||
if (!window._voiceTableView) window._voiceTableEditMode = false;
|
|
||||||
try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {}
|
try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {}
|
||||||
const btn = document.getElementById('voice-table-view-btn');
|
const btn = document.getElementById('voice-table-view-btn');
|
||||||
btn?.classList.toggle('active', window._voiceTableView);
|
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-view', window._voiceTableView);
|
||||||
document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', !!window._voiceTableEditMode);
|
|
||||||
|
|
||||||
// Auto-close any open inspector row when toggling table view
|
// Auto-close any open inspector row when toggling table view
|
||||||
if (window._voiceTableView) {
|
if (window._voiceTableView) {
|
||||||
document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open'));
|
document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open'));
|
||||||
const inspector = document.getElementById('voices-inspector');
|
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>';
|
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
|
// Restore the preferences on load
|
||||||
@ -175,8 +159,14 @@ document.addEventListener('click', e => {
|
|||||||
const field = th.dataset.sort;
|
const field = th.dataset.sort;
|
||||||
if (field) {
|
if (field) {
|
||||||
const sel = document.getElementById('voice-sort-field');
|
const sel = document.getElementById('voice-sort-field');
|
||||||
if (sel) sel.value = field;
|
if (sel && sel.value === field) {
|
||||||
setSort(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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -185,30 +175,6 @@ document.addEventListener('change', e => {
|
|||||||
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
|
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = {
|
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'],
|
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'],
|
CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'],
|
||||||
@ -417,9 +383,7 @@ async function clientVoiceLoudness(v) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clientCalculateVoiceDb() {
|
async function clientCalculateVoiceDb() {
|
||||||
const voices = (_bulkSelected && _bulkSelected.size > 0)
|
const voices = visibleLibraryVoices();
|
||||||
? (_voices || []).filter(v => _bulkSelected.has(v.id))
|
|
||||||
: visibleLibraryVoices();
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
let calculated = 0;
|
let calculated = 0;
|
||||||
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'};
|
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'};
|
||||||
@ -624,8 +588,7 @@ function libraryTargetDb() {
|
|||||||
|
|
||||||
$('calculate-db-btn').addEventListener('click', async () => {
|
$('calculate-db-btn').addEventListener('click', async () => {
|
||||||
$('calculate-db-btn').disabled = true;
|
$('calculate-db-btn').disabled = true;
|
||||||
const _calcTarget = (_bulkSelected && _bulkSelected.size > 0) ? `${_bulkSelected.size} selected` : 'visible';
|
status('Calculating voice loudness…');
|
||||||
status(`Calculating voice loudness (${_calcTarget})…`);
|
|
||||||
try {
|
try {
|
||||||
const d = await clientCalculateVoiceDb();
|
const d = await clientCalculateVoiceDb();
|
||||||
renderVoiceList();
|
renderVoiceList();
|
||||||
@ -769,26 +732,9 @@ async function runVoiceBenchmarkBatch() {
|
|||||||
const total = voices.length;
|
const total = voices.length;
|
||||||
const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true};
|
const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true};
|
||||||
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''};
|
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);
|
setBenchmarkProgress(0, total, 'Starting benchmark...', stats);
|
||||||
for (let i = 0; i < voices.length; i++) {
|
for (let i = 0; i < voices.length; i++) {
|
||||||
const voice = voices[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);
|
setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats);
|
||||||
status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`);
|
status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`);
|
||||||
try {
|
try {
|
||||||
@ -814,20 +760,8 @@ async function runVoiceBenchmarkBatch() {
|
|||||||
stats.errors++;
|
stats.errors++;
|
||||||
stats.last = `${voice.id}: failed · ${e.message}`;
|
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);
|
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);
|
setBenchmarkProgress(total, total, 'Benchmark complete', stats);
|
||||||
return aggregate;
|
return aggregate;
|
||||||
}
|
}
|
||||||
@ -910,11 +844,9 @@ $('copy-active-voices-btn').addEventListener('click', async () => {
|
|||||||
// (re)builds it from the wav when missing — so this is purely a warm-up.
|
// (re)builds it from the wav when missing — so this is purely a warm-up.
|
||||||
$('precompute-embeddings-btn')?.addEventListener('click', async () => {
|
$('precompute-embeddings-btn')?.addEventListener('click', async () => {
|
||||||
const backend = libraryTtsBackend();
|
const backend = libraryTtsBackend();
|
||||||
const _useSelected = _bulkSelected && _bulkSelected.size > 0;
|
const ids = activeVoiceIds();
|
||||||
const ids = _useSelected ? [..._bulkSelected] : activeVoiceIds();
|
if (!ids.length) { toast('No active voices to precompute', 'error'); return; }
|
||||||
const _scopeLabel = _useSelected ? `${ids.length} selected` : `${ids.length} active`;
|
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;
|
||||||
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 btn = $('precompute-embeddings-btn'); if (btn) btn.disabled = true;
|
||||||
const ov = document.createElement('div');
|
const ov = document.createElement('div');
|
||||||
@ -1523,11 +1455,6 @@ function renderVoiceList() {
|
|||||||
if (_tvBtn) {
|
if (_tvBtn) {
|
||||||
_tvBtn.classList.toggle('active', !!window._voiceTableView);
|
_tvBtn.classList.toggle('active', !!window._voiceTableView);
|
||||||
document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!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
|
// Apply sidebar category filter
|
||||||
@ -1866,7 +1793,6 @@ function makeVoiceRow(v) {
|
|||||||
const benchCls = benchmarkClass(v);
|
const benchCls = benchmarkClass(v);
|
||||||
|
|
||||||
const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
||||||
const genderMapLetter = {F:'F', M:'M', N:'D', '':'?'};
|
|
||||||
const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
||||||
const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
||||||
const gender = v.gender || '';
|
const gender = v.gender || '';
|
||||||
@ -1890,73 +1816,43 @@ function makeVoiceRow(v) {
|
|||||||
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
|
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Avatar: photo > gender/type icon > silhouette (with color)
|
// Avatar: photo > gender/type icon > flag icon > initial letter (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 iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
|
||||||
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
|
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
|
||||||
: iconHtml ? 'vl-avatar vl-avatar-icon' : 'vl-avatar vl-avatar-sil';
|
: iconHtml ? 'vl-avatar vl-avatar-icon'
|
||||||
const avatarStyle = (picSrc || iconHtml) ? '' : `style="background:${color}"`;
|
: flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
|
||||||
|
const avatarStyle = (picSrc || iconHtml || flagIconHtml) ? '' : `style="background:${color}"`;
|
||||||
const avatarContent = picSrc
|
const avatarContent = picSrc
|
||||||
? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
|
? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
|
||||||
: iconHtml || silHtml;
|
: iconHtml || flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`;
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
<div class="vl-compact">
|
<div class="vl-compact">
|
||||||
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
|
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
|
||||||
<div class="vl-info">
|
<div class="vl-info">
|
||||||
${window._voiceTableEditMode
|
<span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>
|
||||||
? `<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">
|
<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>
|
<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>
|
</div>
|
||||||
<div class="vr-play-group">
|
<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-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
|
||||||
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
|
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">
|
<div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">${flagEmoji} ${escHtml(langCode)}</div>
|
||||||
${window._voiceTableEditMode
|
<div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}</div>
|
||||||
? `<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-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-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-dbfs" title="${dbTitle}">${dbfs} dB</div>
|
||||||
<div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
|
<div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
|
||||||
<div class="vl-tbl-rating">
|
<div class="vl-tbl-rating">${starsHtml}</div>
|
||||||
${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-source">${escHtml(v.origin || '-')}</div>
|
||||||
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
|
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
|
||||||
<div class="vl-tbl-note" title="${escHtml(v.note||'')}">
|
<div class="vl-tbl-note" title="${escHtml(v.note||'')}">${escHtml(v.note || '')}</div>
|
||||||
${window._voiceTableEditMode
|
<div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">${escHtml(v.tag || '')}</div>
|
||||||
? `<input type="text" class="vl-inline-edit" data-field="note" value="${escHtml(v.note||'')}" style="width:100%">`
|
<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>
|
||||||
: 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>
|
||||||
|
|
||||||
<div class="vr-main-row">
|
<div class="vr-main-row">
|
||||||
@ -3106,13 +3002,6 @@ function _bulkUpdate() {
|
|||||||
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
|
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
|
||||||
cb.checked = _bulkSelected.has(cb.dataset.id);
|
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) {
|
function _bulkToggle(id, checked) {
|
||||||
@ -3154,15 +3043,16 @@ $('vl-bulk-select-all')?.addEventListener('click', () => {
|
|||||||
_bulkUpdate();
|
_bulkUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always-visible Select-all toggle in the table header (the bulk bar only appears once
|
// Always-visible Select-all toggle in the sort bar (the bulk bar only appears once
|
||||||
// something is selected, so this is the entry point). Selects every visible row, or
|
// something is selected, so this is the entry point). Selects every visible row, or
|
||||||
// clears the selection if they're all already selected.
|
// clears the selection if they're all already selected.
|
||||||
$('vl-select-all-header')?.addEventListener('change', (e) => {
|
$('vl-select-all-visible')?.addEventListener('click', () => {
|
||||||
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
|
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
|
||||||
if (e.target.checked) {
|
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
|
||||||
rows.forEach(r => _bulkSelected.add(r.dataset.id));
|
if (allSelected) {
|
||||||
} else {
|
|
||||||
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
|
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
|
||||||
|
} else {
|
||||||
|
rows.forEach(r => _bulkSelected.add(r.dataset.id));
|
||||||
}
|
}
|
||||||
_bulkUpdate();
|
_bulkUpdate();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
||||||
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
||||||
's-llms', 's-conversation', 's-library',
|
's-llms', 's-conversation',
|
||||||
];
|
];
|
||||||
|
|
||||||
function _storedSection() {
|
function _storedSection() {
|
||||||
@ -126,8 +126,6 @@
|
|||||||
'/static/js/reader.js',
|
'/static/js/reader.js',
|
||||||
'/static/js/audiobook.js',
|
'/static/js/audiobook.js',
|
||||||
'/static/js/character-sheets.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 _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
|
||||||
var _bundleOk = false;
|
var _bundleOk = false;
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
llms: 's-llms'
|
llms: 's-llms'
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library'];
|
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
|
||||||
|
|
||||||
function storedSection() {
|
function storedSection() {
|
||||||
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
||||||
@ -69,40 +69,13 @@
|
|||||||
var _treeOpen = {};
|
var _treeOpen = {};
|
||||||
try { _treeOpen = JSON.parse(localStorage.getItem('ttsvc_trees') || '{}'); } catch(_) {}
|
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 = [
|
var TREES = [
|
||||||
{ tree: 'nav-voices-tree', chevron: 'nav-voices-chevron', section: 's-voices' },
|
{ 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-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' },
|
{ tree: 'nav-settings-tree', chevron: 'nav-settings-chevron', section: 's-settings' },
|
||||||
|
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 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() {
|
function _applyTreeStates() {
|
||||||
TREES.forEach(function (t) {
|
TREES.forEach(function (t) {
|
||||||
var open = !!_treeOpen[t.tree];
|
var open = !!_treeOpen[t.tree];
|
||||||
@ -124,7 +97,6 @@
|
|||||||
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
|
// 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.readerStop === 'function') window.readerStop();
|
||||||
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
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);
|
setStoredSection(sectionId);
|
||||||
setSectionHash(sectionId);
|
setSectionHash(sectionId);
|
||||||
SECTIONS.forEach(function (id) {
|
SECTIONS.forEach(function (id) {
|
||||||
@ -135,9 +107,13 @@
|
|||||||
item.classList.toggle('active', item.dataset.navSection === sectionId);
|
item.classList.toggle('active', item.dataset.navSection === sectionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigating to a section auto-opens its tree AND every ancestor tree
|
// Navigating to a section auto-opens its tree (but never closes others)
|
||||||
// (so a nested item is actually visible), but never closes others.
|
TREES.forEach(function (t) {
|
||||||
_openTreesForSection(sectionId);
|
if (t.section === sectionId && !_treeOpen[t.tree]) {
|
||||||
|
_treeOpen[t.tree] = true;
|
||||||
|
localStorage.setItem('ttsvc_trees', JSON.stringify(_treeOpen));
|
||||||
|
}
|
||||||
|
});
|
||||||
_applyTreeStates();
|
_applyTreeStates();
|
||||||
|
|
||||||
// When entering settings, show the active sub-page (default: connections)
|
// When entering settings, show the active sub-page (default: connections)
|
||||||
@ -204,46 +180,30 @@
|
|||||||
function n(fn) { return voices.filter(fn).length; }
|
function n(fn) { return voices.filter(fn).length; }
|
||||||
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v || ''; }
|
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v || ''; }
|
||||||
set('ntc-all', n(function () { return true; }));
|
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-favorites',n(function (v) { return (v.rating || 0) >= 4; }));
|
||||||
set('ntc-hidden', n(function (v) { return v.enabled === false; }));
|
set('ntc-hidden', n(function (v) { return v.enabled === false; }));
|
||||||
set('nav-voices-count', voices.length);
|
set('nav-voices-count', voices.length);
|
||||||
window.buildTagsTree(voices);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Tags tree — distinct voice tags with counts, plus the computed
|
// ── Virtual tag "subfolders" under the fixed categories ──────────────────
|
||||||
// Cloned / Designed / Favourites predicates that moved out of Library ──
|
var tree = document.getElementById('nav-voices-tree');
|
||||||
window.buildTagsTree = function (voices) {
|
if (!tree) return;
|
||||||
voices = voices || window._voices;
|
tree.querySelectorAll('.nav-tree-tag').forEach(function (e) { e.remove(); });
|
||||||
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 = {};
|
var counts = {};
|
||||||
voices.forEach(function (v) {
|
voices.forEach(function (v) {
|
||||||
String(v.tag || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean)
|
String(v.tag || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean)
|
||||||
.forEach(function (t) { counts[t] = (counts[t] || 0) + 1; });
|
.forEach(function (t) { counts[t] = (counts[t] || 0) + 1; });
|
||||||
});
|
});
|
||||||
var names = Object.keys(counts).sort(function (a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); });
|
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) {
|
names.forEach(function (tag) {
|
||||||
var el = document.createElement('div');
|
var el = document.createElement('div');
|
||||||
el.className = 'nav-tree-item' + (window._voiceTagFilter === tag ? ' is-active' : '');
|
el.className = 'nav-tree-item nav-tree-tag' + (window._voiceTagFilter === tag ? ' is-active' : '');
|
||||||
el.dataset.voiceTag = tag;
|
el.dataset.voiceTag = tag;
|
||||||
var icon = document.createElement('span'); icon.className = 'mdi mdi-tag-outline'; el.appendChild(icon);
|
var icon = document.createElement('span'); icon.className = 'mdi mdi-tag-outline'; el.appendChild(icon);
|
||||||
el.appendChild(document.createTextNode(' ' + tag + ' '));
|
el.appendChild(document.createTextNode(' ' + tag + ' '));
|
||||||
@ -317,19 +277,6 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 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)
|
// Restore last-used section from hash/localStorage (fallback: My Voices)
|
||||||
var _savedSection = storedSection();
|
var _savedSection = storedSection();
|
||||||
// On very first visit, default voices tree open
|
// On very first visit, default voices tree open
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
<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 — 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>
|
|
||||||
@ -147,14 +147,10 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><span class="mdi mdi-message-text-outline"></span> Reference transcript</h2>
|
<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>
|
<p class="card-subtitle">The exact words spoken in the audio — type them or auto-transcribe.</p>
|
||||||
<div class="engine-setup-row" style="padding:10px 12px; margin-bottom:12px">
|
<div class="btn-row" style="gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
||||||
<div class="engine-setup-col no-border" style="flex:1">
|
<label style="font-size:13px;color:var(--subtext);white-space:nowrap">Recognition engine:</label>
|
||||||
<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>
|
<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>
|
||||||
<div class="engine-setup-controls">
|
<button class="btn-secondary" id="clone-refresh-stt-btn" type="button">Refresh</button>
|
||||||
<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>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn-secondary" id="transcribe-btn"><span class="mdi mdi-message-text-outline"></span> Auto-transcribe</button>
|
<button class="btn-secondary" id="transcribe-btn"><span class="mdi mdi-message-text-outline"></span> Auto-transcribe</button>
|
||||||
|
|||||||
@ -7,40 +7,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config bar -->
|
<!-- Config bar -->
|
||||||
<div class="conv-config-bar card" style="padding:14px">
|
<div class="conv-config-bar card">
|
||||||
<div class="engine-setup-row" style="margin-bottom:8px">
|
<div class="conv-config-row">
|
||||||
<div class="engine-setup-col">
|
<div class="conv-config-group">
|
||||||
<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>
|
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
|
||||||
<div class="engine-setup-controls">
|
|
||||||
<select id="conv-stt-select" aria-label="Speech-to-text engine"><option value="configured">Checking...</option></select>
|
<select id="conv-stt-select" aria-label="Speech-to-text engine"><option value="configured">Checking...</option></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="conv-config-group">
|
||||||
<div class="engine-setup-col" style="flex:1.5">
|
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
|
||||||
<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 style="display:flex;gap:6px">
|
||||||
<div class="engine-setup-controls">
|
|
||||||
<input id="conv-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
|
<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>
|
<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>
|
<select id="conv-llm-model-select" aria-label="Language model"><option value="">— fetch models —</option></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="engine-setup-col" style="flex:1.5; border-right:none; padding-right:0">
|
<div class="conv-config-group">
|
||||||
<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>
|
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
|
||||||
<div class="engine-setup-controls">
|
<div style="display:flex;gap:6px">
|
||||||
<select id="conv-tts-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
|
<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>
|
<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>
|
<select id="conv-tts-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="conv-config-group conv-config-group--actions">
|
||||||
<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>
|
<button class="btn-secondary" id="conv-clear-btn" title="Clear conversation"><span class="mdi mdi-delete-outline"></span> Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Main area: chat + stats -->
|
<!-- Main area: chat + stats -->
|
||||||
@ -108,7 +104,7 @@
|
|||||||
<div class="conv-pipe-val" id="cpv-llm">—</div>
|
<div class="conv-pipe-val" id="cpv-llm">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="conv-pipe-step" id="cps-tts">
|
<div class="conv-pipe-step" id="cps-tts">
|
||||||
<div class="conv-pipe-label"><span class="mdi mdi-account-voice"></span> TTS</div>
|
<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="cpf-tts"></div></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 class="conv-pipe-val" id="cpv-tts">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
<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 — 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>
|
|
||||||
@ -1,50 +1,25 @@
|
|||||||
<style>
|
<div class="section-head">
|
||||||
.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>
|
<span class="section-icon"><span class="mdi mdi-brain"></span></span>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>Engines</h2>
|
<h2>Engines</h2>
|
||||||
<p style="margin:0">Local and cloud services for language generation, speech recognition, and synthesis.</p>
|
<p>Local and cloud services for language generation, speech recognition, and speech 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’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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Language Models sub-page ──────────────────────────── -->
|
<!-- ── Language Models sub-page ──────────────────────────── -->
|
||||||
<div class="s-engines-page" data-page="llm">
|
<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) ──────────────── -->
|
<!-- ── Active Language Model (global picker) ──────────────── -->
|
||||||
<div class="llm-active-panel card">
|
<div class="llm-active-panel card">
|
||||||
<div class="llm-active-head">
|
<div class="llm-active-head">
|
||||||
@ -56,9 +31,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="llm-active-controls">
|
<div class="llm-active-controls">
|
||||||
<label class="llm-active-lbl"><span class="mdi mdi-server-network"></span> Endpoint</label>
|
<label class="llm-active-lbl"><span class="mdi mdi-server-network"></span> Endpoint</label>
|
||||||
<select id="llm-active-url" class="llm-active-url-inp" style="appearance: auto; cursor: pointer; padding-right: 25px;">
|
<input id="llm-active-url" type="text" class="llm-active-url-inp" list="llm-active-url-suggestions"
|
||||||
<option value="">— Select an endpoint —</option>
|
placeholder="http://host:port/v1" spellcheck="false" autocomplete="off">
|
||||||
</select>
|
<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>
|
||||||
<label class="llm-active-lbl"><span class="mdi mdi-brain"></span> Model</label>
|
<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>
|
<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>
|
<button id="llm-active-refresh" class="btn-secondary" type="button" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
|
||||||
@ -66,17 +48,207 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div id="dc-grid-llm" 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">
|
<h4 class="llm-local-cat">Local</h4>
|
||||||
<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>
|
<div class="llm-local-grid">
|
||||||
<button class="btn-primary" type="button" onclick="openAddEngineDialog('llm')"><span class="mdi mdi-plus"></span> Add Custom</button>
|
|
||||||
|
<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>
|
||||||
|
<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> <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 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> <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> <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> <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 & 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>
|
||||||
|
|
||||||
|
<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-service-grid">
|
||||||
|
|
||||||
<div class="llm-card llm-card-highlight">
|
<div class="llm-card llm-card-highlight">
|
||||||
@ -193,38 +365,7 @@
|
|||||||
<span class="llm-model-tag">mistral-large-latest</span>
|
<span class="llm-model-tag">mistral-large-latest</span>
|
||||||
</div>
|
</div>
|
||||||
</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 & 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>
|
||||||
|
|
||||||
</div><!-- /llm -->
|
</div><!-- /llm -->
|
||||||
@ -232,21 +373,113 @@
|
|||||||
<!-- ── Speech to Text sub-page ───────────────────────────── -->
|
<!-- ── Speech to Text sub-page ───────────────────────────── -->
|
||||||
<div class="s-engines-page" data-page="stt">
|
<div class="s-engines-page" data-page="stt">
|
||||||
|
|
||||||
|
<div class="llm-sec-header">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;margin-bottom:12px">
|
<div>
|
||||||
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Local</h4>
|
<h3 class="llm-sec-title">Speech Recognition</h3>
|
||||||
<button class="btn-primary" type="button" onclick="openAddEngineDialog('stt')"><span class="mdi mdi-plus"></span> Add Custom</button>
|
<p class="llm-sec-note">Your Docker stack containers appear at the top. Local runners and cloud APIs below.</p>
|
||||||
</div>
|
</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 id="dc-grid-stt" class="dc-grid" style="margin-bottom:14px"></div>
|
<div id="dc-grid-stt" 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">
|
<!-- Inline STT test panel -->
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div class="stt-test-panel" id="stt-test-panel">
|
||||||
<span class="mdi mdi-cloud-outline"></span>
|
<div class="stt-test-row">
|
||||||
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Cloud APIs</h4>
|
<span class="stt-test-label">Quick test</span>
|
||||||
<span class="llm-free-badge">Free tiers</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>
|
||||||
<button class="btn-primary" type="button" onclick="openAddEngineDialog('stt')"><span class="mdi mdi-plus"></span> Add Custom</button>
|
<div id="stt-test-result" class="stt-test-result" style="display:none"></div>
|
||||||
</div>
|
</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× 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: 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]}]">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× 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 & run</span>
|
||||||
|
<button class="llm-copy-btn" data-copy="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">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-service-grid">
|
||||||
|
|
||||||
<div class="llm-card llm-card-highlight">
|
<div class="llm-card llm-card-highlight">
|
||||||
@ -332,120 +565,197 @@
|
|||||||
|
|
||||||
</div>
|
</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× RT · 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–15× RT CPU · ~1 GB RAM.</span>
|
|
||||||
</div>
|
|
||||||
<div class="s-field">
|
|
||||||
<label>Groq API key <span class="s-label-note">(Groq Whisper & LLM)</span></label>
|
|
||||||
<div class="s-key-row">
|
|
||||||
<input type="password" id="s-groq-api-key" placeholder="gsk_…" 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 -->
|
</div><!-- /stt -->
|
||||||
|
|
||||||
<!-- ── Text to Speech sub-page ───────────────────────────── -->
|
<!-- ── Text to Speech sub-page ───────────────────────────── -->
|
||||||
<div class="s-engines-page" data-page="tts">
|
<div class="s-engines-page" data-page="tts">
|
||||||
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;margin-bottom:12px">
|
<div class="llm-sec-header">
|
||||||
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Local</h4>
|
<div>
|
||||||
<button class="btn-primary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
|
<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’s backend settings.</p>
|
||||||
</div>
|
</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">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<span class="mdi mdi-cloud-outline"></span>
|
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
|
||||||
<h4 class="llm-local-cat" style="margin:0;text-transform:uppercase">Cloud APIs</h4>
|
<button class="btn-secondary dc-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
|
||||||
<span class="llm-free-badge">Free tiers</span>
|
<span class="llm-free-badge llm-free-local">100% Local</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
|
|
||||||
</div>
|
</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× GPU</span>
|
||||||
|
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> 1–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 — 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 · 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> <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) — 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× 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…). Drop-in replacement for OpenAI’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 — multilingual voice cloning from a 6-second sample. 17 languages. Compatible with this app’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 — 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 \ -H "Content-Type: application/json" \ -d '{"text":"Hello from VibeVoice"}' \ --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-service-grid">
|
||||||
|
|
||||||
<div class="llm-card llm-card-highlight">
|
<div class="llm-card llm-card-highlight">
|
||||||
@ -519,53 +829,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div> <!-- CLOSE llm-service-grid -->
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom: 24px;">
|
<div class="llm-local-howto">
|
||||||
<div class="s-page-head">
|
<div class="llm-howto-icon"><span class="mdi mdi-lightbulb-outline"></span></div>
|
||||||
<h3>Default Playback Voice</h3>
|
<div class="llm-howto-body">
|
||||||
<p>Pre-selected voice in the STT-TTS panel</p>
|
<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>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div><!-- /tts -->
|
</div><!-- /tts -->
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<div class="bench-tabs" role="tablist" aria-label="Benchmark modes">
|
<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" 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-account-voice"></span> TTS <span>Text to Speech</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" type="button" data-bench-tab="turn"><span class="mdi mdi-swap-horizontal"></span> Turn <span>STT → TTS</span></button>
|
<button class="bench-tab" type="button" data-bench-tab="turn"><span class="mdi mdi-swap-horizontal"></span> Turn <span>STT → TTS</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -75,19 +75,21 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Run benchmark</h2>
|
<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 < 1.0 means the backend generates faster than real-time.</p>
|
<p class="card-subtitle">Pick a backend and voice, set run count, then measure synthesis latency and real-time factor (RTF). RTF < 1.0 means the backend generates faster than real-time.</p>
|
||||||
<div class="engine-setup-row" style="margin-bottom:12px">
|
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||||
<div class="engine-setup-col" style="flex:2">
|
<div class="field">
|
||||||
<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>
|
<label>Backend</label>
|
||||||
<div class="engine-setup-controls">
|
<select id="perf-backend-select"><option value="">Checking backends…</option></select>
|
||||||
<select id="perf-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
|
</div>
|
||||||
<button class="btn-secondary" id="perf-fetch-voices-btn" type="button" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
|
<div class="field">
|
||||||
<select id="perf-voice-select" aria-label="Voice"><option value="">— select after fetch —</option></select>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="engine-setup-col no-border" style="flex:1">
|
<div class="field">
|
||||||
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Benchmark Options</label>
|
<label>Runs</label>
|
||||||
<div class="engine-setup-controls">
|
<select id="perf-runs">
|
||||||
<select id="perf-runs" aria-label="Runs">
|
|
||||||
<option value="1">1 run</option>
|
<option value="1">1 run</option>
|
||||||
<option value="3" selected>3 runs</option>
|
<option value="3" selected>3 runs</option>
|
||||||
<option value="5">5 runs</option>
|
<option value="5">5 runs</option>
|
||||||
@ -95,7 +97,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Sample text</label>
|
<label>Sample text</label>
|
||||||
<textarea id="perf-text" rows="3">Hello, how are you today? Please read this sample clearly for a fair voice benchmark.</textarea>
|
<textarea id="perf-text" rows="3">Hello, how are you today? Please read this sample clearly for a fair voice benchmark.</textarea>
|
||||||
@ -137,17 +138,14 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Batch benchmark</h2>
|
<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>
|
<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="engine-setup-row" style="margin-bottom:12px">
|
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||||
<div class="engine-setup-col" style="flex:2">
|
<div class="field">
|
||||||
<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>
|
<label>Backend</label>
|
||||||
<div class="engine-setup-controls">
|
<select id="batch-backend-select"><option value="">Checking backends…</option></select>
|
||||||
<select id="batch-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="field">
|
||||||
<div class="engine-setup-col no-border" style="flex:1">
|
<label>Runs per voice</label>
|
||||||
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Benchmark Options</label>
|
<select id="batch-runs">
|
||||||
<div class="engine-setup-controls">
|
|
||||||
<select id="batch-runs" aria-label="Runs per voice">
|
|
||||||
<option value="1" selected>1 run</option>
|
<option value="1" selected>1 run</option>
|
||||||
<option value="3">3 runs</option>
|
<option value="3">3 runs</option>
|
||||||
<option value="5">5 runs</option>
|
<option value="5">5 runs</option>
|
||||||
@ -155,7 +153,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
||||||
<div class="batch-voice-toolbar">
|
<div class="batch-voice-toolbar">
|
||||||
<button class="btn-secondary btn-sm" id="batch-load-voices-btn" type="button" title="Load all voices exposed by the selected backend"><span class="mdi mdi-reload"></span> Reload from backend</button>
|
<button class="btn-secondary btn-sm" id="batch-load-voices-btn" type="button" title="Load all voices exposed by the selected backend"><span class="mdi mdi-reload"></span> Reload from backend</button>
|
||||||
@ -221,27 +218,25 @@
|
|||||||
<p>Runs the same STT → LLM → TTS pipeline as Conversation Playground and records turn latency.</p>
|
<p>Runs the same STT → LLM → TTS pipeline as Conversation Playground and records turn latency.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="engine-setup-row" style="margin-bottom:12px; margin-top:12px">
|
<div class="conv-config-row">
|
||||||
<div class="engine-setup-col" style="flex:1">
|
<div class="conv-config-group">
|
||||||
<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>
|
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
|
||||||
<div class="engine-setup-controls">
|
<select id="bench-turn-stt"><option value="configured">Checking...</option></select>
|
||||||
<select id="bench-turn-stt" aria-label="STT backend"><option value="configured">Checking...</option></select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="conv-config-group">
|
||||||
<div class="engine-setup-col" style="flex:1.5">
|
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
|
||||||
<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="bench-inline-controls">
|
||||||
<div class="engine-setup-controls">
|
<input id="bench-turn-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
|
||||||
<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>
|
<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>
|
<select id="bench-turn-llm-model"><option value="">Fetch models</option></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="engine-setup-col no-border" style="flex:1.5">
|
<div class="conv-config-group">
|
||||||
<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>
|
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
|
||||||
<div class="engine-setup-controls">
|
<div class="bench-inline-controls">
|
||||||
<select id="bench-turn-tts-backend" aria-label="TTS backend"><option value="">Checking...</option></select>
|
<select id="bench-turn-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>
|
<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" aria-label="Voice"><option value="">Fetch voices</option></select>
|
<select id="bench-turn-voice"><option value="">Fetch voices</option></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -275,7 +270,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-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-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-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-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"><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 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 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>
|
||||||
<div class="conv-stats-head" style="margin-top:14px">Turn history</div>
|
<div class="conv-stats-head" style="margin-top:14px">Turn history</div>
|
||||||
|
|||||||
@ -8,39 +8,36 @@
|
|||||||
|
|
||||||
<div class="tab-content" id="tab-reader">
|
<div class="tab-content" id="tab-reader">
|
||||||
|
|
||||||
<div id="reader-main-view">
|
|
||||||
<!-- ① Import + controls ─────────────────────────────────────────── -->
|
<!-- ① Import + controls ─────────────────────────────────────────── -->
|
||||||
<div class="card" style="padding:14px">
|
<div class="card">
|
||||||
<div class="engine-setup-row" style="margin-bottom:8px">
|
<div class="reader-controls">
|
||||||
<div class="engine-setup-col" style="flex:1">
|
<div class="field reader-ctl">
|
||||||
<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>
|
<label>Backend</label>
|
||||||
<div class="engine-setup-controls">
|
<select id="reader-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
|
||||||
<select id="reader-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
|
</div>
|
||||||
<button class="btn-secondary" id="reader-fetch-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
|
<div class="field reader-ctl">
|
||||||
<select id="reader-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
|
<label>Voice</label>
|
||||||
|
<div class="reader-voice-pick">
|
||||||
|
<select id="reader-voice-select" aria-label="Voice"><option value="">— fetch or pick a voice —</option></select>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-fetch-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="field reader-ctl">
|
||||||
<label>Speed <span id="reader-speed-label" class="reader-speed-label">1.0×</span></label>
|
<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">
|
<input type="range" id="reader-speed" min="0.5" max="2" step="0.05" value="1" aria-label="Reading speed">
|
||||||
</div>
|
</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">
|
<div class="field reader-ctl reader-ctl-sm">
|
||||||
<label>Seed <span class="note">(blank = auto)</span></label>
|
<label>Seed <span class="note">(blank = auto)</span></label>
|
||||||
<input type="number" id="reader-seed" placeholder="auto" autocomplete="off">
|
<input type="number" id="reader-seed" placeholder="auto" autocomplete="off">
|
||||||
@ -49,18 +46,25 @@
|
|||||||
<label>Temperature <span class="note">(0–1)</span></label>
|
<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">
|
<input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="field reader-ctl reader-ctl-sm">
|
<label class="reader-ctl reader-norm-toggle" title="Even out loudness differences between chunks during playback">
|
||||||
<label>Native Speed <span class="note">(0.5–2)</span></label>
|
<input type="checkbox" id="reader-normalize" checked> Normalise loudness
|
||||||
<input type="number" id="reader-tts-speed" min="0.5" max="2" step="0.05" value="1" placeholder="1.0" autocomplete="off">
|
</label>
|
||||||
</div>
|
<label class="btn-primary reader-import-btn" title="Import a PDF or text file">
|
||||||
<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
|
<span class="mdi mdi-file-upload-outline"></span> Import
|
||||||
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
|
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="reader-backend-hint" id="reader-backend-hint" style="padding-left:14px; margin-top:8px"></div>
|
<div class="reader-backend-hint" id="reader-backend-hint"></div>
|
||||||
</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 ─────────────────────────────────── -->
|
<!-- ② Drag & drop / empty state ─────────────────────────────────── -->
|
||||||
<div class="reader-dropzone" id="reader-dropzone">
|
<div class="reader-dropzone" id="reader-dropzone">
|
||||||
@ -139,18 +143,5 @@
|
|||||||
|
|
||||||
<!-- ⑥ Document viewport ─────────────────────────────────────────── -->
|
<!-- ⑥ Document viewport ─────────────────────────────────────────── -->
|
||||||
<div class="reader-doc" id="reader-doc"></div>
|
<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 -->
|
</div><!-- /tab-reader -->
|
||||||
|
|||||||
@ -162,14 +162,12 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><span class="mdi mdi-account-group-outline"></span> Cast your characters</h2>
|
<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>
|
<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="engine-setup-row" style="margin-bottom:12px; padding:10px 12px">
|
<div class="reh-cast2-backend">
|
||||||
<div class="engine-setup-col no-border" style="flex:1">
|
<div class="field" style="flex:1;min-width:160px">
|
||||||
<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>
|
<label>TTS backend</label>
|
||||||
<div class="engine-setup-controls">
|
<select id="reh-backend-select"><option value="">Checking…</option></select>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
<!-- Narrator is now a regular cast entry — see NARRATOR row in cast list -->
|
<!-- Narrator is now a regular cast entry — see NARRATOR row in cast list -->
|
||||||
|
|
||||||
@ -180,20 +178,19 @@
|
|||||||
<strong id="reh-autocast-title">Match characters with voices</strong>
|
<strong id="reh-autocast-title">Match characters with voices</strong>
|
||||||
<span class="note">— let an LLM cast every character automatically</span>
|
<span class="note">— let an LLM cast every character automatically</span>
|
||||||
</div>
|
</div>
|
||||||
<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="reh-autodesign-row">
|
||||||
<div class="engine-setup-col" style="flex:1">
|
<div class="field" style="flex:1;min-width:150px;margin:0">
|
||||||
<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>
|
<label for="reh-llm-url" style="font-size:11px">LLM endpoint</label>
|
||||||
<div class="engine-setup-controls">
|
|
||||||
<select id="reh-llm-url-sel" style="display:none" aria-hidden="true"></select>
|
<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...)">
|
<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>
|
<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>
|
</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>
|
<select id="reh-llm-model" aria-label="LLM model"><option value="">— default —</option></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="field" style="min-width:90px;margin:0">
|
||||||
<div class="engine-setup-col no-border" style="flex:0.3">
|
<label for="reh-design-lang" style="font-size:11px">Language</label>
|
||||||
<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">
|
<select id="reh-design-lang" aria-label="Voice language">
|
||||||
<option value="English">English</option>
|
<option value="English">English</option>
|
||||||
<option value="German">German</option>
|
<option value="German">German</option>
|
||||||
@ -204,7 +201,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Three casting options -->
|
<!-- Three casting options -->
|
||||||
<div class="reh-cast-options">
|
<div class="reh-cast-options">
|
||||||
@ -291,14 +287,7 @@
|
|||||||
|
|
||||||
<!-- Director's console -->
|
<!-- Director's console -->
|
||||||
<div class="reh-console" id="reh-transport-bar">
|
<div class="reh-console" id="reh-transport-bar">
|
||||||
<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 class="reh-cast-strip" id="reh-cast-strip"></div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="reh-console-transport">
|
<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>
|
<button class="reh-tb-btn" id="reh-tb-prev" title="Previous line"><span class="mdi mdi-skip-previous"></span></button>
|
||||||
@ -340,10 +329,6 @@
|
|||||||
<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-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-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>
|
<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-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 & speech recognition"><span class="mdi mdi-school-outline"></span> Train</button>
|
<button class="btn-secondary btn-sm" id="reh-train-open-btn" title="Train mode — practice your lines with cue playback & speech recognition"><span class="mdi mdi-school-outline"></span> Train</button>
|
||||||
<button class="btn-secondary btn-sm" id="reh-exit-btn">Exit</button>
|
<button class="btn-secondary btn-sm" id="reh-exit-btn">Exit</button>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<span class="section-icon"><span class="mdi mdi-cog-outline"></span></span>
|
<span class="section-icon"><span class="mdi mdi-cog-outline"></span></span>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<p>Configure general app behaviour, default parameters, and persistent storage settings.</p>
|
<p>Configure backend URLs, API keys, voice folders, and playback preferences.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -24,10 +24,172 @@
|
|||||||
</select>
|
</select>
|
||||||
<span class="s-hint">Switch between light and dark interface. Saved instantly.</span>
|
<span class="s-hint">Switch between light and dark interface. Saved instantly.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="s-field" style="grid-column: 1 / -1">
|
</div>
|
||||||
<label>Default Seed Finder text</label>
|
</div>
|
||||||
<textarea id="s-seed-finder-text" placeholder="Leave empty for language-specific defaults..." style="min-height:60px; font-family:inherit; resize:vertical;"></textarea>
|
</div>
|
||||||
<span class="s-hint">Custom test sentence used when opening the Seed Finder.</span>
|
|
||||||
|
<!-- ── 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 · 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 · 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… ~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× RT · 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–15× RT CPU · ~1 GB RAM.</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label>Groq API key <span class="s-label-note">(Groq Whisper & LLM)</span></label>
|
||||||
|
<div class="s-key-row">
|
||||||
|
<input type="password" id="s-groq-api-key" placeholder="gsk_…" 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="s-page-actions">
|
<div class="s-page-actions">
|
||||||
@ -37,11 +199,140 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 & 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 ─────────────────────────────────────────────── -->
|
<!-- ── Payloads ─────────────────────────────────────────────── -->
|
||||||
<div class="s-settings-page" data-page="payloads">
|
<div class="s-settings-page" data-page="payloads">
|
||||||
@ -114,36 +405,14 @@
|
|||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<div class="s-field">
|
<div class="s-field">
|
||||||
<label>Voice scan directory</label>
|
<label>Voice scan directory</label>
|
||||||
<div class="s-key-row">
|
|
||||||
<input type="text" id="s-voices-scan-dir" placeholder="/voices">
|
<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>
|
<span class="s-hint">Contains <code>active_voices</code>, <code>hidden_voices</code>, sounds, and metadata.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="s-field">
|
<div class="s-field">
|
||||||
<label>Active voices directory</label>
|
<label>Active voices directory</label>
|
||||||
<div class="s-key-row">
|
|
||||||
<input type="text" id="s-output-dir" placeholder="/voices/active_voices">
|
<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>
|
<span class="s-hint">New cloned/exported voices are saved here.</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="s-page-actions">
|
<div class="s-page-actions">
|
||||||
<button class="btn-primary s-save-btn">Save settings</button>
|
<button class="btn-primary s-save-btn">Save settings</button>
|
||||||
|
|||||||
@ -10,19 +10,22 @@
|
|||||||
<div class="tab-content" id="tab-generation">
|
<div class="tab-content" id="tab-generation">
|
||||||
|
|
||||||
<!-- ① Voice selection ─────────────────────────────────────────── -->
|
<!-- ① Voice selection ─────────────────────────────────────────── -->
|
||||||
<div class="card" style="padding:14px">
|
<div class="card">
|
||||||
<div class="engine-setup-row" style="margin-bottom:8px">
|
<h2><span class="mdi mdi-account-voice"></span> Voice & backend</h2>
|
||||||
<div class="engine-setup-col" style="flex:1">
|
<div class="tryout-voice-row">
|
||||||
<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="field" style="min-width:0">
|
||||||
<div class="engine-setup-controls">
|
<label>Backend</label>
|
||||||
<select id="tts-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
|
<select id="tts-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
|
||||||
<button class="btn-secondary" id="fetch-tts-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
|
</div>
|
||||||
<select id="tts-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="engine-setup-col no-border" style="flex:0.5">
|
<div class="field" style="min-width:0">
|
||||||
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Playback</label>
|
<label>Playback</label>
|
||||||
<div class="engine-setup-controls">
|
|
||||||
<select id="preview-playback-mode" aria-label="Playback mode">
|
<select id="preview-playback-mode" aria-label="Playback mode">
|
||||||
<option value="settings">Settings default</option>
|
<option value="settings">Settings default</option>
|
||||||
<option value="streaming">Streaming only</option>
|
<option value="streaming">Streaming only</option>
|
||||||
@ -31,8 +34,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<strong>Checking available TTS backends…</strong>
|
||||||
</div>
|
</div>
|
||||||
<!-- Reference voice panel (shown when the selected voice matches the library) -->
|
<!-- Reference voice panel (shown when the selected voice matches the library) -->
|
||||||
@ -71,10 +73,6 @@
|
|||||||
<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 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>
|
</span>
|
||||||
</div>
|
</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">
|
<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">
|
<label class="chunk-toggle-label" title="Rewrite text through this voice's character persona before generating">
|
||||||
<input type="checkbox" id="preview-persona-toggle">
|
<input type="checkbox" id="preview-persona-toggle">
|
||||||
@ -228,13 +226,15 @@
|
|||||||
<div class="card" id="refine-card">
|
<div class="card" id="refine-card">
|
||||||
<h2>Refine with LLM</h2>
|
<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>
|
<p class="card-subtitle">Clean up the transcription using a local language model — remove fillers, fix repetitions, and normalise punctuation.</p>
|
||||||
<div class="engine-setup-row" style="margin-bottom:12px; padding:10px 12px">
|
<div class="refine-config">
|
||||||
<div class="engine-setup-col" style="flex:1">
|
<div class="field" style="flex:1;min-width:180px">
|
||||||
<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>
|
<label>LLM URL</label>
|
||||||
<div class="engine-setup-controls">
|
<input type="text" id="refine-llm-url" placeholder="http://localhost:11434/v1" spellcheck="false">
|
||||||
<input type="text" id="refine-llm-url" placeholder="http://localhost:11434/v1" spellcheck="false" title="OpenAI-compatible endpoint (Ollama, LM Studio, vLLM...)">
|
<span class="note">OpenAI-compatible endpoint (Ollama, LM Studio, vLLM…)</span>
|
||||||
<input type="text" id="refine-model" placeholder="auto-detect model" style="max-width:140px">
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="refine-toggles">
|
<div class="refine-toggles">
|
||||||
@ -252,18 +252,23 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Synthesize transcription</h2>
|
<h2>Synthesize transcription</h2>
|
||||||
<p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p>
|
<p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p>
|
||||||
<div class="engine-setup-row" style="margin-bottom:12px; padding:10px 12px">
|
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||||
<div class="engine-setup-col" style="flex:1">
|
<div class="field">
|
||||||
<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>
|
<label>Backend</label>
|
||||||
<div class="engine-setup-controls">
|
|
||||||
<select id="stt-tts-backend-select" aria-label="TTS backend"><option value="">Checking backends...</option></select>
|
<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>
|
</div>
|
||||||
<select id="stt-tts-voice-select" aria-label="Voice"><option value="">-- select voices --</option></select>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="engine-setup-col no-border" style="flex:0.5">
|
<div class="field">
|
||||||
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Playback</label>
|
<label>Playback</label>
|
||||||
<div class="engine-setup-controls">
|
|
||||||
<select id="stt-tts-playback-mode" aria-label="Playback mode">
|
<select id="stt-tts-playback-mode" aria-label="Playback mode">
|
||||||
<option value="settings">Settings default</option>
|
<option value="settings">Settings default</option>
|
||||||
<option value="streaming">Streaming only</option>
|
<option value="streaming">Streaming only</option>
|
||||||
@ -272,10 +277,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label>Style Instruction <span style="font-weight:400">(optional)</span></label>
|
<label>Style Instruction <span style="font-weight:400">(optional)</span></label>
|
||||||
<input type="text" id="stt-tts-style-instruction" placeholder="Optional style/emotion instruction">
|
<input type="text" id="stt-tts-style-instruction" placeholder="Optional style/emotion instruction">
|
||||||
|
|||||||
@ -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-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
|
||||||
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button>
|
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button>
|
||||||
<button id="voice-table-view-btn" class="vl-sort-dir-btn" title="Toggle full-width table view"><span class="mdi mdi-table"></span></button>
|
<button id="voice-table-view-btn" class="vl-sort-dir-btn" title="Toggle full-width table view"><span class="mdi mdi-table"></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>
|
<button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
|
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
|
||||||
@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vl-table-header" id="vl-table-header">
|
<div class="vl-table-header" id="vl-table-header">
|
||||||
<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></div>
|
||||||
<div>Img</div>
|
<div>Img</div>
|
||||||
<div>Play</div>
|
<div>Play</div>
|
||||||
<div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div>
|
<div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div>
|
||||||
|
|||||||
467
static/style.css
467
static/style.css
@ -42,23 +42,6 @@ button .mdi, a .mdi { font-size: 15px; }
|
|||||||
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
--sidebar-width: 220px;
|
--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; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* stop iOS auto-inflating text */
|
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* stop iOS auto-inflating text */
|
||||||
body {
|
body {
|
||||||
@ -142,36 +125,6 @@ body {
|
|||||||
.nav-tree-item.nav-tree-tag { padding-left: 38px; }
|
.nav-tree-item.nav-tree-tag { padding-left: 38px; }
|
||||||
.nav-tree-item.nav-tree-tag .mdi { font-size: 13px; opacity: .8; }
|
.nav-tree-item.nav-tree-tag .mdi { font-size: 13px; opacity: .8; }
|
||||||
.ntc { font-size: 14px; color: var(--subtext); font-weight: 500; }
|
.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-tree-item.is-active .ntc { color: var(--accent); }
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@ -453,32 +406,6 @@ audio { width: 100%; }
|
|||||||
.s-engines-page { display:none; flex-direction:column; gap:16px; }
|
.s-engines-page { display:none; flex-direction:column; gap:16px; }
|
||||||
.s-engines-page.is-active { display:flex; }
|
.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 */
|
/* Page card head */
|
||||||
.s-page-head { border-bottom:1px solid var(--border); padding-bottom:14px; }
|
.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); }
|
.s-page-head h3 { font-size:16px; font-weight:700; margin:0 0 4px; color:var(--text); }
|
||||||
@ -558,9 +485,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 { 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:hover { border-color:var(--accent); color:var(--accent); }
|
||||||
.s-log-filter.is-active { background:var(--accent); border-color:var(--accent); color:#fff; }
|
.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:75vh; 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:440px; 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-empty { color:var(--subtext); font-family:inherit; text-align:center; padding:24px 0; }
|
||||||
.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 { 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:last-child { border-bottom:none; }
|
.s-log-row:last-child { border-bottom:none; }
|
||||||
.s-log-ts { color:var(--subtext); font-size:11px; white-space:nowrap; }
|
.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; }
|
.s-log-level { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||||
@ -1321,15 +1248,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
}
|
}
|
||||||
.vl-row.vl-bulk-selected { background: rgba(37,99,235,.06); }
|
.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 {
|
.voice-groups-bar {
|
||||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
||||||
padding: 7px 10px; border-bottom: 1px solid var(--border);
|
padding: 7px 10px; border-bottom: 1px solid var(--border);
|
||||||
@ -1371,12 +1289,6 @@ 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:last-child { border-bottom: none !important; }
|
||||||
.voices-list-pane .vl-row:hover { background: var(--panel) !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.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; }
|
.voices-list-pane .vl-row.vr-disabled { opacity: .5; }
|
||||||
/* Hide everything except the compact div */
|
/* Hide everything except the compact div */
|
||||||
.voices-list-pane .vl-row .vr-main-row,
|
.voices-list-pane .vl-row .vr-main-row,
|
||||||
@ -1420,22 +1332,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
/* Separator dot between type and gender */
|
/* Separator dot between type and gender */
|
||||||
.vl-type-label + .vl-gender-label::before { content: '·'; color: var(--border); margin-right: 6px; font-weight: 400; }
|
.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 */
|
/* Gender label — no background, just colored symbol + text */
|
||||||
.vl-gender-letter {
|
.vl-gender-label { font-size: 10px; font-weight: 600; }
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
.vl-gender-label.g-f { color: #e91e8c; }
|
||||||
width: 16px; height: 16px; border-radius: 4px;
|
.vl-gender-label.g-m { color: #0ea5e9; }
|
||||||
font-weight: 800; font-size: 11px;
|
.vl-gender-label.g-n { color: #8b5cf6; }
|
||||||
}
|
|
||||||
.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-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 */
|
/* Benchmark speed chip — compact list row + inspector header */
|
||||||
.vl-meta .vl-bench-chip { margin-left: 2px; }
|
.vl-meta .vl-bench-chip { margin-left: 2px; }
|
||||||
.vl-bench-chip {
|
.vl-bench-chip {
|
||||||
@ -1494,8 +1396,7 @@ 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-meta { display: none; }
|
||||||
.voices-workbench.table-view .vl-avatar { order: 1; width: 32px; height: 32px; }
|
.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 .vr-play-group { order: 2; flex-shrink: 0; }
|
||||||
.voices-workbench.table-view .vl-name,
|
.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-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-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-gender { display: block; order: 5; font-size: 12px; }
|
||||||
.voices-workbench.table-view .vl-tbl-type { display: block; order: 6; font-size: 12px; }
|
.voices-workbench.table-view .vl-tbl-type { display: block; order: 6; font-size: 12px; }
|
||||||
@ -1510,31 +1411,6 @@ 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-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-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) */
|
/* Synth mode panel (segmented control + preview input) */
|
||||||
.vl-synth-panel { background: var(--surface); }
|
.vl-synth-panel { background: var(--surface); }
|
||||||
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }
|
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }
|
||||||
@ -1915,20 +1791,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
/* vr-main-row in inspector: always hidden (metadata shown in header grid) */
|
/* vr-main-row in inspector: always hidden (metadata shown in header grid) */
|
||||||
.inspector-body .vr-main-row { display: none !important; }
|
.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 ──────────────────────────────────────────── */
|
/* ── AI Backends / LLMs section ──────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Category pill tabs */
|
/* Category pill tabs */
|
||||||
@ -1992,7 +1854,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
|
|
||||||
/* ── Cloud service cards grid ─────────────────────────── */
|
/* ── Cloud service cards grid ─────────────────────────── */
|
||||||
.llm-service-grid {
|
.llm-service-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); gap: 14px;
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2179,7 +2041,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
|
|
||||||
/* ── Connect / Disconnect — primary CTA, prominent blue ─────────────────── */
|
/* ── Connect / Disconnect — primary CTA, prominent blue ─────────────────── */
|
||||||
.llm-local-ping {
|
.llm-local-ping {
|
||||||
padding: 5px 12px; font-size: 11.5px; font-weight: 700; border-radius: 5px;
|
padding: 6px 16px; font-size: 12.5px; font-weight: 700; border-radius: 6px;
|
||||||
border: 1.5px solid #2563eb; background: #2563eb; color: #fff;
|
border: 1.5px solid #2563eb; background: #2563eb; color: #fff;
|
||||||
cursor: pointer; font-family: inherit;
|
cursor: pointer; font-family: inherit;
|
||||||
display: inline-flex; align-items: center; gap: 5px;
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
@ -2201,11 +2063,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
|
|
||||||
/* ── Docker action buttons (Stop / Start / Restart) ─────────────────────── */
|
/* ── Docker action buttons (Stop / Start / Restart) ─────────────────────── */
|
||||||
.llm-use-btn {
|
.llm-use-btn {
|
||||||
font-size: 11.5px; padding: 5px 10px; border-radius: 5px;
|
font-size: 11px; padding: 4px 11px; border-radius: 5px;
|
||||||
border: 1.5px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
|
border: 1px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
|
||||||
color: #115E59; cursor: pointer; font-family: inherit; font-weight: 600;
|
color: #115E59; cursor: pointer; font-family: inherit; font-weight: 600;
|
||||||
transition: background .15s, border-color .15s;
|
transition: background .15s, border-color .15s;
|
||||||
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0;
|
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.llm-use-btn:hover:not(:disabled) { background: rgba(13,148,136,.15); border-color: var(--teal); }
|
.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); }
|
.llm-use-btn:active { background: rgba(13,148,136,.25); }
|
||||||
@ -2214,15 +2076,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
/* ── "Use as TTS / STT" — activate action, right-aligned, clearly distinct ─ */
|
/* ── "Use as TTS / STT" — activate action, right-aligned, clearly distinct ─ */
|
||||||
.dc-use-btn {
|
.dc-use-btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 5px 12px; font-size: 11.5px; font-weight: 700; border-radius: 5px;
|
padding: 6px 16px; font-size: 12px; font-weight: 700; border-radius: 6px;
|
||||||
border: 1.5px solid #2563eb; background: #2563eb;
|
border: 1.5px solid rgba(13,148,136,.55); background: rgba(13,148,136,.12);
|
||||||
color: #fff; cursor: pointer; font-family: inherit;
|
color: var(--teal); cursor: pointer; font-family: inherit;
|
||||||
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; flex-shrink: 0;
|
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; flex-shrink: 0;
|
||||||
transition: background .15s, border-color .15s, box-shadow .15s;
|
transition: background .15s, border-color .15s, box-shadow .15s;
|
||||||
}
|
}
|
||||||
.dc-use-btn:hover:not(:disabled) {
|
.dc-use-btn:hover:not(:disabled) {
|
||||||
background: #1d4ed8; border-color: #1d4ed8;
|
background: rgba(13,148,136,.22); border-color: var(--teal);
|
||||||
box-shadow: 0 2px 8px rgba(37,99,235,.35);
|
box-shadow: 0 1px 6px rgba(13,148,136,.25);
|
||||||
}
|
}
|
||||||
.dc-use-btn.active {
|
.dc-use-btn.active {
|
||||||
background: rgba(13,148,136,.22); border-color: var(--teal);
|
background: rgba(13,148,136,.22); border-color: var(--teal);
|
||||||
@ -2425,7 +2287,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
}
|
}
|
||||||
.dc-grid {
|
.dc-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@ -2965,48 +2827,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
|
|
||||||
/* ── Conversation Playground ─────────────────────────────────────────────── */
|
/* ── Conversation Playground ─────────────────────────────────────────────── */
|
||||||
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
|
.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-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 { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.conv-config-group--actions { margin-left: auto; }
|
.conv-config-group--actions { margin-left: auto; }
|
||||||
@ -3603,53 +3423,48 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
|
|
||||||
/* ── Proper screenplay A4 formatting ─────────────────────────────────── */
|
/* ── Proper screenplay A4 formatting ─────────────────────────────────── */
|
||||||
/* Use Courier New throughout — 1 page ≈ 1 minute of screen time */
|
/* 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 {
|
.reh-a4-page {
|
||||||
font-family: 'Courier New', Courier, monospace !important;
|
font-family: 'Courier New', Courier, monospace !important;
|
||||||
font-size: calc(12pt * var(--reh-stage-scale, 1));
|
font-size: 12pt;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.reh-page-title {
|
.reh-page-title {
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: 'Courier New', Courier, monospace;
|
||||||
font-size: 1.17em;
|
font-size: 14pt;
|
||||||
}
|
}
|
||||||
/* Scene headings: all-caps, bold, with spacing */
|
/* Scene headings: all-caps, bold, with spacing */
|
||||||
.reh-scene {
|
.reh-scene {
|
||||||
font-size: 1em;
|
font-size: 12pt;
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
/* Narrator / action text and spoken dialog share ONE size (1em) so the play
|
/* Action text: normal weight, left-aligned */
|
||||||
reads evenly — no narrator-vs-dialogue size mismatch. */
|
.reh-action-block { font-size: 12pt; }
|
||||||
.reh-action-block { font-size: 1em; }
|
|
||||||
.reh-action-text { font-size: 1em; }
|
|
||||||
|
|
||||||
/* Act headings: larger, extra top space */
|
/* Act headings: larger, extra top space */
|
||||||
.reh-act { font-size: 1.08em; }
|
.reh-act { font-size: 13pt; }
|
||||||
|
|
||||||
/* Transitions: right-aligned */
|
/* Transitions: right-aligned */
|
||||||
.reh-transition { font-size: 1em; }
|
.reh-transition { font-size: 12pt; }
|
||||||
|
|
||||||
/* Dialog block — screenplay format */
|
/* Dialog block — screenplay format */
|
||||||
.reh-block-name {
|
.reh-block-name {
|
||||||
font-size: 0.92em;
|
font-size: 11pt;
|
||||||
letter-spacing: .1em;
|
letter-spacing: .1em;
|
||||||
}
|
}
|
||||||
.reh-block-dialog {
|
.reh-block-dialog {
|
||||||
font-size: 1em;
|
font-size: 12pt;
|
||||||
padding-left: 32px; /* ~1.5" dialog indent */
|
padding-left: 32px; /* ~1.5" dialog indent */
|
||||||
max-width: 440px; /* ~3.5" dialog column width */
|
max-width: 440px; /* ~3.5" dialog column width */
|
||||||
}
|
}
|
||||||
/* Parenthetical directions: centered-ish */
|
/* Parenthetical directions: centered-ish */
|
||||||
.reh-direction {
|
.reh-direction {
|
||||||
font-size: 0.92em;
|
font-size: 11pt;
|
||||||
padding-left: 80px;
|
padding-left: 80px;
|
||||||
color: #444;
|
color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Script editor modal */
|
/* 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 { 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-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; }
|
.reh-modal-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid var(--border); font-weight:700; }
|
||||||
|
|
||||||
@ -3734,20 +3549,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
/* Cast strip takes its own full row inside the console */
|
/* 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; }
|
.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 */
|
/* Act heading on A4 page */
|
||||||
.reh-act {
|
.reh-act {
|
||||||
font-family: 'Courier New', Courier, monospace; font-size: 13px; font-weight: 700;
|
font-family: 'Courier New', Courier, monospace; font-size: 13px; font-weight: 700;
|
||||||
@ -3779,16 +3580,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
}
|
}
|
||||||
.reh-scene .mdi { font-size: 11px; opacity: .5; flex-shrink: 0; }
|
.reh-scene .mdi { font-size: 11px; opacity: .5; flex-shrink: 0; }
|
||||||
|
|
||||||
/* Action / description block — 1em so narrator matches spoken dialog and
|
/* Action / description block */
|
||||||
scales with the +A / -A control. */
|
|
||||||
.reh-action-block {
|
.reh-action-block {
|
||||||
font-family: 'Courier New', Courier, monospace; font-size: 1em; line-height: 1.75;
|
font-family: 'Courier New', Courier, monospace; font-size: 13px; line-height: 1.75;
|
||||||
color: #2c2c3e; padding: 2px 0 10px; cursor: pointer;
|
color: #2c2c3e; padding: 2px 0 10px; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition line */
|
/* Transition line */
|
||||||
.reh-transition {
|
.reh-transition {
|
||||||
font-family: 'Courier New', Courier, monospace; font-size: 0.75em; font-weight: 700;
|
font-family: 'Courier New', Courier, monospace; font-size: 11.5px; font-weight: 700;
|
||||||
text-align: right; text-transform: uppercase; letter-spacing: .1em;
|
text-align: right; text-transform: uppercase; letter-spacing: .1em;
|
||||||
color: #555; padding: 8px 0;
|
color: #555; padding: 8px 0;
|
||||||
}
|
}
|
||||||
@ -4029,7 +3829,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.reh-paper {
|
.reh-paper {
|
||||||
width: min(794px, 100%); background: #fff; color: #1a1a2e;
|
width: min(794px, 100%); background: #fff; color: #1a1a2e;
|
||||||
padding: 56px 72px 40px; box-shadow: 0 4px 24px rgba(0,0,0,.18);
|
padding: 56px 72px 40px; box-shadow: 0 4px 24px rgba(0,0,0,.18);
|
||||||
font-family: 'Courier New', Courier, monospace; font-size: calc(12pt * var(--reh-stage-scale, 1)); line-height: 1.6;
|
font-family: 'Courier New', Courier, monospace; font-size: 12pt; line-height: 1.6;
|
||||||
border-radius: 2px; position: relative; box-sizing: border-box;
|
border-radius: 2px; position: relative; box-sizing: border-box;
|
||||||
min-height: min(1123px, calc((100vw - 32px) * 1.414));
|
min-height: min(1123px, calc((100vw - 32px) * 1.414));
|
||||||
}
|
}
|
||||||
@ -4136,34 +3936,21 @@ 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-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-lib-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
|
||||||
.reader-book {
|
.reader-book {
|
||||||
position: relative; padding: 18px 16px 16px 24px; border: 1px solid var(--border);
|
position: relative; padding: 14px; border: 1px solid var(--border); border-radius: 10px;
|
||||||
border-radius: 4px 14px 14px 4px; background: var(--surface); cursor: pointer;
|
background: var(--panel); cursor: pointer; transition: border-color .15s, transform .1s, box-shadow .15s;
|
||||||
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::before {
|
.reader-book:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(0,0,0,.08); transform: translateY(-1px); }
|
||||||
content: ''; position: absolute; top: 0; bottom: 0; left: 14px; width: 2px;
|
.reader-book-icon { font-size: 26px; color: var(--accent); line-height: 1; }
|
||||||
background: rgba(0,0,0,.04); border-left: 1px solid rgba(255,255,255,.5);
|
.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:hover {
|
.reader-book-bar { position: relative; height: 6px; border-radius: 4px; background: var(--border); margin-top: 10px; overflow: hidden; }
|
||||||
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-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-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); z-index: 1; }
|
.reader-book-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); margin-top: 5px; }
|
||||||
.reader-book-del {
|
.reader-book-del {
|
||||||
position: absolute; top: 8px; right: 8px; border: none; background: rgba(0,0,0,.06);
|
position: absolute; top: 6px; right: 6px; border: none; background: rgba(0,0,0,.04);
|
||||||
color: var(--subtext); border-radius: 6px; width: 28px; height: 28px; cursor: pointer; opacity: 0; transition: opacity .15s;
|
color: var(--subtext); border-radius: 6px; width: 24px; height: 24px; 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:hover .reader-book-del { opacity: 1; }
|
||||||
.reader-book-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.1); }
|
.reader-book-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.1); }
|
||||||
|
|
||||||
@ -4190,141 +3977,40 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
/* Audiobook live casting — floating, non-blocking, minimisable panel */
|
/* Audiobook live casting — floating, non-blocking, minimisable panel */
|
||||||
.ab-castpanel {
|
.ab-castpanel {
|
||||||
position: fixed; right: 18px; bottom: 18px; z-index: 1000;
|
position: fixed; right: 18px; bottom: 18px; z-index: 1000;
|
||||||
width: min(880px, 96vw); height: 50vh; max-height: 82vh; display: flex; flex-direction: column;
|
width: min(880px, 96vw); max-height: 82vh; display: flex; flex-direction: column;
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||||
box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden; resize: both; min-height: 300px; min-width: 400px;
|
box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden;
|
||||||
}
|
}
|
||||||
.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg); cursor: move; }
|
.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); cursor: default; }
|
||||||
.ab-castpanel-head > .mdi { color: var(--accent); font-size: 18px; }
|
.ab-castpanel-head > .mdi { color: var(--accent); font-size: 18px; }
|
||||||
.ab-castpanel-title { font-weight: 800; font-size: 14px; }
|
.ab-castpanel-title { font-weight: 800; font-size: 14px; }
|
||||||
.ab-castpanel-count { font-size: 12px; color: var(--subtext); }
|
.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 { 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-btn:hover { background: var(--panel); color: var(--text); }
|
||||||
.ab-castpanel-bar { margin: 0; border-radius: 0; flex-shrink: 0; }
|
.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); background: var(--bg); flex-shrink: 0; z-index: 10; position: relative; }
|
.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||||
.ab-cv-done { font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
|
.ab-cv-done { font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.ab-cv-done .mdi { color: #10b981; }
|
.ab-cv-done .mdi { color: #10b981; }
|
||||||
/* minimised → just the header chip in the corner */
|
/* minimised → just the header chip in the corner */
|
||||||
.ab-castpanel.minimized { width: auto; max-width: 320px; height: auto; min-height: auto; resize: none; }
|
.ab-castpanel.minimized { width: auto; max-width: 320px; }
|
||||||
.ab-castpanel.minimized .ab-castpanel-bar,
|
.ab-castpanel.minimized .ab-castpanel-bar,
|
||||||
.ab-castpanel.minimized .ab-cv-body,
|
.ab-castpanel.minimized .ab-cv-body,
|
||||||
.ab-castpanel.minimized .ab-castpanel-foot { display: none; }
|
.ab-castpanel.minimized .ab-castpanel-foot { display: none; }
|
||||||
.ab-castpanel.minimized .ab-castpanel-head { cursor: pointer; border-bottom: none; }
|
.ab-castpanel.minimized .ab-castpanel-head { cursor: pointer; border-bottom: none; }
|
||||||
|
|
||||||
.ab-castpanel-inline {
|
.ab-cv-body { display: grid; grid-template-columns: 1fr 220px; gap: 12px; padding: 12px; }
|
||||||
height: calc(100vh - 140px);
|
.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; }
|
||||||
min-height: 400px;
|
.ab-cv-row { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 12.5px; padding: 2px 0; align-items: baseline; }
|
||||||
display: flex;
|
.ab-cv-row.is-narr { opacity: .6; }
|
||||||
flex-direction: column;
|
.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-body { display: grid; grid-template-columns: 1fr 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; }
|
.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-feed-wrap { position: relative; min-height: 0; display: flex; flex-direction: column; }
|
.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; height: 44vh; }
|
||||||
.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-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-chars { display: flex; flex-direction: column; gap: 5px; }
|
||||||
.ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
|
.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; cursor: pointer; transition: background 0.1s; }
|
.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: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-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; }
|
.ab-chip b { margin-left: auto; color: var(--subtext); font-weight: 700; }
|
||||||
.audiobook-narr-note { color: var(--subtext); }
|
.audiobook-narr-note { color: var(--subtext); }
|
||||||
@ -4372,35 +4058,6 @@ 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 { 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 b { color: var(--text); }
|
||||||
.cs-sources .mdi { color: var(--accent); }
|
.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-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-track { width: 130px; height: 6px; border-radius: 4px; background: var(--panel); overflow: hidden; }
|
||||||
.reader-synth-fill { height: 100%; width: 0; background: rgba(234,179,8,.9); transition: width .15s; }
|
.reader-synth-fill { height: 100%; width: 0; background: rgba(234,179,8,.9); transition: width .15s; }
|
||||||
@ -4438,15 +4095,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.reader-leg-reading::before { background: rgba(37,99,235,.6); }
|
.reader-leg-reading::before { background: rgba(37,99,235,.6); }
|
||||||
|
|
||||||
.reader-doc {
|
.reader-doc {
|
||||||
margin-top: 14px; max-height: 72vh; overflow-y: auto; overflow-x: auto;
|
margin-top: 14px; max-height: 72vh; overflow-y: auto;
|
||||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
padding: 16px; display: flex; flex-direction: column; gap: 16px;
|
padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 16px;
|
||||||
}
|
}
|
||||||
.reader-doc:empty { display: none; }
|
.reader-doc:empty { display: none; }
|
||||||
.reader-parsing { display: flex; align-items: center; gap: 10px; padding: 24px; color: var(--subtext); font-size: 13px; margin: 0 auto; }
|
.reader-parsing { display: flex; align-items: center; gap: 10px; padding: 24px; color: var(--subtext); font-size: 13px; }
|
||||||
.reader-doc.two-page { flex-direction: row; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
|
.reader-doc.two-page { flex-direction: row; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
|
||||||
.reader-page {
|
.reader-page {
|
||||||
position: relative; flex: 0 0 auto; margin: 0 auto;
|
position: relative; flex: 0 0 auto;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,.12); background: #fff;
|
box-shadow: 0 2px 10px rgba(0,0,0,.12); background: #fff;
|
||||||
}
|
}
|
||||||
.reader-canvas { display: block; }
|
.reader-canvas { display: block; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user