Release v1.8.1

This commit is contained in:
mARTin-B78 2026-06-25 18:48:25 +02:00
parent ca599be5c3
commit 18b4c1959c
27 changed files with 1994 additions and 1210 deletions

View File

@ -9,6 +9,23 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.8.1] — 2026-06-25
### Added
- **Audiobook casting — Warmup request**: Added an invisible "Wake up" request before starting the attribution loop, showing a clear "Waking up LLM model..." status. This absorbs the 3–4 minute cold-boot time of massive models (like 120B/30B via llama-swap) without freezing the UI or timing out the first real book chunk.
- **Audiobook casting — Quick-cancel**: The character assignment popup now has a close button and responds to the `Escape` key, so you can easily dismiss it if you click "Assign" by accident.
### Changed
- **Audiobook casting — Narrator quick-pick**: The `📖 Narrator` role is now always pinned to the top of the manual assignment dropdown list, so you don't have to scroll or type to revert a mis-cast line to narration.
- **LLM timeout increased**: The hardcoded API timeout in `routes/conversation.py` for all LLM calls has been increased from 3 minutes to 10 minutes (`timeout=600`), providing plenty of headroom for dynamic model proxies to download and load models into VRAM on demand.
### Fixed
- **Read Aloud — "Fit width" scaling bug**: Clicking "Fit width" on PDFs with a small cover page (e.g. A5) but larger subsequent pages previously zoomed the cover perfectly but blew the text pages up massively (e.g. 241%), forcing horizontal scrolling. The scale is now computed against the *maximum* width of all pages, guaranteeing the entire book fits.
- **Read Aloud — Left-side text cut-off (CSS bug)**: Fixed a flexbox centering issue on the document container where a zoomed PDF page that was wider than the screen would overflow equally on both sides. Because browsers only scroll to the right, the left edge of the page was permanently inaccessible. Fixed by replacing `align-items: center` with `margin: 0 auto`.
- **Audiobook casting — Minified bundle caching**: Features added directly to JS files weren't appearing because the app was serving an older, cached minified bundle. The bundle has been rebuilt and version cache-busting ensures the new UI shows up immediately.
---
## [1.8.0] — 2026-06-22
### Added
@ -393,7 +410,8 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.1...HEAD
[1.8.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.7.0...v1.8.0
[1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.0.0...v1.1.0

View File

@ -1 +1 @@
1.8.0
1.8.1

View File

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

View File

@ -64,6 +64,7 @@ def _sentence_split(buf: str) -> int:
def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str:
"""Inline synchronous persona rewrite; raises RuntimeError on failure."""
settings = _load_settings()
system = (
f"Rephrase the user's text as if spoken by this character: {persona}\n"
"Keep the same meaning but adapt vocabulary, tone, and style to the character. "
@ -82,11 +83,12 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str
resp = requests.post(
f"{llm_url.rstrip('/')}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60,
)
resp.raise_for_status()
result = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
if result.startswith('"') and result.endswith('"'):
result = result[1:-1].strip()
return result
@ -149,11 +151,12 @@ async def refine_text(request: Request):
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60,
)
resp.raise_for_status()
refined = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
refined = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
if refined.startswith('"') and refined.endswith('"'):
refined = refined[1:-1].strip()
return {"text": refined, "original": text}
@ -209,11 +212,12 @@ async def rewrite_with_persona(request: Request):
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
timeout=60,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
resp.raise_for_status()
result = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
if result.startswith('"') and result.endswith('"'):
result = result[1:-1].strip()
return {"text": result, "original": text, "persona": persona}
@ -291,11 +295,12 @@ async def analyze_characters(request: Request):
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
timeout=180,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
for candidate in (content, _extract_json_block(content)):
if not candidate:
@ -389,10 +394,11 @@ async def match_characters_voices(request: Request):
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=180,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
break
except requests.exceptions.ConnectionError as e:
# llama-swap (and similar) often drop the first request while swapping/loading
@ -507,10 +513,11 @@ async def character_sheets(request: Request):
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=240,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
except Exception as e:
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
@ -568,35 +575,45 @@ async def attribute_dialogue(request: Request):
raise HTTPException(400, "No text provided")
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
base_prompt = _settings.get("audiobook_prompt") or ""
if not base_prompt.strip():
base_prompt = (
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
"Split the passage into consecutive segments in reading order. For each segment output:\n"
"- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. "
"Use 'Unknown' ONLY as an absolute last resort.\n"
"- type: 'narration' or 'dialogue'\n"
"- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration\n"
"- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration\n"
f"{lang_hint}\n"
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\n"
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
"French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line "
"starts with — or – (Spanish/French/Polish style).\n"
"German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n"
"ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n"
"1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns "
"(er/sie/he/she) to the actual name from nearby context.\n"
"2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES "
"every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.\n"
"3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the "
"'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the "
"known-characters list. Reuse the EXACT known names.\n"
"4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and "
"context — this should be rare. Prefer the most likely named character over 'Unknown'.\n"
"RULES:\n"
"- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.\n"
"- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts "
"into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.\n"
"- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order."
)
else:
if lang_hint:
base_prompt += f"\n\n{lang_hint}"
system = (
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
"Split the passage into consecutive segments in reading order. For each segment output:\n"
"- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. "
"Use 'Unknown' ONLY as an absolute last resort.\n"
"- type: 'narration' or 'dialogue'\n"
"- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration\n"
"- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration\n"
f"{lang_hint}\n"
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\n"
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
"French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line "
"starts with — or – (Spanish/French/Polish style).\n"
"German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n"
"ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n"
"1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns "
"(er/sie/he/she) to the actual name from nearby context.\n"
"2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES "
"every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.\n"
"3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the "
"'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the "
"known-characters list. Reuse the EXACT known names.\n"
"4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and "
"context — this should be rare. Prefer the most likely named character over 'Unknown'.\n"
"RULES:\n"
"- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.\n"
"- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts "
"into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.\n"
"- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.\n"
f"{base_prompt}\n\n"
"Respond with STRICT JSON only:\n"
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}\n/no-think'
)
@ -611,23 +628,31 @@ async def attribute_dialogue(request: Request):
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": min(4096, max(700, len(text) // 2 + 800)),
"max_tokens": min(8192, max(2048, len(text) + 1000)),
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=180,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or msg.get("reasoning_content") or "").strip()
except Exception as e:
raise HTTPException(502, f"LLM attribution failed: {e}")
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
segments = []
for cand in (content, _extract_json_block(content)):
block = _extract_json_block(content)
candidates = [content, block]
if block:
# If the JSON was truncated by token limits, appending ]} to the last complete object often salvages it.
candidates.append(block + "]}")
for cand in candidates:
if not cand:
continue
try:
@ -637,6 +662,57 @@ async def attribute_dialogue(request: Request):
break
except Exception:
continue
# Fallback if the user's custom prompt forced the LLM to output a raw text script
# instead of the requested JSON schema.
if not segments:
for line in content.split('\n'):
line = line.strip()
if not line:
continue
# Match formats like "ANNA: text", "**THOMAS** (angry): text", or "1. Narrator: text"
# Strip all markdown asterisks and leading list numbers
line_clean = line.replace("*", "").strip()
line_clean = re.sub(r"^[\d\.\-\s]+", "", line_clean).strip()
m = re.match(r"^([A-ZÄÖÜa-zäöüß0-9\s]+?)(?:\s*\([^)]+\))?\s*:\s*(.*)", line_clean)
if m:
sp = m.group(1).strip()
txt = m.group(2).strip()
# If the LLM regurgitated the prompt as Markdown, ignore these fake "speakers"
if len(sp) > 30 or sp.lower() in {
"task", "output format", "segments", "language", "quotation styles",
"speaker attribution", "constraint", "input", "output text",
"fields per segment", "rules", "note", "context setup",
"introductory narration", "closing narration", "the text says",
"speaker name", "note on language", "note on metadata",
"note on quotation marks", "segment 1", "segment 2", "segment 3",
"segment 4", "segment 5", "text", "speaker", "emotion", "type"
} or sp.lower().startswith("segment "):
# Treat it as garbage/narration so it doesn't pollute the character list
segments.append({"speaker": "Narrator", "type": "narration", "text": line_clean, "emotion": ""})
continue
# Strip leading/trailing quotation marks that LLM might have included
txt = re.sub(r'^["\'«»„“\*]+', '', txt)
txt = re.sub(r'["\'«»„“\*]+$', '', txt)
# Extract emotion if present in parentheses before the colon
emo = ""
emo_m = re.search(r"\(([^)]+)\)", line_clean.split(":", 1)[0])
if emo_m:
emo = emo_m.group(1).strip()
if sp.upper() in ("NARRATOR", "ERZÄHLER"):
segments.append({"speaker": "Narrator", "type": "narration", "text": txt, "emotion": emo})
else:
segments.append({"speaker": sp, "type": "dialogue", "text": txt, "emotion": emo})
else:
# No speaker prefix, assume it's narration (or LLM is just rambling)
txt = re.sub(r'^["\'«»„“\*]+', '', line)
txt = re.sub(r'["\'«»„“\*]+$', '', txt)
segments.append({"speaker": "Narrator", "type": "narration", "text": txt, "emotion": ""})
# Normalise + collect speaker roster
clean, chars = [], []
for seg in segments:
@ -1106,14 +1182,23 @@ async def mcp_sse(request: Request):
# ── Conversation playground ───────────────────────────────────────────────────
@router.get("/api/conversation/llm-models")
async def conversation_llm_models(url: str = ""):
async def conversation_llm_models(url: str = "", api_key: str = ""):
"""List models from a local LLM endpoint (Ollama / vLLM / LM Studio)."""
from core.validation import _validate_http_url
settings = _load_settings()
base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
key_to_use = api_key or settings.get("llm_api_key") or "sk-dummy-key"
try:
base = _validate_http_url(base, allow_private=True)
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": "Bearer no-key"})
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
# Fallback for gateways like LiteLLM that might prefer /models over /v1/models
if r.status_code != 200 and base.endswith("/v1"):
fallback_base = base[:-3]
r2 = requests.get(f"{fallback_base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
if r2.status_code == 200:
r = r2
if r.status_code == 200:
payload = r.json()
data = payload.get("data", []) if isinstance(payload, dict) else []
@ -1122,9 +1207,10 @@ async def conversation_llm_models(url: str = ""):
for item in data if item
]
return {"models": models, "url": base}
except Exception:
pass
return {"models": [], "url": base}
else:
return {"models": [], "url": base, "error": r.text}
except Exception as e:
return {"models": [], "url": base, "error": str(e)}
def _make_tts_task(
@ -1242,7 +1328,7 @@ async def conversation_turn(
try:
resp = requests.post(
f"{eff_llm_url}/chat/completions", json=llm_payload,
headers={"Authorization": "Bearer no-key"}, stream=True, timeout=120,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, stream=True, timeout=120,
)
resp.raise_for_status()
for raw_line in resp.iter_lines():

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import Response
@ -18,7 +19,19 @@ logger = logging.getLogger("uvicorn.error")
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(title="TTS Voice Creator - Clone and Design")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Uvicorn resets loggers during startup when run via CLI.
# We must attach our handlers here to ensure they capture the active logs.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
l = logging.getLogger(name)
if _buf_handler not in l.handlers:
l.addHandler(_buf_handler)
if "_file_handler" in globals() and _file_handler not in l.handlers:
l.addHandler(_file_handler)
yield
app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan)
# ── Response compression ──────────────────────────────────────────────────────
# Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the
@ -30,8 +43,6 @@ app.add_middleware(GZipMiddleware, minimum_size=1024)
_buf_handler = _BufferHandler()
_buf_handler.setLevel(logging.DEBUG)
logging.getLogger().addHandler(_buf_handler)
logging.getLogger("uvicorn.error").addHandler(_buf_handler)
logging.getLogger("uvicorn.access").addHandler(_buf_handler)
# ── File log handler (rotates at 5 MB, keeps 3 files) ────────────────────────
@ -50,8 +61,6 @@ try:
datefmt="%Y-%m-%d %H:%M:%S",
))
logging.getLogger().addHandler(_file_handler)
logging.getLogger("uvicorn.error").addHandler(_file_handler)
logging.getLogger("uvicorn.access").addHandler(_file_handler)
except Exception as _log_err:
logger.warning("Could not set up file logging at %s: %s", _LOG_DIR, _log_err)

View File

@ -147,7 +147,16 @@
<div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
</div>
<div class="nav-item" data-nav-section="s-reader" onclick="navTo('s-reader')"> <span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span> Read Aloud</div>
<div class="nav-item nav-tree-head" data-nav-section="s-reader" id="nav-reader-head" onclick="navTo('s-reader')">
<span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span>
<span class="nav-label">Read Aloud</span>
<span class="nav-chevron" id="nav-reader-chevron" onclick="event.stopPropagation();toggleNavTree('nav-reader-tree','nav-reader-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-reader-tree">
<div class="nav-tree-item is-active" data-reader-view="main" onclick="navReaderView('main')"><span class="mdi mdi-book-open-outline"></span> Reader</div>
<div class="nav-tree-item" data-reader-view="cast" onclick="navReaderView('cast')"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
<div class="nav-tree-item" data-reader-view="library" onclick="navReaderView('library')"><span class="mdi mdi-bookshelf"></span> Library</div>
</div>
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
@ -160,10 +169,11 @@
<div class="nav-tree" id="nav-engines-tree">
<div class="nav-tree-item is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')"><span class="mdi mdi-brain"></span> Language Models</div>
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-text-to-speech"></span> Text to Speech</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-account-voice"></span> Text to Speech</div>
</div>
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')"> <span class="nav-icon"><span class="mdi mdi-swap-horizontal"></span></span> App Routing</div>
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')"> <span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps</div>
<div class="nav-item nav-tree-head" data-nav-section="s-settings" id="nav-settings-head" onclick="navTo('s-settings')">
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span>
<span class="nav-label">Settings</span>
@ -171,9 +181,8 @@
</div>
<div class="nav-tree" id="nav-settings-tree">
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')"><span class="mdi mdi-tune-vertical"></span> General</div>
<div class="nav-tree-item" data-settings-cat="connections" onclick="navSettingsCat('connections')"><span class="mdi mdi-lan-connect"></span> Connections</div>
<div class="nav-tree-item" data-settings-cat="playback" onclick="navSettingsCat('playback')"><span class="mdi mdi-speaker-outline"></span> Playback</div>
<div class="nav-tree-item" data-settings-cat="captures" onclick="navSettingsCat('captures')"><span class="mdi mdi-record-circle-outline"></span> Captures</div>
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')"><span class="mdi mdi-code-json"></span> Payloads</div>
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')"><span class="mdi mdi-folder-outline"></span> Storage</div>
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')"><span class="mdi mdi-key-outline"></span> API Keys</div>
@ -270,6 +279,28 @@
<div id="toast" aria-live="polite" aria-atomic="true"></div>
<div id="status-bar" role="status" aria-live="polite" aria-atomic="true">Loading…</div>
<!-- Global Directory Browser Modal -->
<div class="reh-modal-overlay" id="global-dir-browser-modal" hidden style="z-index:9999;">
<div class="reh-modal-box" style="width:500px; max-width:90vw; max-height:80vh;">
<div class="reh-modal-head">
<span>Browse Container Folders</span>
<button type="button" class="btn-icon" onclick="document.getElementById('global-dir-browser-modal').hidden = true;"><span class="mdi mdi-close"></span></button>
</div>
<div style="padding:16px; overflow-y:auto; flex:1;">
<div class="vef-dir-browser" style="margin-top:0; border:1px solid var(--border); border-radius:var(--radius); background:var(--bg-card); display:flex; flex-direction:column; min-height: 250px;">
<div class="vef-breadcrumb" id="global-dir-browser-crumb" style="padding:8px 12px; border-bottom:1px solid var(--border); background:var(--bg-sunken); overflow-x:auto; white-space:nowrap;"></div>
<div class="vef-dir-list" id="global-dir-browser-list" style="padding:8px; overflow-y:auto; flex:1; max-height:300px;"><span class="vef-loading" style="padding:8px; color:var(--text-muted);">Loading…</span></div>
</div>
<p class="note" style="margin-top:12px;">This browses the filesystem <strong>inside</strong> the Docker container, not your host computer. Ensure volumes are mapped correctly.</p>
</div>
<div style="padding:16px; border-top:1px solid var(--border); display:flex; gap:12px; align-items:center;">
<span class="note" id="global-dir-browser-path" style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:monospace;">/</span>
<button class="btn-secondary" type="button" onclick="document.getElementById('global-dir-browser-modal').hidden = true;">Cancel</button>
<button class="btn-primary" type="button" id="global-dir-browser-select">Select Folder</button>
</div>
</div>
</div>
<!-- WaveSurfer — self-hosted, loaded synchronously (voice waveform requires it) -->
<script src="/static/vendor/wavesurfer.min.js"></script>
<script src="/static/vendor/wavesurfer-regions.min.js"></script>

View File

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

View File

@ -133,20 +133,50 @@ const _AB_PALETTE = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec
// Live casting view: a scrolling feed of attributed lines + a character roster
// that fills up as speakers are discovered. Far clearer than a bare bar.
function audiobookCastView(total) {
document.getElementById('audiobook-overlay')?.remove();
// A floating, NON-blocking, minimisable panel — keep working in the app and
// come back to watch progress (no backdrop, no modal lock).
const ov = document.createElement('div');
ov.id = 'audiobook-overlay'; ov.className = 'ab-castpanel';
ov.innerHTML = `
function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
const panel = document.getElementById('reader-audiobook-panel');
if (!panel) return;
if (typeof window.navReaderView === 'function') window.navReaderView('cast');
else if (typeof window.showReaderView === 'function') window.showReaderView('cast');
else {
const mainView = document.getElementById('reader-main-view');
if (mainView) mainView.hidden = true;
panel.hidden = false;
}
panel.className = 'ab-castpanel-inline card';
panel.style.display = 'flex';
panel.style.flexDirection = 'column';
panel.style.minHeight = '500px';
panel.innerHTML = `
<div class="ab-castpanel-head" id="ab-cv-head">
<span class="mdi mdi-drama-masks"></span>
<span class="ab-castpanel-title">Casting audiobook</span>
<span class="ab-castpanel-count" id="ab-cv-count">passage 0 / ${total}</span>
<span style="flex:1"></span>
<button class="ab-castpanel-btn" id="ab-cv-min" title="Minimise — keep working, come back later"><span class="mdi mdi-window-minimize"></span></button>
<button class="ab-castpanel-btn" id="ab-cv-x" title="Cancel"><span class="mdi mdi-close"></span></button>
<label for="ab-cv-llm-url" style="font-size:12px; font-weight:600; color:var(--subtext); margin-right:4px;">LLM Engine</label>
<select id="ab-cv-llm-url" style="width:260px; margin-right:4px; padding:2px 6px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none; appearance:auto; cursor:pointer;" aria-label="LLM API URL">
<option value="${escHtml(llmUrl)}">${escHtml(llmUrl || '— Select an endpoint —')}</option>
</select>
<button class="ab-castpanel-btn" id="ab-cv-llm-refresh" style="margin-right:4px;" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="ab-cv-llm-select" style="width:220px; padding:2px 6px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none;" aria-label="LLM Model" title="Change LLM model for character extraction">
<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>
</select>
<span style="margin-right:8px;"></span>
<button class="ab-castpanel-btn" id="ab-cv-prompt-btn" title="Edit Casting Prompt"><span class="mdi mdi-text-box-edit-outline"></span></button>
</div>
<div class="ab-castpanel-prompt" id="ab-cv-prompt-panel" style="padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border);">
<textarea id="ab-cv-prompt-text" rows="8" style="width:100%; font-family:monospace; font-size:13px; font-weight:500; padding:10px; line-height:1.5; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text); resize:vertical;" placeholder="LLM casting instructions..."></textarea>
<div style="display:flex; justify-content:flex-end; margin-top:8px; gap:8px; align-items:center;">
<select id="ab-cv-prompt-lib" class="btn-secondary btn-sm" style="max-width:200px; outline:none; border:1px solid var(--border); border-radius:4px; padding:4px 8px; font-size:12px; background:var(--surface);" aria-label="Load a saved prompt">
<option value="">— Load saved prompt —</option>
</select>
<button class="btn-secondary btn-sm" id="ab-cv-prompt-del" title="Delete selected prompt" style="display:none; color:var(--error);"><span class="mdi mdi-delete-outline"></span></button>
<input type="text" id="ab-cv-prompt-name" style="width:140px; padding:4px 8px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none;" placeholder="Preset name...">
<button class="btn-primary btn-sm" id="ab-cv-prompt-save" title="Save current prompt as a preset"><span class="mdi mdi-content-save"></span> Save preset</button>
</div>
</div>
<div class="reader-synth-track ab-castpanel-bar"><div class="reader-synth-fill" id="ab-cv-fill"></div></div>
<div class="ab-cv-body">
@ -156,26 +186,201 @@ function audiobookCastView(total) {
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">listening…</span></div>
</div>
</div>
<div class="ab-castpanel-foot" id="ab-cv-foot" hidden></div>`;
document.body.appendChild(ov);
const setMin = min => {
ov.classList.toggle('minimized', min);
const ic = ov.querySelector('#ab-cv-min .mdi');
if (ic) ic.className = 'mdi ' + (min ? 'mdi-window-maximize' : 'mdi-window-minimize');
</div>
<div class="ab-castpanel-foot" id="ab-cv-foot" style="border-top:1px solid var(--border); padding:10px 12px; display:flex; justify-content:flex-end; align-items:center;">
<span class="ab-cv-status" id="ab-cv-status-msg" style="color:var(--subtext); font-size:12px; flex:1; display:none;">Ready to cast.</span>
<button class="btn-secondary btn-sm" id="ab-cv-cancel" style="color:var(--red); margin-right:8px;"><span class="mdi mdi-close"></span> Cancel</button>
<button class="btn-secondary btn-sm" id="ab-cv-start-recast-unk" style="display:none; margin-right:8px; color:var(--error);" title="Re-run only Unknown segments"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button>
<button class="btn-secondary btn-sm" id="ab-cv-start-recast" style="display:none; margin-right:8px;" title="Re-run entire document"><span class="mdi mdi-refresh"></span> Recast all</button>
<button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button>
</div>`;
const AB_DEFAULT_PROMPT = `You attribute dialogue in prose fiction for a multi-voice audiobook. Split the passage into consecutive segments in reading order. For each segment output:
- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. Use 'Unknown' ONLY as an absolute last resort.
- type: 'narration' or 'dialogue'
- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration
- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration
QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:
English straight "..." and curly “...”; German »...« (guillemets pointing inward) and „...“; French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line starts with — or – (Spanish/French/Polish style).
German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.
ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):
1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns (er/sie/he/she) to the actual name from nearby context.
2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.
3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the 'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the known-characters list. Reuse the EXACT known names.
4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and context — this should be rare. Prefer the most likely named character over 'Unknown'.
RULES:
- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.
- 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.
- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.`;
const globalPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT;
panel.querySelector('#ab-cv-prompt-text').value = globalPrompt;
const closePanel = () => {
panel.hidden = true;
panel.innerHTML = '';
if (typeof window.navReaderView === 'function') window.navReaderView('main');
else if (typeof window.showReaderView === 'function') window.showReaderView('main');
else {
const mv = document.getElementById('reader-main-view');
if (mv) mv.hidden = false;
}
};
ov.querySelector('#ab-cv-min').addEventListener('click', () => setMin(!ov.classList.contains('minimized')));
ov.querySelector('#ab-cv-x').addEventListener('click', () => {
panel.querySelector('#ab-cv-cancel').addEventListener('click', () => {
if (_audiobook.running) {
_audiobook.cancel = true;
if (typeof _audiobook.abort === 'function') _audiobook.abort();
ov.remove();
}
closePanel();
});
panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => {
const p = panel.querySelector('#ab-cv-prompt-panel');
p.hidden = !p.hidden;
});
// Prompt Library logic
let savedPrompts = [];
try { savedPrompts = JSON.parse(localStorage.getItem('ttsvc_ab_prompts') || '[]'); } catch (_) { savedPrompts = []; }
const libSelect = panel.querySelector('#ab-cv-prompt-lib');
const delBtn = panel.querySelector('#ab-cv-prompt-del');
const promptText = panel.querySelector('#ab-cv-prompt-text');
const renderPromptLib = (selectedIdx = -1) => {
libSelect.innerHTML = '<option value="">— Load saved prompt —</option>' +
savedPrompts.map((p, i) => `<option value="${i}">${escHtml(p.name)}</option>`).join('');
if (selectedIdx >= 0) {
libSelect.value = selectedIdx;
delBtn.style.display = 'block';
} else {
ov.remove();
libSelect.value = '';
delBtn.style.display = 'none';
}
};
renderPromptLib();
libSelect.addEventListener('change', () => {
const idx = parseInt(libSelect.value);
const nameInput = panel.querySelector('#ab-cv-prompt-name');
if (!isNaN(idx) && savedPrompts[idx]) {
promptText.value = savedPrompts[idx].prompt;
if (nameInput) nameInput.value = savedPrompts[idx].name;
delBtn.style.display = 'block';
} else {
if (nameInput) nameInput.value = '';
delBtn.style.display = 'none';
}
});
ov.querySelector('#ab-cv-head').addEventListener('click', e => { if (ov.classList.contains('minimized') && !e.target.closest('button')) setMin(false); });
const fill = ov.querySelector('#ab-cv-fill'), count = ov.querySelector('#ab-cv-count');
const feed = ov.querySelector('#ab-cv-feed'), chars = ov.querySelector('#ab-cv-chars');
delBtn.addEventListener('click', () => {
const idx = parseInt(libSelect.value);
if (isNaN(idx)) return;
if (confirm('Delete this saved prompt preset?')) {
savedPrompts.splice(idx, 1);
localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts));
renderPromptLib();
toast('Prompt deleted', 'success');
}
});
panel.querySelector('#ab-cv-prompt-save').addEventListener('click', async () => {
const val = promptText.value.trim();
if (!val) { toast('Prompt is empty', 'error'); return; }
const nameInput = panel.querySelector('#ab-cv-prompt-name');
const name = nameInput.value.trim() || 'Custom Prompt ' + (savedPrompts.length + 1);
let targetIdx = parseInt(libSelect.value);
if (!isNaN(targetIdx) && savedPrompts[targetIdx] && savedPrompts[targetIdx].name === name) {
savedPrompts[targetIdx].prompt = val;
} else {
savedPrompts.push({ name, prompt: val });
targetIdx = savedPrompts.length - 1;
}
localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts));
renderPromptLib(targetIdx);
toast('Prompt preset saved', 'success');
// Also save as global default for next time
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = val;
try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: val }) }); } catch (e) {}
});
// Sync the LLM engine dropdown with global options
const syncEngineSelect = () => {
const localSel = panel.querySelector('#ab-cv-llm-url');
const globalSel = document.getElementById('llm-active-url');
if (globalSel && localSel) {
const currentVal = localSel.value;
localSel.innerHTML = globalSel.innerHTML;
localSel.value = currentVal || globalSel.value || '';
}
};
syncEngineSelect();
// Fetch available models and populate the select drop-down
const loadModels = () => {
const urlInput = panel.querySelector('#ab-cv-llm-url');
const sel = panel.querySelector('#ab-cv-llm-select');
if (!sel || !urlInput) return;
const currentUrl = urlInput.value.trim();
const oldVal = sel.value;
sel.innerHTML = '<option value="">loading...</option>';
fetch('/api/conversation/llm-models' + (currentUrl ? '?url=' + encodeURIComponent(currentUrl) : ''))
.then(r => r.json())
.then(d => {
if (d.models && d.models.length) {
sel.innerHTML = d.models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
if (oldVal && d.models.includes(oldVal)) sel.value = oldVal;
else if (d.models.includes(defaultModel)) sel.value = defaultModel;
} else {
sel.innerHTML = `<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>`;
}
}).catch(() => {
sel.innerHTML = `<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>`;
});
};
loadModels();
panel.querySelector('#ab-cv-llm-refresh').addEventListener('click', loadModels);
const urlInput = panel.querySelector('#ab-cv-llm-url');
if (urlInput) urlInput.addEventListener('change', loadModels);
const applyPromptAndRun = (callback) => {
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim();
const currentModel = panel.querySelector('#ab-cv-llm-select')?.value;
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) })
.finally(() => {
if (callback) callback(currentUrl, currentModel);
});
};
if (isIdle) {
const hasSegs = _audiobook.segments && _audiobook.segments.length > 0;
panel.querySelector('#ab-cv-status-msg').style.display = 'inline-block';
if (hasSegs) {
const recastUnkBtn = panel.querySelector('#ab-cv-start-recast-unk');
const recastBtn = panel.querySelector('#ab-cv-start-recast');
recastUnkBtn.style.display = 'inline-block';
recastBtn.style.display = 'inline-block';
recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown));
recastBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast));
panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to recast.';
} else {
const castBtn = panel.querySelector('#ab-cv-start-cast');
castBtn.style.display = 'inline-block';
castBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast));
}
} else {
panel.querySelector('#ab-cv-cancel').innerHTML = '<span class="mdi mdi-stop"></span> Stop Casting';
}
const fill = panel.querySelector('#ab-cv-fill'), count = panel.querySelector('#ab-cv-count');
const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars');
const roster = new Map(); // name -> { count, color }
const colorFor = name => {
if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] });
@ -189,18 +394,300 @@ function audiobookCastView(total) {
};
const MAXROWS = 80;
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
// Manual character assignment logic
let assignModeSeg = null;
let assignModeRow = null;
let assignPopup = document.getElementById('ab-cv-assign-popup');
if (!assignPopup) {
assignPopup = document.createElement('div');
assignPopup.id = 'ab-cv-assign-popup';
assignPopup.style.cssText = 'position:fixed; z-index:2001; display:none; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.4); padding:10px; width:220px; max-height:320px; flex-direction:column; gap:8px;';
const header = document.createElement('div');
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:-4px; margin-top:-4px;';
header.innerHTML = '<span style="font-size:11px; font-weight:600; color:var(--subtext); text-transform:uppercase;">Assign to</span><span class="mdi mdi-close" style="cursor:pointer; font-size:16px; color:var(--subtext); padding:4px;" title="Close (Esc)"></span>';
assignPopup.appendChild(header);
const inp = document.createElement('input');
inp.type = 'text'; inp.placeholder = 'Type new character...';
inp.style.cssText = 'width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);';
assignPopup.appendChild(inp);
const list = document.createElement('div');
list.className = 'ab-cv-popup-list';
list.style.cssText = 'display:flex; flex-direction:column; gap:2px; overflow-y:auto; max-height:220px; margin-right:-4px; padding-right:4px;';
assignPopup.appendChild(list);
document.body.appendChild(assignPopup);
header.querySelector('.mdi-close').addEventListener('click', closeAssignPopup);
document.addEventListener('mousedown', e => {
if (assignPopup.style.display !== 'none' && !assignPopup.contains(e.target) && !e.target.closest('.ab-cv-spk')) {
closeAssignPopup();
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && assignPopup.style.display !== 'none') {
closeAssignPopup();
}
});
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const val = inp.value.trim();
if (val) assignName(val);
} else if (e.key === 'Escape') {
closeAssignPopup();
}
});
}
function closeAssignPopup() {
assignPopup.style.display = 'none';
if (assignModeRow) assignModeRow.classList.remove('is-assigning');
assignModeSeg = null;
assignModeRow = null;
}
function assignName(name) {
if (!assignModeSeg) return;
const isNarrator = name.toLowerCase() === 'narrator';
assignModeSeg.speaker = isNarrator ? 'Narrator' : name;
assignModeSeg.type = isNarrator ? 'narration' : 'dialogue';
if (isNarrator) assignModeSeg.emotion = '';
assignModeRow.classList.remove('is-assigning');
if (isNarrator) {
assignModeRow.className = 'ab-cv-row is-narr';
assignModeRow.querySelector('.ab-cv-spk').innerHTML = 'Narrator';
assignModeRow.querySelector('.ab-cv-spk').style.color = '';
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
} else {
const c = colorFor(name);
if (!roster.has(name)) roster.set(name, { count: 0, color: c });
roster.get(name).count++;
assignModeRow.className = 'ab-cv-row';
assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(name)}${assignModeSeg.emotion ? ' · ' + escHtml(assignModeSeg.emotion) : ''}`;
assignModeRow.querySelector('.ab-cv-spk').style.color = c;
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
}
renderRoster();
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
closeAssignPopup();
}
feed.addEventListener('click', e => {
const spk = e.target.closest('.ab-cv-spk');
if (spk) {
const row = spk.closest('.ab-cv-row');
if (row && row.__seg && !row.classList.contains('is-processing')) {
if (assignModeRow) assignModeRow.classList.remove('is-assigning');
assignModeSeg = row.__seg;
assignModeRow = row;
row.classList.add('is-assigning');
window.getSelection().removeAllRanges();
assignPopup.style.display = 'flex';
const rect = spk.getBoundingClientRect();
// ensure popup doesn't go off bottom of screen
const top = Math.min(rect.bottom + 4, window.innerHeight - 300);
assignPopup.style.top = top + 'px';
assignPopup.style.left = rect.left + 'px';
const list = assignPopup.querySelector('.ab-cv-popup-list');
list.innerHTML = '';
// Always show Narrator as the first option
const narrBtn = document.createElement('div');
narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;';
narrBtn.innerHTML = `<span style="font-size:13px;">📖</span> Narrator`;
narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)';
narrBtn.onmouseout = () => narrBtn.style.background = 'transparent';
narrBtn.onclick = () => assignName('Narrator');
list.appendChild(narrBtn);
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
for (const [n, info] of items) {
const btn = document.createElement('div');
btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);';
btn.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};"></span> ${escHtml(n)}`;
btn.onmouseover = () => btn.style.background = 'var(--panel)';
btn.onmouseout = () => btn.style.background = 'transparent';
btn.onclick = () => assignName(n);
list.appendChild(btn);
}
const inp = assignPopup.querySelector('input');
inp.value = '';
setTimeout(() => inp.focus(), 50);
}
}
});
let splitBtn = document.getElementById('ab-cv-split-btn');
if (!splitBtn) {
splitBtn = document.createElement('button');
splitBtn.id = 'ab-cv-split-btn';
splitBtn.className = 'btn-secondary btn-sm';
splitBtn.innerHTML = '<span class="mdi mdi-call-split"></span> Split text to Unknown Speaker';
splitBtn.style.cssText = 'position:fixed; z-index:2000; display:none; background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px rgba(0,0,0,0.3);';
document.body.appendChild(splitBtn);
}
let currentSplitState = null;
document.addEventListener('selectionchange', () => {
if (!feed) return; // Panel closed
const sel = window.getSelection();
if (sel.isCollapsed || !feed.contains(sel.anchorNode)) {
if (splitBtn) splitBtn.style.display = 'none';
currentSplitState = null;
return;
}
const txtSpan = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode.closest('.ab-cv-txt') : sel.anchorNode.closest('.ab-cv-txt');
if (!txtSpan) { splitBtn.style.display = 'none'; return; }
const row = txtSpan.closest('.ab-cv-row');
if (!row || !row.__seg) return;
const text = sel.toString().trim();
if (text.length > 0) {
// If we are in assign mode and they selected a short text, it's for assigning a name. Don't show split.
if (assignModeSeg && text.length < 40 && !text.includes('\n')) {
splitBtn.style.display = 'none';
return;
}
const rect = sel.getRangeAt(0).getBoundingClientRect();
splitBtn.style.display = 'block';
splitBtn.style.top = (rect.bottom + 8) + 'px';
splitBtn.style.left = Math.max(10, rect.left + (rect.width / 2) - 100) + 'px';
currentSplitState = { row, text, seg: row.__seg };
} else {
splitBtn.style.display = 'none';
currentSplitState = null;
}
});
splitBtn.addEventListener('click', () => {
if (!currentSplitState) return;
const { row, text, seg } = currentSplitState;
const fullText = seg.text || '';
const idx = fullText.indexOf(text);
if (idx === -1) return;
const before = fullText.substring(0, idx).trim();
const after = fullText.substring(idx + text.length).trim();
let arr = _audiobook.segments || [];
// if it's currently casting, they are in allSegments (which we can't easily reference), but we can just update the array when it finishes.
// To be safe, we just modify the object and insert new objects.
const globalIdx = arr.indexOf(seg);
const newSegs = [];
if (before) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before });
newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: text });
if (after) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after });
if (globalIdx !== -1) arr.splice(globalIdx, 1, ...newSegs);
else {
// It's casting right now. We can just replace the row visually, and at the end of casting it uses the segments array.
// Wait, allSegments holds the real ones. If we can't find it in _audiobook.segments, we're in trouble.
// Just flag the row. Actually, we should just prevent splitting during active casting.
if (panel.querySelector('#ab-cv-status-msg').textContent !== 'Ready to recast.') {
toast('Wait for casting to finish or stop it before splitting', 'error');
splitBtn.style.display = 'none';
return;
}
}
const frag = document.createDocumentFragment();
for (const s of newSegs) {
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
const newRow = document.createElement('div');
newRow.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
newRow.__seg = s;
if (dialog) {
const c = colorFor(s.speaker);
newRow.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
} else {
newRow.innerHTML = `<span class="ab-cv-spk" title="Click to assign character">Narrator</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
}
frag.appendChild(newRow);
}
row.parentNode.insertBefore(frag, row);
row.remove();
splitBtn.style.display = 'none';
window.getSelection().removeAllRanges();
toast('Segment split! You can now assign a character to the Unknown block.', 'success');
});
feed.addEventListener('mouseup', () => {
if (!assignModeSeg) return;
const sel = window.getSelection();
if (!sel.isCollapsed && feed.contains(sel.anchorNode)) {
const text = sel.toString().trim();
if (text && text.length < 40 && !text.includes('\n')) {
assignName(text);
sel.removeAllRanges();
}
}
});
chars.addEventListener('click', e => {
const chip = e.target.closest('.ab-chip');
if (chip) {
let name = '';
for (const n of chip.childNodes) if (n.nodeType === 3) name += n.nodeValue;
name = name.trim();
if (assignModeSeg) {
if (name) assignName(name);
} else if (name) {
// Scroll to the next occurrence of this character in the feed
const rows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r => r.__seg && r.__seg.speaker === name);
if (!rows.length) return;
const currentY = feed.scrollTop;
let target = rows.find(r => (r.offsetTop - feed.offsetTop) > currentY + 10);
if (!target) target = rows[0]; // loop around to the top
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight briefly
target.style.transition = 'background 0.3s';
target.style.background = 'var(--accent-hover, rgba(100, 150, 255, 0.2))';
setTimeout(() => {
if (target.parentNode) target.style.background = '';
}, 1000);
}
}
});
return {
update(done) { if (fill) fill.style.width = (done / total * 100) + '%'; if (count) count.textContent = `passage ${done} / ${total}`; },
processing(text) {
if (this._procRow) this._procRow.remove();
this._procRow = document.createElement('div');
this._procRow.className = 'ab-cv-row is-processing';
this._procRow.innerHTML = `<span class="ab-cv-spk" style="color:var(--blue)"><span class="mdi mdi-loading mdi-spin"></span> LLM Reading…</span><span class="ab-cv-txt" style="opacity:0.6; font-style:italic;">${escHtml(text.slice(0, 200))}${text.length > 200 ? '…' : ''}</span>`;
feed.appendChild(this._procRow);
trim();
},
clearProcessing() {
if (this._procRow) { this._procRow.remove(); this._procRow = null; }
},
addSegments(segs) {
this.clearProcessing();
const frag = document.createDocumentFragment();
for (const s of segs) {
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
const row = document.createElement('div');
row.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
row.__seg = s;
if (dialog) { const c = colorFor(s.speaker); roster.get(s.speaker).count++;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
} else {
row.innerHTML = `<span class="ab-cv-spk">Narrator</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
row.innerHTML = `<span class="ab-cv-spk" title="Click to assign character">Narrator</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
}
frag.appendChild(row);
}
@ -209,25 +696,170 @@ function audiobookCastView(total) {
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
// Park the panel in a "done" state with a Review button instead of auto-popping
// the preview — so it waits for you if you wandered off to do something else.
complete(summary, onOpen) {
complete(summary, onOpen, onRecast, onRecastUnknown) {
if (count) count.textContent = 'done';
if (fill) fill.style.width = '100%';
ov.querySelector('#ab-cv-min').hidden = true;
ov.querySelector('#ab-cv-x').title = 'Dismiss';
const foot = ov.querySelector('#ab-cv-foot');
const cancelBtn = panel.querySelector('#ab-cv-cancel');
if (cancelBtn) cancelBtn.hidden = true;
const foot = panel.querySelector('#ab-cv-foot');
foot.hidden = false;
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review &amp; cast</button>`;
foot.querySelector('#ab-cv-review').addEventListener('click', () => { ov.remove(); onOpen(); });
// gently nudge the panel open if it was minimised while you were away
ov.classList.add('ab-castpanel-done');
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-save-script" style="margin-right:8px;" title="Save to Script Rehearsals without leaving this page"><span class="mdi mdi-content-save-outline"></span> Save script</button><button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review &amp; cast</button>`;
foot.querySelector('#ab-cv-review').addEventListener('click', () => { closePanel(); onOpen(); });
foot.querySelector('#ab-cv-save-script').addEventListener('click', audiobookSaveAsRehearsal);
const applyPromptAndRun = (callback) => {
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim();
const currentModel = panel.querySelector('#ab-cv-llm-select')?.value;
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) })
.finally(() => {
closePanel();
if (callback) callback(currentUrl, currentModel);
});
};
foot.querySelector('#ab-cv-recast').addEventListener('click', () => applyPromptAndRun(audiobookCast));
foot.querySelector('#ab-cv-recast-unk').addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown));
panel.classList.add('ab-castpanel-done');
},
done() { ov.remove(); },
done() { closePanel(); },
};
}
// ── Attribution → script handoff ─────────────────────────────────────────────
async function audiobookRecastUnknown(overrideUrl, overrideModel) {
if (_audiobook.running) return;
const segs = _audiobook.segments;
if (!segs || !segs.length) return;
async function audiobookCast() {
const unknownIdxs = [];
for (let i = 0; i < segs.length; i++) {
if (segs[i].type === 'dialogue' && (!segs[i].speaker || segs[i].speaker === 'Unknown')) {
unknownIdxs.push(i);
}
}
if (!unknownIdxs.length) {
toast('No Unknown speakers found', 'success');
audiobookShowPreview();
return;
}
_audiobook.running = true; _audiobook.cancel = false;
const ac = new AbortController(); _audiobook.abort = () => ac.abort();
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang();
let model = overrideModel || audiobookLlmModel();
const view = audiobookCastView(unknownIdxs.length, llm_url, model);
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
try {
await fetch('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({
text: 'Wake up.', known_characters: [], recent: '', language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: document.getElementById('ab-cv-llm-select')?.value || model
})
});
} catch (err) {
if (err.name === 'AbortError') { _audiobook.running = false; view.done(); return; }
}
let done = 0;
try {
for (let i of unknownIdxs) {
if (_audiobook.cancel) break;
const targetSeg = segs[i];
const start = Math.max(0, i - 12);
const end = Math.min(segs.length, i + 6);
const contextSegs = segs.slice(start, end);
const passageText = contextSegs.map(s => {
if (s.type === 'narration') return s.text;
return `"${s.text}"`;
}).join(' ');
view.update(done++, `Attributing line ${done} / ${unknownIdxs.length}…`);
view.processing(passageText.trim());
let data = null;
try {
const r = await fetch('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
text: passageText.trim(),
known_characters: _audiobook.roster.slice(-40),
recent: '',
language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: document.getElementById('ab-cv-llm-select')?.value || model
})
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
data = await r.json();
} catch (err) {
if (err.name === 'AbortError') break;
view.note(`API Error: ${err.message}`);
continue;
}
if (data && data.segments) {
const match = data.segments.find(s => s.type === 'dialogue' && targetSeg.text.includes(s.text.slice(0, 15)));
if (match && match.speaker && match.speaker !== 'Unknown') {
targetSeg.speaker = match.speaker;
if (match.emotion) targetSeg.emotion = match.emotion;
if (!_audiobook.roster.includes(match.speaker)) _audiobook.roster.push(match.speaker);
}
}
view.addSegments([targetSeg]);
}
view.update(unknownIdxs.length);
} catch (e) {
if (e.name !== 'AbortError') view.note('Error recasting unknown: ' + e.message);
} finally {
_audiobook.running = false;
}
if (_audiobook.cancel) { view.done(); toast('Recast cancelled', 'error'); return; }
const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
}
function audiobookOpenCastView() {
if (_audiobook.running) return;
const text = audiobookScopeText();
if (!text) { toast('Import a document first', 'error'); return; }
const llm_url = audiobookLlmUrl();
const model = audiobookLlmModel();
const chunks = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
: [text];
// If the user navigates away and back, restore the view instead of clearing their work
if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) {
const view = audiobookCastView(chunks.length, llm_url, model, false);
// we need to set the roster manually so color generation matches
view.addSegments(_audiobook.segments);
const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
view.update(chunks.length);
return;
}
audiobookCastView(chunks.length, llm_url, model, true);
}
async function audiobookCast(overrideUrl, overrideModel) {
if (_audiobook.running) return;
const text = audiobookScopeText();
if (!text) { toast('Import a document first', 'error'); return; }
@ -241,8 +873,24 @@ async function audiobookCast() {
const ac = new AbortController();
_audiobook.abort = () => ac.abort();
const view = audiobookCastView(chunks.length);
const llm_url = audiobookLlmUrl(), model = audiobookLlmModel(), language = audiobookLang();
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang();
let model = overrideModel || audiobookLlmModel();
const view = audiobookCastView(chunks.length, llm_url, model);
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
try {
await fetch('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({
text: 'Wake up.', known_characters: [], recent: '', language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: document.getElementById('ab-cv-llm-select')?.value || model
})
});
} catch (err) {
if (err.name === 'AbortError') { _audiobook.running = false; view.done(); return; }
}
const allSegments = [];
const roster = [];
@ -261,17 +909,20 @@ async function audiobookCast() {
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown')
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
view.processing(chunks[i]);
let data = null;
try {
const r = await fetch('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), recent, language, llm_url, model }),
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), recent, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model }),
});
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 || e.error || r.statusText); }
data = await r.json();
} catch (err) {
if (err.name === 'AbortError') break;
view.note(`API Error: ${err.message}`);
data = null;
}
@ -295,17 +946,20 @@ async function audiobookCast() {
_audiobook.running = false;
}
if (_audiobook.cancel) { view.done(); toast('Casting cancelled', 'error'); return; }
if (!allSegments.length) { view.done(); toast('No segments produced', 'error'); return; }
_audiobook.segments = allSegments;
_audiobook.lastText = text;
_audiobook.roster = roster;
_audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded;
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
view.complete(summary, audiobookShowPreview);
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
}
// ── Editable attribution preview ─────────────────────────────────────────────
@ -479,5 +1133,66 @@ async function audiobookExport() {
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-audiobook-btn')?.addEventListener('click', audiobookCast);
$('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
async function audiobookSaveAsRehearsal() {
const segs = _audiobook.segments;
if (!segs || !segs.length) { toast('No segments to save', 'error'); return; }
const { script, emotions } = audiobookBuildScript(segs);
const title = (typeof readerState !== 'undefined' && readerState.title) ? readerState.title : 'Audiobook';
// Need to parse lines to get cast
const lines = (typeof parseScript === 'function') ? parseScript(script) : [];
if (emotions && emotions.length) {
let eIdx = 0;
lines.forEach(l => {
if (l.type === 'dialog' && eIdx < emotions.length) {
if (emotions[eIdx]) l.emotion = emotions[eIdx];
eIdx++;
}
});
}
const cast = {};
if (typeof detectCharacters === 'function') {
const detected = detectCharacters(lines);
Object.entries(detected).forEach(([sp, def]) => {
cast[sp] = {
voice: def.voice, color: def.color, instruct: '',
lang: '', gender: '', tags: '', soul: '',
ignored: false, hidden: false, voiceData: null,
};
});
}
const rec = {
title: title,
script: script,
cast: cast,
emotions: {},
notes: {}, ignored: {}, hidden: {},
backend: '', narratorVoice: '',
lineIndex: 0, clips: [],
created: new Date(),
updated: new Date(),
};
// extract emotions for the record
lines.forEach((l, i) => {
if (l.type === 'dialog' && l.emotion) rec.emotions[i] = l.emotion;
});
if (typeof rehDbAdd === 'function') {
try {
await rehDbAdd(rec);
toast('Saved as Script Rehearsal', 'success');
if (typeof renderLibraryList === 'function') renderLibraryList();
} catch (e) {
toast('Failed to save rehearsal: ' + e.message, 'error');
}
} else {
toast('Rehearser DB not available', 'error');
}
}

View File

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

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -371,12 +371,11 @@ async function loadPdfJs() {
if (window.pdfjsLib) return;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
s.src = '/static/js/pdf/pdf.min.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js';
}
// Extract a screenplay from a PDF, reconstructing element types from the

View File

@ -328,6 +328,7 @@ const SETTINGS_FIELD_HELP = {
's-llm-url': ['LLM base URL', 'OpenAI-compatible chat endpoint used for rewrites, casting, refinement, and voice-design assistance.'],
's-auto-refine': ['Auto-refine', 'When enabled, the app sends transcripts to the LLM for cleanup automatically. Leave off if you want raw transcripts.'],
's-refine-model': ['Refinement model', 'Optional model override for transcript cleanup. Leave empty to use the current LLM default.'],
's-audiobook-prompt': ['Audiobook prompt', 'Custom LLM instructions for character detection.'],
's-captures-default-voice': ['Default playback voice', 'Preselects a TTS voice in capture workflows so you do not have to choose it every time.'],
's-tts-extra-voice-clone': ['Voice Clone params', 'Advanced JSON sent to the 8020 backend. Temperature/top_p/seed are common controls.'],
's-tts-extra-streaming': ['Streaming params', 'Advanced JSON sent to the streaming backend. Keep close to Voice Clone params for comparable sound.'],
@ -631,3 +632,70 @@ $('helper-apply-btn')?.addEventListener('click', () => {
$('voice-id-input').dispatchEvent(new Event('input'));
});
// ── Global Directory Browser ──────────────────────────────────────────────────
(function initGlobalDirBrowser() {
const modal = document.getElementById('global-dir-browser-modal');
const crumb = document.getElementById('global-dir-browser-crumb');
const list = document.getElementById('global-dir-browser-list');
const pathEl = document.getElementById('global-dir-browser-path');
const selectBtn = document.getElementById('global-dir-browser-select');
if (!modal || !crumb || !list || !pathEl || !selectBtn) return;
let currentTargetInput = null;
let currentPath = '/';
async function navigateTo(path) {
currentPath = path;
list.innerHTML = '<span class="vef-loading" style="padding:8px; color:var(--text-muted);">Loading…</span>';
pathEl.textContent = path;
try {
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
const parts = data.path.split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
crumb.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}" style="background:none; border:none; color:var(--text-link); cursor:pointer; font:inherit; padding:2px;">${escHtml(c.label)}</button><span class="vef-sep" style="color:var(--text-muted); margin:0 4px;">/</span>`
: `<span class="vef-crumb-cur" style="padding:2px;">${escHtml(c.label)}</span>`
).join('');
crumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => navigateTo(b.dataset.path)));
if (!data.dirs || !data.dirs.length) {
list.innerHTML = '<span class="vef-empty" style="padding:8px; color:var(--text-muted); display:block;">No subdirectories here.</span>';
} else {
list.innerHTML = data.dirs.map(d =>
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}" style="display:flex; width:100%; text-align:left; background:none; border:none; padding:6px 8px; border-radius:4px; cursor:pointer; color:var(--text); align-items:center; gap:8px;">
<span class="mdi mdi-folder-outline" style="color:var(--text-muted);"></span> ${escHtml(d)}
</button>`
).join('');
list.querySelectorAll('.vef-dir-item').forEach(b => {
b.addEventListener('click', () => navigateTo(b.dataset.path));
b.addEventListener('mouseover', () => b.style.background = 'var(--bg-hover)');
b.addEventListener('mouseout', () => b.style.background = 'none');
});
}
pathEl.textContent = data.path;
currentPath = data.path;
} catch (e) {
list.innerHTML = `<span class="vef-error" style="padding:8px; color:var(--color-danger); display:block;">Error: ${escHtml(e.message)}</span>`;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('.s-browse-dir-btn');
if (!btn) return;
currentTargetInput = document.getElementById(btn.dataset.target);
if (!currentTargetInput) return;
const startPath = currentTargetInput.value.trim() || currentTargetInput.placeholder || '/';
modal.hidden = false;
navigateTo(startPath);
});
selectBtn.addEventListener('click', () => {
if (currentTargetInput && currentPath) {
currentTargetInput.value = currentPath;
currentTargetInput.dispatchEvent(new Event('input', { bubbles: true }));
}
modal.hidden = true;
});
})();

View File

@ -74,6 +74,7 @@
{ tree: 'nav-engines-tree', chevron: 'nav-engines-chevron', section: 's-llms' },
{ tree: 'nav-settings-tree', chevron: 'nav-settings-chevron', section: 's-settings' },
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
{ tree: 'nav-reader-tree', chevron: 'nav-reader-chevron', section: 's-reader' },
];
function _applyTreeStates() {
@ -277,6 +278,19 @@
});
};
// ── Reader tree sub-navigation ───────────────────────────────────────────
window.navReaderView = function (view) {
navTo('s-reader');
document.querySelectorAll('[data-reader-view]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.readerView === view);
});
if (typeof showReaderView === 'function') {
showReaderView(view);
} else {
window._readerStartView = view;
}
};
// Restore last-used section from hash/localStorage (fallback: My Voices)
var _savedSection = storedSection();
// On very first visit, default voices tree open

View File

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

View File

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

View File

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

View File

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

View File

@ -8,36 +8,39 @@
<div class="tab-content" id="tab-reader">
<div id="reader-main-view">
<!-- ① Import + controls ─────────────────────────────────────────── -->
<div class="card">
<div class="reader-controls">
<div class="field reader-ctl">
<label>Backend</label>
<select id="reader-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
</div>
<div class="field reader-ctl">
<label>Voice</label>
<div class="reader-voice-pick">
<select id="reader-voice-select" aria-label="Voice"><option value="">— fetch or pick a voice —</option></select>
<button class="btn-secondary btn-sm" id="reader-fetch-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
<div class="card" style="padding:14px">
<div class="engine-setup-row" style="margin-bottom:8px">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="reader-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="reader-fetch-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
<select id="reader-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
</div>
</div>
<div class="engine-setup-col no-border" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Synthesis Options</label>
<div class="engine-setup-controls" style="flex-wrap:nowrap">
<input type="text" id="reader-instruct" placeholder="Optional style / tone" autocomplete="off" style="width:140px">
<select id="reader-chunk-mode" title="Voice consistency" style="width:120px">
<option value="sentence">Per sentence</option>
<option value="paragraph">Per paragraph</option>
<option value="page">Per page</option>
</select>
<label class="reader-ctl reader-norm-toggle" title="Even out loudness" style="margin:0; font-size:11px">
<input type="checkbox" id="reader-normalize" checked> Norm
</label>
</div>
</div>
</div>
<div class="reader-controls" style="margin-top:12px; background:var(--bg); border:1px solid var(--border); padding:10px 12px; border-radius:8px">
<div class="field reader-ctl">
<label>Speed <span id="reader-speed-label" class="reader-speed-label">1.0×</span></label>
<input type="range" id="reader-speed" min="0.5" max="2" step="0.05" value="1" aria-label="Reading speed">
</div>
<div class="field reader-ctl">
<label>Voice consistency</label>
<select id="reader-chunk-mode" title="Synthesise larger chunks so the voice stays steady across a passage">
<option value="sentence">Per sentence (responsive)</option>
<option value="paragraph">Per paragraph (steadier)</option>
<option value="page">Per page (steadiest)</option>
</select>
</div>
<div class="field reader-ctl reader-ctl-grow">
<label>Tone / style <span class="note">(optional)</span></label>
<input type="text" id="reader-instruct" placeholder="e.g. calm, warm narration" autocomplete="off">
</div>
<div class="field reader-ctl reader-ctl-sm">
<label>Seed <span class="note">(blank = auto)</span></label>
<input type="number" id="reader-seed" placeholder="auto" autocomplete="off">
@ -46,25 +49,14 @@
<label>Temperature <span class="note">(0–1)</span></label>
<input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off">
</div>
<label class="reader-ctl reader-norm-toggle" title="Even out loudness differences between chunks during playback">
<input type="checkbox" id="reader-normalize" checked> Normalise loudness
</label>
<label class="btn-primary reader-import-btn" title="Import a PDF or text file">
<label class="btn-primary reader-import-btn" title="Import a PDF or text file" style="margin-left:auto">
<span class="mdi mdi-file-upload-outline"></span> Import
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
</label>
</div>
<div class="reader-backend-hint" id="reader-backend-hint"></div>
<div class="reader-backend-hint" id="reader-backend-hint" style="padding-left:14px; margin-top:8px"></div>
</div>
<!-- Saved books library ─────────────────────────────────────────── -->
<div class="card reader-library-card" id="reader-library-card" hidden>
<div class="reader-lib-head">
<h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> My books</h2>
<span class="note">Saved documents with their synthesised audio — reopen to continue.</span>
</div>
<div class="reader-lib-list" id="reader-lib-list"></div>
</div>
<!-- ② Drag & drop / empty state ─────────────────────────────────── -->
<div class="reader-dropzone" id="reader-dropzone">
@ -143,5 +135,18 @@
<!-- ⑥ Document viewport ─────────────────────────────────────────── -->
<div class="reader-doc" id="reader-doc"></div>
</div><!-- /reader-main-view -->
<!-- ⑦ Audiobook Casting Panel ──────────────────────────────────── -->
<div id="reader-audiobook-panel" hidden></div>
<!-- ⑧ Saved books library ─────────────────────────────────────────── -->
<div class="card reader-library-card" id="reader-library-card" hidden>
<div class="reader-lib-head">
<h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> My books</h2>
<span class="note">Saved documents with their synthesised audio — reopen to continue.</span>
</div>
<div class="reader-lib-list" id="reader-lib-list"></div>
</div>
</div><!-- /tab-reader -->

View File

@ -162,12 +162,14 @@
<div class="card">
<h2><span class="mdi mdi-account-group-outline"></span> Cast your characters</h2>
<p class="card-subtitle">Assign a TTS voice or your mic to each character. Add a speaking-style note per character so the voice matches their personality.</p>
<div class="reh-cast2-backend">
<div class="field" style="flex:1;min-width:160px">
<label>TTS backend</label>
<select id="reh-backend-select"><option value="">Checking…</option></select>
<div class="engine-setup-row" style="margin-bottom:12px; padding:10px 12px">
<div class="engine-setup-col no-border" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-account-voice"></span> Text to Speech <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('tts'); return false;" title="Configure Text to Speech settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="reh-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="reh-fetch-voices-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
</div>
</div>
<button class="btn-secondary" id="reh-fetch-voices-btn" style="align-self:flex-end"><span class="mdi mdi-refresh"></span> Fetch voices</button>
</div>
<!-- Narrator is now a regular cast entry — see NARRATOR row in cast list -->
@ -178,27 +180,29 @@
<strong id="reh-autocast-title">Match characters with voices</strong>
<span class="note">— let an LLM cast every character automatically</span>
</div>
<div class="reh-autodesign-row">
<div class="field" style="flex:1;min-width:150px;margin:0">
<label for="reh-llm-url" style="font-size:11px">LLM endpoint</label>
<select id="reh-llm-url-sel" style="display:none" aria-hidden="true"></select>
<input type="text" id="reh-llm-url" placeholder="http://localhost:11434/v1" list="reh-llm-url-list" aria-label="LLM endpoint URL">
<datalist id="reh-llm-url-list"></datalist>
<div class="engine-setup-row" style="margin-top:12px; margin-bottom:12px; padding:10px 12px; background:var(--bg); border:1px solid var(--border)">
<div class="engine-setup-col" style="flex:1">
<label class="engine-setup-label"><span class="mdi mdi-brain"></span> Language Model <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;" title="Configure Language Model settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<select id="reh-llm-url-sel" style="display:none" aria-hidden="true"></select>
<input type="text" id="reh-llm-url" placeholder="http://localhost:11434/v1" list="reh-llm-url-list" aria-label="LLM endpoint URL" title="OpenAI-compatible endpoint (Ollama, LM Studio, vLLM...)">
<datalist id="reh-llm-url-list"></datalist>
<button class="btn-secondary" id="reh-llm-refresh" title="List available models" aria-label="Refresh model list"><span class="mdi mdi-refresh"></span></button>
<select id="reh-llm-model" aria-label="LLM model"><option value="">— default —</option></select>
</div>
</div>
<div class="field" style="flex:1;min-width:150px;margin:0">
<label for="reh-llm-model" style="font-size:11px">Model <button class="reh-llm-refresh" id="reh-llm-refresh" title="List available models" aria-label="Refresh model list">⟳</button></label>
<select id="reh-llm-model" aria-label="LLM model"><option value="">— default —</option></select>
</div>
<div class="field" style="min-width:90px;margin:0">
<label for="reh-design-lang" style="font-size:11px">Language</label>
<select id="reh-design-lang" aria-label="Voice language">
<option value="English">English</option>
<option value="German">German</option>
<option value="Auto">Auto</option>
<option value="French">French</option>
<option value="Spanish">Spanish</option>
<option value="Italian">Italian</option>
</select>
<div class="engine-setup-col no-border" style="flex:0.3">
<label class="engine-setup-label"><span class="mdi mdi-earth"></span> Language</label>
<div class="engine-setup-controls">
<select id="reh-design-lang" aria-label="Voice language">
<option value="English">English</option>
<option value="German">German</option>
<option value="Auto">Auto</option>
<option value="French">French</option>
<option value="Spanish">Spanish</option>
<option value="Italian">Italian</option>
</select>
</div>
</div>
</div>

View File

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

View File

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

View File

@ -502,9 +502,9 @@ audio { width: 100%; }
.s-log-filter { border:1px solid var(--border); background:transparent; color:var(--subtext); border-radius:999px; padding:3px 10px; font-size:12px; cursor:pointer; transition:all .15s; }
.s-log-filter:hover { border-color:var(--accent); color:var(--accent); }
.s-log-filter.is-active { background:var(--accent); border-color:var(--accent); color:#fff; }
.s-log-viewer { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; font-family:monospace; font-size:12px; line-height:1.6; max-height:440px; overflow-y:auto; display:flex; flex-direction:column; gap:1px; }
.s-log-viewer { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; font-family:monospace; font-size:12px; line-height:1.6; max-height:75vh; overflow-y:auto; display:flex; flex-direction:column; gap:1px; }
.s-log-empty { color:var(--subtext); font-family:inherit; text-align:center; padding:24px 0; }
.s-log-row { display:grid; grid-template-columns:130px 56px 1fr; gap:8px; align-items:baseline; padding:2px 0; border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent); }
.s-log-row { display:grid; grid-template-columns:max-content max-content 1fr; gap:12px; align-items:baseline; padding:2px 0; border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent); }
.s-log-row:last-child { border-bottom:none; }
.s-log-ts { color:var(--subtext); font-size:11px; white-space:nowrap; }
.s-log-level { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
@ -1936,7 +1936,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Cloud service cards grid ─────────────────────────── */
.llm-service-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); gap: 14px;
margin-bottom: 8px;
}
@ -2123,7 +2123,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Connect / Disconnect — primary CTA, prominent blue ─────────────────── */
.llm-local-ping {
padding: 6px 16px; font-size: 12.5px; font-weight: 700; border-radius: 6px;
padding: 5px 12px; font-size: 11.5px; font-weight: 700; border-radius: 5px;
border: 1.5px solid #2563eb; background: #2563eb; color: #fff;
cursor: pointer; font-family: inherit;
display: inline-flex; align-items: center; gap: 5px;
@ -2145,11 +2145,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Docker action buttons (Stop / Start / Restart) ─────────────────────── */
.llm-use-btn {
font-size: 11px; padding: 4px 11px; border-radius: 5px;
border: 1px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
font-size: 11.5px; padding: 5px 10px; border-radius: 5px;
border: 1.5px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
color: #115E59; cursor: pointer; font-family: inherit; font-weight: 600;
transition: background .15s, border-color .15s;
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0;
}
.llm-use-btn:hover:not(:disabled) { background: rgba(13,148,136,.15); border-color: var(--teal); }
.llm-use-btn:active { background: rgba(13,148,136,.25); }
@ -2158,15 +2158,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── "Use as TTS / STT" — activate action, right-aligned, clearly distinct ─ */
.dc-use-btn {
margin-left: auto;
padding: 6px 16px; font-size: 12px; font-weight: 700; border-radius: 6px;
border: 1.5px solid rgba(13,148,136,.55); background: rgba(13,148,136,.12);
color: var(--teal); cursor: pointer; font-family: inherit;
padding: 5px 12px; font-size: 11.5px; font-weight: 700; border-radius: 5px;
border: 1.5px solid #2563eb; background: #2563eb;
color: #fff; cursor: pointer; font-family: inherit;
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; flex-shrink: 0;
transition: background .15s, border-color .15s, box-shadow .15s;
}
.dc-use-btn:hover:not(:disabled) {
background: rgba(13,148,136,.22); border-color: var(--teal);
box-shadow: 0 1px 6px rgba(13,148,136,.25);
background: #1d4ed8; border-color: #1d4ed8;
box-shadow: 0 2px 8px rgba(37,99,235,.35);
}
.dc-use-btn.active {
background: rgba(13,148,136,.22); border-color: var(--teal);
@ -2369,7 +2369,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.dc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
gap: 14px;
margin-bottom: 8px;
}
@ -2909,6 +2909,38 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Conversation Playground ─────────────────────────────────────────────── */
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
/* ── Unified Engine Config Rows (used across tools) ───────────────── */
.engine-setup-row {
display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;
margin-bottom: 16px; padding: 12px 14px; background: rgba(0,0,0,0.02);
border: 1px solid var(--border); border-radius: 8px;
}
.engine-setup-col {
flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 6px;
border-right: 1px solid var(--border); padding-right: 16px;
}
.engine-setup-col:last-child, .engine-setup-col.no-border {
border-right: none; padding-right: 0;
}
.engine-setup-label {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--subtext);
display: flex; align-items: center; gap: 6px; margin-bottom: 2px;
}
.engine-setup-label .mdi { font-size: 14px; }
.engine-setup-link {
color: var(--subtext); opacity: 0.6; transition: opacity 0.15s, color 0.15s;
margin-left: auto; /* Push gear to the right */
}
.engine-setup-link:hover { opacity: 1; color: var(--accent); }
.engine-setup-controls {
display: flex; gap: 6px; align-items: center; width: 100%; flex-wrap: wrap;
}
.engine-setup-controls select, .engine-setup-controls input[type="text"] {
flex: 1; min-width: 0;
background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: var(--radius); padding: 8px 12px; font-size: 14px;
}
.conv-config-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
.conv-config-group { display: flex; flex-direction: column; gap: 4px; }
.conv-config-group--actions { margin-left: auto; }
@ -3547,6 +3579,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Script editor modal */
.reh-modal-overlay { position:fixed; inset:0; z-index:2000; background:rgba(0,0,0,.45); display:flex; align-items:center; justify-content:center; padding:16px; }
.reh-modal-overlay[hidden] { display: none !important; }
.reh-modal-box { background:var(--bg); border-radius:10px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; display:flex; flex-direction:column; }
.reh-modal-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid var(--border); font-weight:700; }
@ -4018,17 +4051,28 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.reader-lib-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.reader-lib-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
.reader-book {
position: relative; padding: 14px; border: 1px solid var(--border); border-radius: 10px;
background: var(--panel); cursor: pointer; transition: border-color .15s, transform .1s, box-shadow .15s;
position: relative; padding: 18px 16px 16px 24px; border: 1px solid var(--border);
border-radius: 4px 14px 14px 4px; background: var(--surface); cursor: pointer;
transition: border-color .15s, transform .1s, box-shadow .15s;
display: flex; flex-direction: column; aspect-ratio: 1 / 1.35;
box-shadow: inset 5px 0 10px rgba(0,0,0,.03), -1px 0 0 rgba(0,0,0,.06), 0 3px 8px rgba(0,0,0,.05);
}
.reader-book:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(0,0,0,.08); transform: translateY(-1px); }
.reader-book-icon { font-size: 26px; color: var(--accent); line-height: 1; }
.reader-book-title { font-weight: 700; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reader-book-meta { font-size: 11px; color: var(--subtext); margin-top: 2px; }
.reader-book-bar { position: relative; height: 6px; border-radius: 4px; background: var(--border); margin-top: 10px; overflow: hidden; }
.reader-book::before {
content: ''; position: absolute; top: 0; bottom: 0; left: 14px; width: 2px;
background: rgba(0,0,0,.04); border-left: 1px solid rgba(255,255,255,.5);
}
.reader-book:hover {
border-color: var(--accent);
box-shadow: inset 5px 0 10px rgba(0,0,0,.03), -1px 0 0 var(--accent), 0 8px 20px rgba(0,0,0,.08);
transform: translateY(-2px);
}
.reader-book-icon { font-size: 28px; color: var(--accent); line-height: 1; z-index: 1; }
.reader-book-title { font-weight: 800; font-size: 15px; margin-top: 10px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; line-height: 1.3; z-index: 1; }
.reader-book-meta { font-size: 11px; color: var(--subtext); margin-top: 6px; z-index: 1; }
.reader-book-bar { position: relative; height: 6px; border-radius: 4px; background: var(--panel); margin-top: auto; margin-bottom: 5px; overflow: hidden; border: 1px solid var(--border); z-index: 1; }
.reader-book-bar-synth { position: absolute; inset: 0 auto 0 0; background: rgba(34,197,94,.45); }
.reader-book-bar-read { position: absolute; inset: 0 auto 0 0; background: var(--accent); }
.reader-book-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); margin-top: 5px; }
.reader-book-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); z-index: 1; }
.reader-book-del {
position: absolute; top: 6px; right: 6px; border: none; background: rgba(0,0,0,.04);
color: var(--subtext); border-radius: 6px; width: 24px; height: 24px; cursor: pointer; opacity: 0; transition: opacity .15s;
@ -4059,40 +4103,113 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Audiobook live casting — floating, non-blocking, minimisable panel */
.ab-castpanel {
position: fixed; right: 18px; bottom: 18px; z-index: 1000;
width: min(880px, 96vw); max-height: 82vh; display: flex; flex-direction: column;
width: min(880px, 96vw); height: 50vh; max-height: 82vh; display: flex; flex-direction: column;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden;
box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden; resize: both; min-height: 300px; min-width: 400px;
}
.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); cursor: default; }
.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg); cursor: move; }
.ab-castpanel-head > .mdi { color: var(--accent); font-size: 18px; }
.ab-castpanel-title { font-weight: 800; font-size: 14px; }
.ab-castpanel-count { font-size: 12px; color: var(--subtext); }
.ab-castpanel-btn { border: none; background: none; color: var(--subtext); cursor: pointer; padding: 2px 5px; border-radius: 6px; line-height: 1; }
.ab-castpanel-btn:hover { background: var(--panel); color: var(--text); }
.ab-castpanel-bar { margin: 0; border-radius: 0; }
.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--border); }
.ab-castpanel-bar { margin: 0; border-radius: 0; flex-shrink: 0; }
.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--border); background: var(--bg); flex-shrink: 0; z-index: 10; position: relative; }
.ab-cv-done { font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
.ab-cv-done .mdi { color: #10b981; }
/* minimised → just the header chip in the corner */
.ab-castpanel.minimized { width: auto; max-width: 320px; }
.ab-castpanel.minimized { width: auto; max-width: 320px; height: auto; min-height: auto; resize: none; }
.ab-castpanel.minimized .ab-castpanel-bar,
.ab-castpanel.minimized .ab-cv-body,
.ab-castpanel.minimized .ab-castpanel-foot { display: none; }
.ab-castpanel.minimized .ab-castpanel-head { cursor: pointer; border-bottom: none; }
.ab-cv-body { display: grid; grid-template-columns: 1fr 220px; gap: 12px; padding: 12px; }
.ab-cv-feed { height: 44vh; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; background: var(--panel); display: flex; flex-direction: column; gap: 3px; }
.ab-cv-row { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 12.5px; padding: 2px 0; align-items: baseline; }
.ab-cv-row.is-narr { opacity: .6; }
.ab-cv-spk { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ab-cv-row.is-narr .ab-cv-spk { color: var(--subtext); font-weight: 600; }
.ab-cv-txt { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); }
.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; height: 44vh; }
.ab-castpanel-inline {
height: calc(100vh - 140px);
min-height: 400px;
display: flex;
flex-direction: column;
}
.ab-cv-body { display: grid; grid-template-columns: 1fr 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; }
.ab-cv-feed { 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-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;
color: var(--subtext);
padding-left: 0;
font-size: 11px;
}
.ab-cv-row.is-processing {
font-family: inherit;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
}
.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-row.is-processing .ab-cv-txt {
display: inline-block;
padding-left: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ab-cv-txt { color: var(--text); white-space: pre-wrap; word-wrap: break-word; }
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); font-family: sans-serif; }
.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; min-height: 0; }
.ab-cv-side-head { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-bottom: 6px; }
.ab-cv-chars { display: flex; flex-direction: column; gap: 5px; }
.ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
.ab-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 20px; }
.ab-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 20px; cursor: pointer; transition: background 0.1s; }
.ab-chip:hover { background: var(--panel); border-color: var(--accent); }
.ab-chip-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); flex-shrink: 0; }
.ab-chip b { margin-left: auto; color: var(--subtext); font-weight: 700; }
.audiobook-narr-note { color: var(--subtext); }
@ -4177,15 +4294,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.reader-leg-reading::before { background: rgba(37,99,235,.6); }
.reader-doc {
margin-top: 14px; max-height: 72vh; overflow-y: auto;
margin-top: 14px; max-height: 72vh; overflow-y: auto; overflow-x: auto;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 16px;
padding: 16px; display: flex; flex-direction: column; gap: 16px;
}
.reader-doc:empty { display: none; }
.reader-parsing { display: flex; align-items: center; gap: 10px; padding: 24px; color: var(--subtext); font-size: 13px; }
.reader-parsing { display: flex; align-items: center; gap: 10px; padding: 24px; color: var(--subtext); font-size: 13px; margin: 0 auto; }
.reader-doc.two-page { flex-direction: row; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
.reader-page {
position: relative; flex: 0 0 auto;
position: relative; flex: 0 0 auto; margin: 0 auto;
box-shadow: 0 2px 10px rgba(0,0,0,.12); background: #fff;
}
.reader-canvas { display: block; }