Release v1.6.0: a11y (WCAG AA), i18n (DE), PWA, perf, tests, Cast UX

Cast: card/list views, sort & filter, online voice picker, "Hear a line"
sample button, AI character notes, import auto-save.

Platform: WCAG 2.1 AA accessibility pass; German UI translation + language
picker; installable PWA with offline shell; GZip + content-visibility
virtualization + lazy images + Rehearser PCM memory cap (mobile stability);
Playwright suite (desktop + iPhone); opt-in minified bundle build.

Fixes: screenplay parser false characters; Fish-Speech inline-tag tones;
narrator/voice pickers list full library; clone GUI rework; fish.audio
import dedup; voice-ID rename; bulk-delete modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-03 14:23:35 +02:00
parent b64baf2d0b
commit 40e42590cc
55 changed files with 8577 additions and 721 deletions

15
.gitignore vendored
View File

@ -38,3 +38,18 @@ config/voice_design_presets.json
config/tts_routes.json config/tts_routes.json
logs/*.log logs/*.log
logs/*.log.* logs/*.log.*
# Node / Playwright test tooling
node_modules/
package-lock.json
test-results/
playwright-report/
.playwright/
# Generated production bundle (build with: npm run minify)
static/dist/
# Local caches / scratch / recovery artifacts
config/fishaudio_cache/
claude studio/
*.recovered-*.yml

View File

@ -9,6 +9,81 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.6.0] — 2026-06-03
### Added
- **Script Rehearser — Cast overhaul**: Card / List **view toggle**; **sort & filter** (name, gender, language, line count, tag); character-card-game styling (large portrait, name, description line, action row); **per-character online voice picker** (audition the match, browse alternatives, pick from your library, or search fish.audio inline); **"Hear a line"** button that synthesizes a representative one-liner from the character's own dialogue in their assigned voice.
- **AI character notes** — Match local / Match online / Design all now research the play and drop a per-character note (description, gender, speaking style).
- **Rehearser import auto-save** — uploading a script (PDF/text/FDX/Fountain) saves it to the Library immediately.
- **Internationalization (i18n)** — interface **language picker** with **German** translation of the UI chrome; English is the source language (`static/js/i18n.js`, extend via `I18N_DICT`).
- **Progressive Web App** — installable with offline app shell (`manifest.webmanifest` + network-first service worker), iOS web-app meta and safe-area support.
- **Test suite** — Playwright smoke + functional tests (desktop **and** iPhone/WebKit profiles): app load, sections, clone tabs, PWA, rehearser parse→cast, bundle, i18n.
- **Build tooling** — opt-in single minified bundle (`npm run minify` → `static/dist/main.min.js`, loaded when `?bundle=1`); architecture & migration notes in `docs/ARCHITECTURE.md`.
### Changed
- **Accessibility → WCAG 2.1 AA** — accessible names on all controls, AA text/badge/button contrast, keyboard-focusable scroll regions (audited with axe-core; 40+ violations → a handful of edge cases).
- **Performance / mobile stability** — GZip responses; `content-visibility` virtualization for long lists; lazy-loaded images; Rehearser caps decoded-PCM memory to a sliding window (fixes iPhone crashes); leaked `AudioContext` closed; bounded-concurrency bulk operations.
- **Clone a Voice — reworked GUI** — integrated tab strip (Microphone · Upload · URL/YouTube), clearer sections, scroll-to + obvious "transcribing…" feedback, sample sentence keeps the typed name across language switches.
- **Fish-Speech tone** — per-line tones now reach OpenAudio S2 via inline `[tag]` markers in the text (the `instruct` field is ignored by S2).
- **fish.audio import** — de-duplicates voices already in the library and diversifies matches so different characters don't all get the same fallback voice.
- **Get Voices Online** — tabbed, integrated source switcher; the scrape box lives only under "Direct sources".
- **Voice library** — editable Voice ID (rename), complete country/accent list (decoupled from language), always-visible **Select all** toggle, redesigned bulk-delete confirmation modal.
### Fixed
- **Screenplay parser** — title-page text, numbered scene headings (`A1 EXT. … EVENINGA1`), `OMITTED`/`CONTINUED` markers and dated page slugs are no longer detected as characters.
- **Narrator & all voice pickers** now list the full voice library (lazy-loaded if needed).
- **Cast list controls** wire reliably regardless of when the section mounts; role names no longer truncate; avatars enlarged.
---
## [1.5.0] — 2026-06-01
### Added
- **Fish-Speech TTS backend** — clones a voice's saved reference WAV (consistent identity) **and** honours inline emotion markers like `(angry)`, `(whispering)`, `(excited)` per line. The only backend that is both WAV-anchored and style-aware; the Rehearser prefers it when available. Configurable via `FISHSPEECH_URL`.
- **Fish.audio Voice Library browser** (Get Voices Online) — search/filter the ~2M public voices at `api.fish.audio`, preview samples, and one-click **Import** (MP3 → WAV + reference transcript) → an instantly clonable voice.
- **Cast tab redesigned as character cards** — big avatar, name, language, gender, tags, voice picker, voice-design prompt, "Character soul · LLM brief" with **Develop** (LLM), and per-character **Ignore / Hide / Delete**.
- **Bulk-edit lines on the Stage** — a **Select** mode adds per-line checkboxes: **Ignore**, **Hide**, **Delete**, Un-ignore, Show-hidden.
- **Designed voices** — display name is the **character name**, the script becomes a **tag**, and an auto-picked **gender/type avatar icon** replaces the language flag.
- **Clone a Voice — name-first flow** — name first (drops into the read-aloud sentence), live voice-ID, **auto-transcribe** after trim, **auto-save when ready**, and a **File / URL / Microphone source picker**.
### Changed
- **IMSDb scraper** — resolves the real script via each title's detail-page "Read Script" link instead of guessing a slug.
- **Rehearser default backend** — `voice_clone` (then `fishspeech`) for consistent identity; the tone-warning explains the trade-off both ways.
- **Try It Out** — the cramped voice/backend row is now a clean responsive layout.
### Fixed
- **Narrator was silent** — `narratorVoice` now stays in sync with the narrator cast row.
- **About → Changelog was empty** — `CHANGELOG.md` is now shipped in the image and resolved resiliently.
---
## [1.4.0] — 2026-06-01
### Added
- **IMSDb browser — list / cover view toggle** — switch between poster-grid and compact list view; preference persisted in `localStorage`.
- **IMSDb browser — local catalogue cache** — catalogue is cached in `localStorage` for 6 h (matching server cache), making reopening the browser instant.
- **IMSDb browser — title in fallback** — script title shown on each gradient poster card while the real poster loads.
- **IMSDb browser — loading spinner** — animated spinner while the catalogue fetches.
- **IMSDb browse button on Import / Export tab** — the "Browse IMSDb" button is now also available on the Import / Export panel; modal moved to global scope.
- **Auto-design — detailed progress panel** — each character shows an expandable card during voice design: gender chip, language, voice ID, age, and the full LLM-generated character description with a live spinner.
- **Auto-design — script title as voice tag** — designed voices receive the script title as their `tag` value so they're easy to filter/find.
- **Auto-design — LLM endpoint datalist** — the LLM endpoint field is now backed by a `<datalist>` auto-populated from all configured Language Models engines, plus hardcoded defaults (Ollama, vLLM, LM Studio, llama-swap, LiteLLM).
- **Stage — synthesis progress** — the synth bar is now more prominent (gradient fill, spinner, sticky), each synthesising line pulses with a blue glow, and the page auto-scrolls to the active line.
- **Stage — tone warning banner** — when a non-style-aware backend (voice_clone, streaming, NVIDIA) is selected and tone is set on lines, a dismissable amber warning banner names the backend and suggests a style-aware alternative.
- **Bulk-edit tools** — new sticky toolbar in My Voices: select any number of voices with checkboxes, then: **Set tag**, **Hide**, **Unhide**, **Rate**, or **Delete** in one action.
- **Rehearser — Voice Design default** — the TTS backend picker in Cast now defaults to `voice_design` (style-aware) instead of voice_clone, so tone selections work out of the box.
### Changed
- **Stage — edit button moved to right gutter** — the pencil (edit text) button is now stacked with the note button in the right-side gutter of each dialog block, keeping the block header clean.
- **Tone / instruct order** — when an emotion is set on a line, the instruction now leads with a directive (`"Speak in a <emotion> manner. <voice profile>"`) so the model prioritises the tone over the base identity description.
- **My Voices — hidden voices in sub-tabs** — fixed: Cloned, Designed, and Favorites tabs now respect the "Disabled" checkbox filter; hidden voices no longer appear unless explicitly requested.
### Fixed
- **IMSDb covers showing as flat lines** — replaced `aspect-ratio` on a flex child (unreliable in all major browsers) with the `padding-bottom: 150%` wrapper trick, guaranteeing a correct 2:3 poster ratio.
- **Rehearser TTS backend "No backend available"** — `refreshRehBackends` now triggers the global backend probe if `_ttsBackends` is empty, and registers a `_ttsRefreshHook` so the select stays in sync with the Engines page.
---
## [1.3.0] — 2026-05-31 ## [1.3.0] — 2026-05-31
### Added ### Added

View File

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

View File

@ -1 +1 @@
1.3.0 1.6.0

View File

@ -15,7 +15,7 @@ from core.constants import (
_NVIDIA_ROUTER_DEFAULT, _NVIDIA_TTS_DEFAULT, _NVIDIA_ASR_DEFAULT, _NVIDIA_ROUTER_DEFAULT, _NVIDIA_TTS_DEFAULT, _NVIDIA_ASR_DEFAULT,
_NVIDIA_CLONE_DEFAULT, _NVIDIA_ZEROSHOT_DEFAULT, _NVIDIA_FLOW_DEFAULT, _NVIDIA_CLONE_DEFAULT, _NVIDIA_ZEROSHOT_DEFAULT, _NVIDIA_FLOW_DEFAULT,
_FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT,
_KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT,
_TTS_STREAM_DEFAULT, _TTS_STREAM_DEFAULT,
) )
@ -35,7 +35,7 @@ _SETTINGS_KEYS = {
"refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation", "refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation",
"captures_default_voice", "captures_default_voice",
"client_voice_bindings", "client_voice_bindings",
"llm_url", "llm_url", "llm_model",
# Browser-persistent UI state # Browser-persistent UI state
"engine_local_urls", "engine_container_names", "custom_engine_cards", "engine_local_urls", "engine_container_names", "custom_engine_cards",
"refine_llm_url", "conv_llm_url", "refine_llm_url", "conv_llm_url",
@ -120,9 +120,13 @@ def _clean_preview_backend(value: str) -> str:
"xtts_v2": "xtts", "xtts_v2": "xtts",
"xtts2": "xtts", "xtts2": "xtts",
"coqui_xtts": "xtts", "coqui_xtts": "xtts",
"fish": "fishspeech",
"fish_speech": "fishspeech",
"fishaudio": "fishspeech",
"openaudio": "fishspeech",
} }
key = aliases.get(key, key) key = aliases.get(key, key)
return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "xtts"} else "voice_clone" return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "xtts", "fishspeech"} else "voice_clone"
def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict: def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict:
@ -188,6 +192,8 @@ def _preview_backend_base_url(settings: dict, backend: str) -> str:
return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT
if backend == "xtts": if backend == "xtts":
return settings.get("xtts_url") or _XTTS_DEFAULT return settings.get("xtts_url") or _XTTS_DEFAULT
if backend == "fishspeech":
return settings.get("fishspeech_url") or _FISHSPEECH_DEFAULT
return settings.get("tts_url") or _TTS_DEFAULT return settings.get("tts_url") or _TTS_DEFAULT
@ -242,6 +248,7 @@ def _load_settings() -> dict:
"kokoro_url": _KOKORO_DEFAULT, "kokoro_url": _KOKORO_DEFAULT,
"vibevoice_url": _VIBEVOICE_DEFAULT, "vibevoice_url": _VIBEVOICE_DEFAULT,
"xtts_url": _XTTS_DEFAULT, "xtts_url": _XTTS_DEFAULT,
"fishspeech_url": _FISHSPEECH_DEFAULT,
"whisper_api_key": "", "whisper_api_key": "",
"tts_api_key": "", "tts_api_key": "",
"voice_design_api_key": "", "voice_design_api_key": "",
@ -260,6 +267,7 @@ def _load_settings() -> dict:
"captures_default_voice": "", "captures_default_voice": "",
"client_voice_bindings": {}, "client_voice_bindings": {},
"llm_url": "http://localhost:11434/v1", "llm_url": "http://localhost:11434/v1",
"llm_model": "",
"engine_local_urls": {}, "engine_local_urls": {},
"engine_container_names": {}, "engine_container_names": {},
"custom_engine_cards": [], "custom_engine_cards": [],

View File

@ -39,6 +39,7 @@ _GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1"
_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1") _KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1")
_VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027") _VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027")
_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8024") _XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8024")
_FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://host.docker.internal:38080")
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts") _TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
_TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override _TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override
_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign") _VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign")

View File

@ -26,13 +26,28 @@ from core.audio import _to_wav_24k, _duration as _dur
from core.voice import _find_voice_audio, _read_reference_text from core.voice import _find_voice_audio, _read_reference_text
# ── NVIDIA language codes ───────────────────────────────────────────────────── # ── Language helpers ──────────────────────────────────────────────────────────
_NVIDIA_LANGUAGE_CODES = { _NVIDIA_LANGUAGE_CODES = {
"EN": "en-US", "DE": "de-DE", "ES": "es-ES", "FR": "fr-FR", "EN": "en-US", "DE": "de-DE", "ES": "es-ES", "FR": "fr-FR",
"IT": "it-IT", "PT": "pt-PT", "NL": "nl-NL", "PL": "pl-PL", "IT": "it-IT", "PT": "pt-PT", "NL": "nl-NL", "PL": "pl-PL",
} }
# Maps 2-letter prefix from voice ID (e.g. "DE_M_…") to full language name
# used by the faster-qwen3-tts clone server's `language` field.
_VOICE_LANG_NAMES = {
"EN": "English", "DE": "German", "ES": "Spanish", "FR": "French",
"IT": "Italian", "PT": "Portuguese", "NL": "Dutch", "PL": "Polish",
"ZH": "Chinese", "JA": "Japanese", "KO": "Korean", "RU": "Russian",
"AR": "Arabic", "HI": "Hindi", "TR": "Turkish", "SV": "Swedish",
}
def _voice_language_name(voice: str) -> str:
"""Return the full language name inferred from the voice ID prefix (e.g. DE_M_ → German)."""
prefix = str(voice or "").split("_", 1)[0].upper()
return _VOICE_LANG_NAMES.get(prefix, "")
def _nvidia_clone_language_code(voice: str, language: str = "") -> str: def _nvidia_clone_language_code(voice: str, language: str = "") -> str:
raw = str(language or "").strip() raw = str(language or "").strip()
@ -104,7 +119,15 @@ def _tts_request_config(
endpoint, payload = f"{tts_url}/v1/audio/speech", {"model": "tts-1", "input": text, "voice": voice, "response_format": response_format} endpoint, payload = f"{tts_url}/v1/audio/speech", {"model": "tts-1", "input": text, "voice": voice, "response_format": response_format}
if instruct.strip(): if instruct.strip():
payload["instruct"] = instruct.strip() payload["instruct"] = instruct.strip()
_apply_tts_extra_params(payload, settings, extra_backend or backend_override or "voice_clone") # Include language derived from voice ID prefix so the TTS server knows what
# language to synthesise (e.g. DE_M_… → German). Only set when the backend
# is the local faster-qwen3-tts clone server — skip for OpenAI / cloud APIs.
_eff_backend = extra_backend or backend_override or "voice_clone"
if _eff_backend in ("voice_clone", "streaming", "customvoice"):
lang = _voice_language_name(voice)
if lang:
payload["language"] = lang
_apply_tts_extra_params(payload, settings, _eff_backend)
return endpoint, payload, tts_hdrs return endpoint, payload, tts_hdrs
@ -320,6 +343,66 @@ def _nvidia_clone_request_audio(
return audio, media_type return audio, media_type
# ── Fish-Speech request (clone from saved WAV + inline emotion markers) ──────
def _fishspeech_emotion_prefix(instruct: str) -> str:
"""Turn the per-line style instruction into a Fish-Speech inline emotion marker.
The Rehearser sends ``"Speak in a {emotion} manner. {persona}"`` — the persona is
already carried by the cloned reference WAV, so we only forward the emotion as a
``(emotion)`` tag, which Fish-Speech honours for per-line tone control.
"""
import re
s = (instruct or "").strip()
if not s:
return ""
m = re.search(r"speak(?:ing)?\s+in\s+(?:a|an)\s+([a-z\- ]+?)\s+manner", s, re.I)
if m:
return f"({m.group(1).strip().lower()}) "
return f"({s}) " if len(s) <= 40 else ""
def _fishspeech_request_audio(
text: str,
voice: str,
settings: dict,
instruct: str = "",
language: str = "",
) -> tuple[bytes, str]:
"""Synthesize via Fish-Speech: clone the voice's saved reference WAV (consistent
speaker identity) while applying the line's tone as an inline emotion marker."""
import base64
import hashlib
from core.validation import _validate_http_url
base_url = _validate_http_url(_preview_backend_base_url(settings, "fishspeech"), allow_private=True).rstrip("/")
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio_path = _find_voice_audio(voice, scan_dir)
if audio_path is None:
raise RuntimeError(f"reference voice not found in library: {voice}")
wav = _to_wav_24k(audio_path)
_has_ref, ref_text = _read_reference_text(audio_path)
audio_b64 = base64.b64encode(wav.read_bytes()).decode("ascii")
# Stable per-voice seed → reduces run-to-run drift on top of the reference clone.
seed = int(hashlib.md5(voice.encode("utf-8")).hexdigest()[:8], 16)
payload = {
"text": _fishspeech_emotion_prefix(instruct) + text,
"format": "wav",
"references": [{"audio": audio_b64, "text": ref_text or ""}],
"seed": seed,
"use_memory_cache": "on",
"chunk_length": 200,
"normalize": True,
}
resp = requests.post(f"{base_url}/v1/tts", json=payload, timeout=180)
resp.raise_for_status()
audio = resp.content
if not audio or len(audio) < 256:
raise RuntimeError("Fish-Speech returned empty audio")
media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav"
return audio, media_type
# ── VibeVoice request ───────────────────────────────────────────────────────── # ── VibeVoice request ─────────────────────────────────────────────────────────
def _vibevoice_request_audio(text: str, settings: dict) -> tuple[bytes, str]: def _vibevoice_request_audio(text: str, settings: dict) -> tuple[bytes, str]:
@ -381,6 +464,8 @@ def _preview_request_audio(
) )
if backend == "vibevoice": if backend == "vibevoice":
return _vibevoice_request_audio(text, settings) return _vibevoice_request_audio(text, settings)
if backend == "fishspeech":
return _fishspeech_request_audio(text, voice, settings, instruct)
if backend == "xtts": if backend == "xtts":
return _tts_request_audio( return _tts_request_audio(
text, voice, settings, instruct, text, voice, settings, instruct,

43
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,43 @@
# Architecture & optimization notes
## Current shape
- **Backend**: FastAPI, modular — `server.py` (app + middleware) → `routes/*.py` (admin, tts, library, sources, stt, conversation, docker, settings) → `core/*.py` (audio, voice, config, validation, tts_helpers, …).
- **Frontend**: ~15k lines of **plain global-scope JS** in `static/js/*.js`, loaded in order by `static/loader.js` (no bundler/build step). Sections are HTML fragments in `static/sections/*.html` fetched at runtime. Modules communicate via globals (`window._voices`, `$`, `escHtml`, `toast`, `saveMeta`, `loadVoiceLibrary`, …) and some runtime monkey-patching (e.g. `renderVoiceList` is reassigned in `voice-library.js`).
## Already optimised
- **GZip** responses (`server.py`) — ~75% smaller text transfer.
- **Static caching** middleware (immutable for versioned JS/CSS).
- **Native virtualization**: `content-visibility:auto` on long-list rows (voice library, online cards, cast cards).
- **Lazy images** (`loading="lazy" decoding="async"`) across all big lists.
- **Rehearser memory cap**: decoded PCM is kept to a sliding window (`REH_DECODE_WINDOW`) around the playhead.
- **AudioContext** instances are closed (iOS limit).
- **PWA**: `manifest.webmanifest` + network-first `sw.js` (served from root for `/` scope), installable, offline shell.
- **Concurrency pool** (`runPool` in `utils.js`) for bulk network ops.
- **Smoke tests**: Playwright (`tests/`, desktop + iPhone profiles).
## Build tooling (opt-in) — DONE
`npm run minify` → `scripts/minify.mjs` concatenates the feature modules **in loader order** into `static/dist/main.min.js` and minifies whitespace/syntax only (**identifiers kept** — inline HTML `onclick="navTo(...)"` and cross-file refs rely on global names). `loader.js` loads this single bundle when `window.APP_USE_BUNDLE === true` (or `?bundle=1`), else falls back to per-file loading — so editing `static/js/*` stays live by default. Verified end-to-end by `tests/bundle.spec.js`. Rebuild the bundle after JS edits if you ship with it enabled.
## Staged migration plan (the two big refactors — do incrementally, behind the smoke tests)
### 1. Decouple globals → ES modules
The global scope + monkey-patching is the root cause of the recurring "wiring/timing" bugs (controls bound before the section mounts, etc.). Migrate **one file at a time**:
1. Add `"type": "module"` loading path; keep a compat shim that re-exposes the few cross-module symbols on `window` during transition.
2. Convert leaf modules first (`utils`, `settings`, `voice-picker`), exporting explicitly and importing where used.
3. Replace the `renderVoiceList` monkey-patch with a normal function + an `onRender` hook.
4. Switch `loader.js` to a single `<script type="module">` entry that imports the graph; `esbuild --bundle` then produces one minified, tree-shaken file.
### 2. Split `rehearser.js` (was 196 KB) — IN PROGRESS
Done incrementally as global-scope ordered files (no ES-module change needed) — each loaded before `rehearser.js` in `loader.js` + added to the bundle list in `scripts/minify.mjs`, verified by `tests/rehearser.spec.js` (parse → cast) and the smoke suite:
- ✅ `rehearser-parse.js` — `parseScript`, `detectCharacters` (pure; only call-time `SPEAKER_COLORS`).
- ⬜ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, character research.
- ⬜ `rehearser-stage.js` — playback, synth cache, pre-decode window, transport.
- ⬜ `rehearser-train.js` — mic record / STT compare.
- ⬜ `rehearser-library.js` — IndexedDB session store, import/export, auto-save.
**Pattern (proven):** extract a cohesive block whose only cross-file references resolve at *call time* (event handlers), not at module load. Top-level `const rehState` and top-level `addEventListener` registrations must stay ordered — extract those last and load them after their dependencies. Re-run `npx playwright test` after each extraction.
## Remaining recommendations
- Replace remaining silent `.catch(() => {})` with `logErr(context, e)` (helper added in `utils.js`).
- Full a11y sweep (icon-button `aria-label`s, focus traps, keyboard nav in `createSearchablePicker`) — run the `web-design-guidelines` review.
- If ever exposed beyond localhost: add an auth token + rate limiting (SSRF is already guarded in `core/validation.py`).

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "tts-voice-creator-tests",
"version": "1.0.0",
"private": true,
"description": "E2E smoke tests for the TTS Voice Creator UI",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"install:browsers": "playwright install chromium",
"minify": "node scripts/minify.mjs"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"esbuild": "^0.24.0"
}
}

29
playwright.config.js Normal file
View File

@ -0,0 +1,29 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
// Point at a running instance. The app's docker container serves on :7890.
const BASE_URL = process.env.BASE_URL || 'http://localhost:7890';
module.exports = defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 10_000 },
// The app talks to a single uvicorn backend — running tests concurrently overwhelms
// it and makes navigation flaky, so keep it serial.
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: BASE_URL,
navigationTimeout: 45_000,
actionTimeout: 15_000,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
// iPhone profile — guards the mobile-stability work
{ name: 'iphone', use: { ...devices['iPhone 13'] } },
],
});

View File

@ -9,8 +9,12 @@
# Then paste this file into Portainer → Stacks → Add stack → Web editor # Then paste this file into Portainer → Stacks → Add stack → Web editor
# (or update the existing stack). # (or update the existing stack).
# #
# After any Python code change (core/ or routes/) you must redeploy this stack # This reconstructs the current stack from the running container plus the
# or rebuild the image — a plain container restart is not enough. # current app code. The older Portainer cache used 7890:7860, but this version
# listens on container port 7890.
#
# After any Python code change (core/ or routes/) you must recreate/redeploy
# this stack or rebuild the image. Static files are bind-mounted.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
services: services:
@ -40,6 +44,8 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- VOICES_DIR=/voices - VOICES_DIR=/voices
- OUTPUT_DIR=/voices/active_voices - OUTPUT_DIR=/voices/active_voices
- TTS_STREAM_URL=http://host.docker.internal:8023
- TTS_CONTAINER_NAME=faster-qwen3-tts-voiceclone
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming - TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign - VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign

View File

@ -25,7 +25,22 @@ async def index():
@router.get("/favicon.ico") @router.get("/favicon.ico")
async def favicon(): async def favicon():
return Response(status_code=204) return FileResponse(STATIC_DIR / "icon.svg", media_type="image/svg+xml")
# ── PWA: serve the service worker and manifest from root so the SW controls "/" ──
@router.get("/sw.js")
async def service_worker():
return FileResponse(
STATIC_DIR / "sw.js",
media_type="application/javascript",
headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"},
)
@router.get("/manifest.webmanifest")
async def manifest():
return FileResponse(STATIC_DIR / "manifest.webmanifest", media_type="application/manifest+json")
@router.get("/api/browse-dirs") @router.get("/api/browse-dirs")
@ -54,10 +69,11 @@ async def get_version():
@router.get("/api/changelog", response_class=PlainTextResponse) @router.get("/api/changelog", response_class=PlainTextResponse)
async def get_changelog(): async def get_changelog():
cl = Path(__file__).parent.parent / "CHANGELOG.md" root = Path(__file__).parent.parent
if not cl.is_file(): for cl in (root / "CHANGELOG.md", root / "static" / "CHANGELOG.md"):
raise HTTPException(404, "CHANGELOG.md not found") if cl.is_file():
return cl.read_text(encoding="utf-8") return cl.read_text(encoding="utf-8")
raise HTTPException(404, "CHANGELOG.md not found")
@router.get("/robots.txt", response_class=PlainTextResponse) @router.get("/robots.txt", response_class=PlainTextResponse)

View File

@ -107,9 +107,10 @@ def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) ->
async def refine_text(request: Request): async def refine_text(request: Request):
"""Clean up raw STT transcription using a local OpenAI-compatible LLM.""" """Clean up raw STT transcription using a local OpenAI-compatible LLM."""
data = await request.json() data = await request.json()
settings = _load_settings()
text: str = (data.get("text") or "").strip() text: str = (data.get("text") or "").strip()
llm_url: str = (data.get("llm_url") or "http://localhost:11434/v1").rstrip("/") llm_url: str = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or "").strip() model: str = (data.get("model") or settings.get("refine_model") or settings.get("llm_model") or "").strip()
toggles: dict = data.get("toggles") or {} toggles: dict = data.get("toggles") or {}
if not text: if not text:
@ -164,10 +165,11 @@ async def refine_text(request: Request):
async def rewrite_with_persona(request: Request): async def rewrite_with_persona(request: Request):
"""Rewrite user text in a voice persona's character using a local LLM.""" """Rewrite user text in a voice persona's character using a local LLM."""
data = await request.json() data = await request.json()
settings = _load_settings()
text: str = (data.get("text") or "").strip() text: str = (data.get("text") or "").strip()
persona: str = (data.get("persona") or "").strip() persona: str = (data.get("persona") or "").strip()
llm_url: str = (data.get("llm_url") or "http://localhost:11434/v1").rstrip("/") llm_url: str = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or "").strip() model: str = (data.get("model") or settings.get("llm_model") or "").strip()
mode: str = (data.get("mode") or "rewrite").strip() mode: str = (data.get("mode") or "rewrite").strip()
if not persona: if not persona:
@ -219,6 +221,220 @@ async def rewrite_with_persona(request: Request):
raise HTTPException(502, f"LLM persona rewrite failed: {e}") raise HTTPException(502, f"LLM persona rewrite failed: {e}")
@router.post("/api/analyze-characters")
async def analyze_characters(request: Request):
"""Analyze a script with an LLM and return per-character voice descriptions.
Used by the Script Rehearser to auto-design voices that match each role.
Returns: {"characters": [{name, gender, language, age, description}, ...]}
"""
data = await request.json()
script: str = (data.get("script") or "").strip()
names: list = data.get("names") or []
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not script:
raise HTTPException(400, "No script provided")
if not names:
raise HTTPException(400, "No character names provided")
# Truncate script to leave room for output within typical model context windows.
# German/non-English text tokenises at ~2.5–3 chars/token, so 6000 chars ≈ 2000–2400 tokens.
if len(script) > 6000:
script = script[:6000]
lang_hint = f" The script language is {language}." if language else ""
def _build_system() -> str:
return (
"You are a casting director and TTS voice-design expert. "
"Your job is to read a script, understand each character deeply from their "
"dialogue, role, and context, then write a voice description that a "
"text-to-speech model can use to generate a matching voice.\n"
f"{lang_hint}\n"
"For EVERY character in the provided list output:\n"
"- name: exact name as given\n"
"- gender: M, F, or N\n"
"- language: spoken language of this character\n"
"- age: estimated age range (e.g. 20s, 40s, elderly)\n"
"- description: 2–3 sentences covering pitch (high/mid/low), pace, "
"timbre, accent/dialect, emotional default, and any distinctive speech trait "
"that fits the character's personality and role.\n"
"Characters with few lines: infer from their role name and context.\n"
"Respond with STRICT JSON only — no markdown, no explanation:\n"
'{"characters":[{"name":"NAME","gender":"M|F|N","language":"LANG",'
'"age":"30s","description":"voice description"}]}\n'
"Use the exact character names provided.\n/no-think"
)
def _call_llm(batch: list[str]) -> list[dict]:
"""Call the LLM for one batch of character names; return list of character dicts."""
user_msg = (
"Character names: " + ", ".join(str(n) for n in batch) + "\n\n"
"Script:\n" + script
)
# 150 tokens per character output, capped at 3072 to stay within 16K context
mt = min(3072, max(512, len(batch) * 150))
payload: dict = {
"messages": [
{"role": "system", "content": _build_system()},
{"role": "user", "content": user_msg},
],
"temperature": 0.4,
"max_tokens": mt,
}
if model:
payload["model"] = model
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": "Bearer no-key"},
timeout=180,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
for candidate in (content, _extract_json_block(content)):
if not candidate:
continue
try:
parsed = json.loads(candidate)
if isinstance(parsed, dict) and "characters" in parsed:
return parsed["characters"]
except Exception:
continue
return []
# Process cast in batches of 10 to stay well within 16 K context
BATCH = 10
all_characters: list[dict] = []
try:
for i in range(0, len(names), BATCH):
batch = names[i:i + BATCH]
all_characters.extend(_call_llm(batch))
except Exception as e:
raise HTTPException(502, f"LLM character analysis failed: {e}")
if not all_characters:
raise HTTPException(502, "LLM did not return valid character JSON")
return {"characters": all_characters}
@router.post("/api/match-characters-voices")
async def match_characters_voices(request: Request):
"""Pick the best EXISTING library voice for each character (instead of designing new ones).
Body: {script, names:[...], voices:[{id, gender, language, tags, description}], llm_url, model, language}
Returns: {"assignments": [{name, voice_id, reason}]}
"""
data = await request.json()
script: str = (data.get("script") or "").strip()
names: list = data.get("names") or []
voices: list = data.get("voices") or []
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not names:
raise HTTPException(400, "No character names provided")
if not voices:
raise HTTPException(400, "No candidate voices provided")
if len(script) > 5000:
script = script[:5000]
def _vline(v: dict) -> str:
meta = [str(v.get(k)) for k in ("gender", "language") if v.get(k)]
if v.get("tags"):
meta.append("tags:" + str(v["tags"]))
desc = str(v.get("description") or v.get("name") or "")[:120]
return f"- {v.get('id')} [{', '.join(meta)}] {desc}".rstrip()
catalogue = "\n".join(_vline(v) for v in voices[:300])
valid_ids = {str(v.get("id")) for v in voices if v.get("id")}
system = (
"You are a casting director assigning existing TTS voices to script characters. "
f"{('Script language: ' + language + '. ') if language else ''}"
"For EACH character, choose the single BEST voice_id from the CATALOGUE. "
"RULE 1 — GENDER FIRST: the voice's gender MUST match the character's gender whenever the "
"character's gender is clear from the script; only pick a different gender if no same-gender "
"voice exists in the catalogue. "
"RULE 2 — then match apparent age, language, personality, and the voice's description/tags. "
"You MUST choose a voice_id that appears verbatim in the catalogue — never invent one. "
"Reuse a voice for two characters only if no better distinct option exists. "
"Respond with STRICT JSON only, no markdown:\n"
'{"assignments":[{"name":"NAME","voice_id":"ID","reason":"short reason"}]}\n/no-think'
)
user = (
"Characters to cast: " + ", ".join(str(n) for n in names) + "\n\n"
"CATALOGUE (voice_id [gender, language, tags] description):\n" + catalogue + "\n\n"
"Script excerpt:\n" + script
)
payload: dict = {
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"temperature": 0.3,
"max_tokens": min(2048, max(512, len(names) * 60)),
}
if model:
payload["model"] = model
import time as _time
raw = None
last_err: Exception | None = None
for attempt in range(3):
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": "Bearer no-key"}, timeout=180,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"].strip()
break
except requests.exceptions.ConnectionError as e:
# llama-swap (and similar) often drop the first request while swapping/loading
# the model — wait and retry rather than failing the cast.
last_err = e
_time.sleep(4 + attempt * 3)
except Exception as e:
last_err = e
break
if raw is None:
raise HTTPException(502, f"LLM voice matching failed: {last_err}")
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
assignments: list = []
for cand in (content, _extract_json_block(content)):
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and isinstance(parsed.get("assignments"), list):
assignments = parsed["assignments"]
break
except Exception:
continue
# Keep only assignments that reference a real catalogue voice
clean = [a for a in assignments if isinstance(a, dict) and str(a.get("voice_id")) in valid_ids]
return {"assignments": clean}
def _extract_json_block(text: str) -> str:
"""Pull the first {...} JSON object out of an LLM response."""
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
if text.startswith("```"):
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
text = re.sub(r"\n?```$", "", text).strip()
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
return ""
# ── Audio effects ───────────────────────────────────────────────────────────── # ── Audio effects ─────────────────────────────────────────────────────────────
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes: def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
@ -414,8 +630,9 @@ async def speak(request: Request):
persona = _load_meta(wav).get("persona", "") persona = _load_meta(wav).get("persona", "")
if persona: if persona:
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
llm_model = settings.get("llm_model") or ""
try: try:
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url) text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
except Exception as e: except Exception as e:
raise HTTPException(502, f"Persona rewrite failed: {e}") raise HTTPException(502, f"Persona rewrite failed: {e}")
@ -521,7 +738,8 @@ async def _mcp_tool_speak(args: dict, client_id: str) -> dict:
persona = _load_meta(wav).get("persona", "") persona = _load_meta(wav).get("persona", "")
if persona: if persona:
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url) llm_model = settings.get("llm_model") or ""
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
audio, _media_type = await asyncio.to_thread( audio, _media_type = await asyncio.to_thread(
_preview_request_audio, text, voice, settings, "", "voice_clone" _preview_request_audio, text, voice, settings, "", "voice_clone"
) )
@ -688,6 +906,25 @@ async def conversation_llm_models(url: str = ""):
return {"models": [], "url": base} return {"models": [], "url": base}
def _make_tts_task(
text: str,
voice: str,
settings: dict,
backend: str,
sem: "asyncio.Semaphore | None",
) -> "asyncio.Task":
if sem is None:
return asyncio.create_task(
asyncio.to_thread(_preview_request_audio, text, voice, settings, "", backend)
)
async def _guarded() -> tuple[bytes, str]:
async with sem:
return await asyncio.to_thread(_preview_request_audio, text, voice, settings, "", backend)
return asyncio.create_task(_guarded())
@router.post("/api/conversation/turn") @router.post("/api/conversation/turn")
async def conversation_turn( async def conversation_turn(
audio: Optional[UploadFile] = None, audio: Optional[UploadFile] = None,
@ -773,8 +1010,9 @@ async def conversation_turn(
messages.extend(hist[-20:]) messages.extend(hist[-20:])
messages.append({"role": "user", "content": transcript}) messages.append({"role": "user", "content": transcript})
llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512} llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512}
if llm_model: eff_llm_model = llm_model or settings.get("llm_model") or ""
llm_payload["model"] = llm_model if eff_llm_model:
llm_payload["model"] = eff_llm_model
_loop = asyncio.get_event_loop() _loop = asyncio.get_event_loop()
_token_q: asyncio.Queue[str | None] = asyncio.Queue() _token_q: asyncio.Queue[str | None] = asyncio.Queue()
@ -812,6 +1050,10 @@ async def conversation_turn(
threading.Thread(target=_llm_thread, daemon=True).start() threading.Thread(target=_llm_thread, daemon=True).start()
# Streaming backend (8023) has per-process voice state — serialize to prevent
# concurrent requests from clobbering each other's voice context.
tts_sem: asyncio.Semaphore | None = asyncio.Semaphore(1) if tts_be == "streaming" else None
t_llm = time.monotonic() t_llm = time.monotonic()
llm_ttft_ms: int | None = None llm_ttft_ms: int | None = None
ttft_done = False ttft_done = False
@ -843,9 +1085,7 @@ async def conversation_turn(
if tts_first_start is None: if tts_first_start is None:
tts_first_start = time.monotonic() tts_first_start = time.monotonic()
tts_texts.append(chunk_text) tts_texts.append(chunk_text)
tts_tasks.append(asyncio.create_task( tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem))
asyncio.to_thread(_preview_request_audio, chunk_text, tts_voice, settings, "", tts_be)
))
except Exception as exc: except Exception as exc:
yield sse({"type": "error", "stage": "llm", "message": str(exc)}) yield sse({"type": "error", "stage": "llm", "message": str(exc)})
return return
@ -855,9 +1095,7 @@ async def conversation_turn(
if tts_first_start is None: if tts_first_start is None:
tts_first_start = time.monotonic() tts_first_start = time.monotonic()
tts_texts.append(sent_buf.strip()) tts_texts.append(sent_buf.strip())
tts_tasks.append(asyncio.create_task( tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem))
asyncio.to_thread(_preview_request_audio, sent_buf.strip(), tts_voice, settings, "", tts_be)
))
llm_total_ms = int((time.monotonic() - t_llm) * 1000) llm_total_ms = int((time.monotonic() - t_llm) * 1000)
yield sse({"type": "llm_done", "text": llm_text, yield sse({"type": "llm_done", "text": llm_text,

View File

@ -111,7 +111,7 @@ async def probe_url(url: str, type: str = ""):
elif type == "stt": elif type == "stt":
checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)] checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)]
elif type == "tts": elif type == "tts":
checks = [("/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)] checks = [("/health", None), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)]
else: else:
checks = [("", None)] checks = [("", None)]

View File

@ -359,7 +359,8 @@ async def update_voice_meta(request: Request):
meta = _load_meta(wav) meta = _load_meta(wav)
meta["enabled"] = enabled meta["enabled"] = enabled
for field in ("note", "rating", "flag", "gender", "loudness", "persona"): for field in ("note", "rating", "flag", "gender", "loudness", "persona", "origin", "group",
"name", "tag", "avatar"):
if field in data: if field in data:
meta[field] = data[field] meta[field] = data[field]
if "transcript" in data: if "transcript" in data:
@ -648,6 +649,27 @@ async def rename_voice(request: Request):
# ── Voice delete ────────────────────────────────────────────────────────────── # ── Voice delete ──────────────────────────────────────────────────────────────
@router.post("/api/voices/delete-group")
async def delete_voice_group(request: Request):
"""Delete every voice whose meta `group` matches the given name. Used to bulk-remove
a rehearser play's auto-designed voices."""
data = await request.json()
group = str(data.get("group") or "").strip()
if not group:
raise HTTPException(400, "No group specified")
settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
deleted_ids = []
for p in _voice_audio_files(scan_dir):
meta = _load_meta(p)
if str(meta.get("group") or "").strip() == group:
for f in _voice_package_paths(p):
if f.exists():
f.unlink()
deleted_ids.append(p.stem)
return {"deleted": deleted_ids, "count": len(deleted_ids)}
@router.delete("/api/voice/{voice_id}") @router.delete("/api/voice/{voice_id}")
async def delete_voice(voice_id: str): async def delete_voice(voice_id: str):
if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id):

View File

@ -16,7 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from core.config import _load_settings from core.config import _load_settings
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR
from core.registry import _registry_put, TEMP_DIR from core.registry import _registry_put, TEMP_DIR
from core.validation import _validate_http_url from core.validation import _validate_http_url
from core.audio import _to_wav_24k, _duration from core.audio import _to_wav_24k, _duration
@ -59,6 +59,8 @@ _PROXY_AUDIO_DOMAINS: set[str] = {
"lanceblairvo.com", "lanceblairvo.com",
"raw.githubusercontent.com", "raw.githubusercontent.com",
"sample-files.com", "sample-files.com",
"platform.r2.fish.audio",
"fish.audio",
} }
_GDRIVE_MIME_EXT = { _GDRIVE_MIME_EXT = {
"audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/mpeg": "mp3", "audio/mp3": "mp3",
@ -624,3 +626,330 @@ async def elevenlabs_shared_voices(request: Request):
return r.json() return r.json()
except requests.exceptions.RequestException as exc: except requests.exceptions.RequestException as exc:
raise HTTPException(502, f"ElevenLabs API error: {exc}") raise HTTPException(502, f"ElevenLabs API error: {exc}")
# ── Fish.audio public voice library (api.fish.audio/model) ────────────────────
_FISHAUDIO_API = "https://api.fish.audio/model"
def _fishaudio_voice(item: dict) -> dict:
"""Normalize a fish.audio model record into a voice card with a clonable MP3 sample."""
samples = item.get("samples") or []
sample = samples[0] if samples else {}
langs = item.get("languages") or []
tags = item.get("tags") or []
gender = next((t for t in tags if t.lower() in ("male", "female", "neutral")), "")
age = next((t for t in tags if t.lower() in ("young", "middle-aged", "middle_aged", "old")), "")
author = item.get("author")
cover = item.get("cover_image")
return {
"id": item.get("_id", ""),
"title": _plain_text(item.get("title", ""))[:120],
"description": _plain_text(item.get("description", ""))[:400],
"tags": tags[:12],
"language": (langs[0] if langs else "").upper(),
"gender": gender.capitalize(),
"age": age.replace("_", "-").replace("middle-aged", "Middle-aged").capitalize() if age else "",
"plays": item.get("task_count", 0),
"likes": item.get("like_count", 0),
"author": author.get("nickname", "") if isinstance(author, dict) else (author or ""),
"image": f"https://public-platform.r2.fish.audio/{cover}" if cover else "",
"sample_audio": sample.get("audio", ""), # direct .mp3 URL (public, no auth)
"sample_text": _plain_text(sample.get("text", ""))[:600],
"default_text": _plain_text(item.get("default_text", ""))[:600],
}
@router.get("/api/fishaudio/voices")
async def fishaudio_voices(request: Request):
"""Browse the public fish.audio voice library. Returns clonable MP3 samples + transcripts."""
qp = request.query_params
# fish.audio only accepts these sort keys — clamp so a stale value never 422s.
sort_by = qp.get("sort_by", "score")
if sort_by not in ("score", "task_count", "created_at"):
sort_by = "score"
params: dict = {
"page_size": qp.get("page_size", "24"),
"page_number": qp.get("page", "1"),
"sort_by": sort_by,
}
if qp.get("search"):
params["title"] = qp["search"]
if qp.get("language"):
params["language"] = qp["language"]
# gender / age / any number of use-case & quality tags all map to fish.audio's
# repeatable `tag` filter
tags = [t for t in (list(qp.getlist("tag")) + [qp.get("gender"), qp.get("age")]) if t]
if tags:
params["tag"] = tags
import hashlib
cache_dir = CONFIG_DIR / "fishaudio_cache"
cache_key = hashlib.md5(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
cache_file = cache_dir / f"{cache_key}.json"
try:
r = await asyncio.to_thread(
lambda: requests.get(_FISHAUDIO_API, params=params, headers={"Accept": "application/json"}, timeout=15)
)
r.raise_for_status()
data = r.json()
except requests.exceptions.RequestException as exc:
# Offline fallback — serve a previously-cached copy of this query if we have one.
if cache_file.is_file():
cached = json.loads(cache_file.read_text(encoding="utf-8"))
cached["offline"] = True
return cached
raise HTTPException(502, f"fish.audio API error: {exc}")
items = [_fishaudio_voice(it) for it in data.get("items", []) if it.get("type") == "tts"]
result = {
"items": items,
"total": data.get("total", len(items)),
"page": int(params["page_number"]),
"page_size": int(params["page_size"]),
}
# Cache the normalized response so the browser keeps working if fish.audio goes offline.
try:
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(result), encoding="utf-8")
except Exception:
pass
return result
# ── Web script fetch (IMSDb + generic screenplay URLs) ────────────────────────
_SCRAPE_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
def _imsdb_title_from_url(raw_url: str) -> str:
"""Extract the movie title from any IMSDb URL variant (script page or detail page)."""
from urllib.parse import unquote as _unq
path = _unq(urlparse(raw_url).path)
# Direct script URL: /scripts/Some-Title.html → "Some Title"
m = re.match(r"^/scripts/(.+?)\.html?$", path, re.I)
if m:
return m.group(1).replace("-", " ").strip()
# Movie Scripts detail page: /Movie Scripts/Some Title[-Script].html
m2 = re.match(r"^/Movie[\s_]Scripts/(.+?)(?:[\s-]Script)?\.html?$", path, re.I)
if m2:
return m2.group(1).strip().rstrip("-").strip()
return ""
def _imsdb_read_link(detail_html: str) -> str:
"""Pull the real '/scripts/Title.html' target out of a detail page's 'Read … Script' anchor.
IMSDb occasionally emits a broken href like '/scripts/.' (its template chokes on
titles containing '?'); requiring a '.html' suffix rejects those so we fall back to slugs.
"""
m = re.search(r'<a[^>]+href="(/scripts/[^"]+\.html)"[^>]*>\s*Read', detail_html, re.I)
return m.group(1).strip() if m else ""
def _imsdb_script_candidates(title: str) -> list[str]:
"""Slug guesses for /scripts/{slug}.html — IMSDb's slugs are inconsistent so we try a few."""
base = re.sub(r"\s+", "-", title.strip())
variants = [
base, # spaces → dashes, punctuation kept (e.g. "1492:-Conquest…")
re.sub(r"[?!*]", "", base), # drop chars IMSDb strips (e.g. "Who-Framed-Roger-Rabbit")
re.sub(r"[?!*:,'\"]", "", base), # drop most punctuation (e.g. "Star-Wars-A-New-Hope")
]
out, seen = [], set()
for slug in variants:
if slug and slug not in seen:
seen.add(slug)
out.append(f"https://www.imsdb.com/scripts/{slug}.html")
return out
def _looks_like_script(html: str) -> bool:
return bool(re.search(r"<pre", html, re.I)) and len(html) > 2500
def _fetch_imsdb_script(raw_url: str, headers: dict) -> tuple[requests.Response, str]:
"""Resolve any IMSDb URL to its real screenplay page and fetch it.
Strategy mirrors the site's own navigation: all-scripts → detail page → 'Read Script' link.
Falls back to slug guesses when the detail link is missing or broken. Returns (response, title).
"""
title = _imsdb_title_from_url(raw_url)
candidates: list[str] = []
# 1. If we were handed a direct /scripts/ URL, try it as-is first.
if re.match(r"^/scripts/", urlparse(raw_url).path, re.I):
candidates.append(f"https://www.imsdb.com{urlparse(raw_url).path}")
# 2. Resolve via the detail page's authoritative "Read Script" link.
if title:
detail_url = f"https://www.imsdb.com/Movie Scripts/{title} Script.html"
try:
dresp = requests.get(detail_url, headers=headers, timeout=25)
if dresp.ok:
link = _imsdb_read_link(dresp.text)
if link:
candidates.append(urljoin("https://www.imsdb.com/", link))
except requests.exceptions.RequestException:
pass
# 3. Slug guesses as a fallback for broken/missing detail links.
candidates.extend(_imsdb_script_candidates(title))
if not candidates:
candidates.append(raw_url)
last: requests.Response | None = None
for url in dict.fromkeys(candidates): # dedupe, preserve order
try:
resp = requests.get(url, headers=headers, timeout=25, allow_redirects=True)
except requests.exceptions.RequestException:
continue
last = resp
if resp.ok and _looks_like_script(resp.text):
return resp, title
if last is None:
raise HTTPException(502, "Could not reach IMSDb for that script")
return last, title
def _extract_screenplay_text(html: str) -> str:
"""Extract and clean screenplay text from a page's <pre> block."""
m = re.search(r"<pre[^>]*>([\s\S]+?)</pre>", html, re.I)
if not m:
return ""
raw = m.group(1)
# Preserve text inside <b> and <i> (character names / stage dirs), strip others
raw = re.sub(r"<b>(.*?)</b>", r"\1", raw, flags=re.DOTALL | re.I)
raw = re.sub(r"<i>(.*?)</i>", r"\1", raw, flags=re.DOTALL | re.I)
raw = re.sub(r"<[^>]+>", "", raw)
text = unescape(raw).replace("\r\n", "\n").replace("\r", "\n").strip()
# Collapse runs of 4+ blank lines to 2
text = re.sub(r"\n{4,}", "\n\n\n", text)
return text
@router.post("/api/fetch-web-script")
async def fetch_web_script(request: Request):
"""Fetch a screenplay from a web URL (IMSDb and plain text/HTML supported)."""
data = await request.json()
raw_url: str = (data.get("url") or "").strip()
if not raw_url:
raise HTTPException(400, "No URL provided")
# Validate URL
parsed = urlparse(raw_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(400, "Invalid URL — must start with http:// or https://")
host = parsed.netloc.lower().replace("www.", "")
title = ""
if "imsdb.com" in host:
headers = {**_SCRAPE_HEADERS, "Referer": "https://www.imsdb.com/"}
try:
resp, title = await asyncio.to_thread(_fetch_imsdb_script, raw_url, headers)
resp.raise_for_status()
except HTTPException:
raise
except requests.exceptions.RequestException as e:
raise HTTPException(502, f"Fetch failed: {e}")
fetch_url = resp.url
else:
fetch_url = raw_url
headers = _SCRAPE_HEADERS
try:
resp = await asyncio.to_thread(
lambda: requests.get(fetch_url, headers=headers, timeout=25, allow_redirects=True)
)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(502, f"Fetch failed: {e}")
content_type = resp.headers.get("content-type", "").lower()
if "text/plain" in content_type:
text = resp.text.strip()
else:
# HTML — try to extract <pre> screenplay block
text = _extract_screenplay_text(resp.text)
if not text:
raise HTTPException(422, "Could not find screenplay text on that page. Try pasting the script manually.")
if len(text) < 200:
raise HTTPException(422, "The page loaded but the script appears empty or too short.")
# (continues below)
return _web_script_result(text, title, fetch_url, parsed)
def _web_script_result(text, title, fetch_url, parsed):
if not title:
title = parsed.path.split("/")[-1].replace(".html", "").replace("-", " ").replace("_", " ").strip()
return {"text": text, "title": title, "url": fetch_url, "chars": len(text)}
# ── IMSDb library browser ─────────────────────────────────────────────────────
_IMSDB_LIST_CACHE: dict = {"items": None, "ts": 0.0}
_POSTER_CACHE: dict = {} # title → poster url (or "" if none found)
@router.get("/api/imsdb/list")
async def imsdb_list():
"""Return the full IMSDb script catalogue: [{title, fetch_url}]. Cached 6 h in-memory."""
import time as _t
now = _t.time()
if _IMSDB_LIST_CACHE["items"] and (now - _IMSDB_LIST_CACHE["ts"] < 6 * 3600):
return {"items": _IMSDB_LIST_CACHE["items"], "cached": True}
try:
resp = await asyncio.to_thread(
lambda: requests.get("https://imsdb.com/all-scripts.html", headers=_SCRAPE_HEADERS, timeout=25)
)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(502, f"Could not reach IMSDb: {e}")
items = []
seen = set()
for href, title in re.findall(r'<a href="(/Movie Scripts/[^"]+)"[^>]*>([^<]+)</a>', resp.text):
title = unescape(title).strip()
if not title or title in seen:
continue
seen.add(title)
# Hand back the detail-page URL; the fetch route follows its real "Read Script"
# link (IMSDb's per-script slugs are inconsistent, so a guessed slug often 404s).
items.append({"title": title, "fetch_url": f"https://www.imsdb.com{href}"})
items.sort(key=lambda x: x["title"].lower())
_IMSDB_LIST_CACHE["items"] = items
_IMSDB_LIST_CACHE["ts"] = now
return {"items": items, "cached": False}
@router.get("/api/movie-poster")
async def movie_poster(title: str = ""):
"""Look up a movie poster + year via OMDb. Cached per title in-memory."""
title = (title or "").strip()
if not title:
return {"poster": "", "year": ""}
if title in _POSTER_CACHE:
return _POSTER_CACHE[title]
from urllib.parse import quote as _q
# Strip trailing year/qualifiers that hurt matching
clean = re.sub(r"\s*\(.*?\)\s*$", "", title).strip()
result = {"poster": "", "year": "", "title": title}
try:
url = f"http://www.omdbapi.com/?t={_q(clean)}&type=movie&apikey=trilogy"
r = await asyncio.to_thread(lambda: requests.get(url, timeout=8))
if r.ok:
d = r.json()
poster = d.get("Poster", "")
if poster and poster != "N/A":
result["poster"] = poster
result["year"] = d.get("Year", "") or ""
except Exception:
pass
_POSTER_CACHE[title] = result
return result

View File

@ -41,6 +41,7 @@ from core.tts_helpers import (
_route_sound_path, _voice_design_request_audio, _voice_design_voice_request_audio, _route_sound_path, _voice_design_request_audio, _voice_design_voice_request_audio,
_nvidia_clone_request_audio, _parse_voice_design_dialogue, _nvidia_clone_request_audio, _parse_voice_design_dialogue,
_infer_voice_design_gender, _voice_design_dialogue_request_audio, _infer_voice_design_gender, _voice_design_dialogue_request_audio,
_fishspeech_request_audio,
) )
from core.audio import _duration, _export_normalized_wav from core.audio import _duration, _export_normalized_wav
from core.docker_client import _docker_post from core.docker_client import _docker_post
@ -74,6 +75,7 @@ def _backend_display_name(backend: str, url: str) -> str:
"kokoro": "Kokoro FastAPI (82M)", "kokoro": "Kokoro FastAPI (82M)",
"vibevoice": "VibeVoice TTS", "vibevoice": "VibeVoice TTS",
"xtts": "XTTS v2", "xtts": "XTTS v2",
"fishspeech": "Fish-Speech (Clone + Emotion)",
} }
port = _backend_port_label(url) port = _backend_port_label(url)
return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend) return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend)
@ -161,6 +163,14 @@ def _backend_capabilities(backend: str) -> dict:
"uses_wav": False, "style_aware": False, "true_streaming": False, "uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "~0.3× GPU", "latency": "1–3 s", "quality": "High", "ram": "~5 GB", "speed": "~0.3× GPU", "latency": "1–3 s", "quality": "High", "ram": "~5 GB",
}, },
"fishspeech": {
"purpose": "Clone a saved WAV voice AND control per-line tone via inline emotion markers.",
"identity": "Clones the selected voice's reference WAV — consistent speaker identity across lines.",
"style": "Strong: emotion/tone markers like (angry), (whispering), (excited) are honoured per request.",
"best_for": "Consistent character voices that still react to tone changes — ideal for the Rehearser.",
"uses_wav": True, "style_aware": True, "true_streaming": False,
"speed": "~0.4× GPU", "latency": "1–4 s", "quality": "Premium", "ram": "~4 GB",
},
} }
return caps.get(_clean_preview_backend(backend), {}) return caps.get(_clean_preview_backend(backend), {})
@ -178,7 +188,7 @@ def _backend_health(url: str) -> bool:
def _backend_available(backend: str, voices: list, health: bool) -> bool: def _backend_available(backend: str, voices: list, health: bool) -> bool:
if _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice"}: if _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "fishspeech"}:
return health return health
return bool(voices) or health return bool(voices) or health
@ -314,7 +324,7 @@ async def tts_voices(backend: str = "voice_clone"):
async def tts_backends(): async def tts_backends():
settings = _load_settings() settings = _load_settings()
items = [] items = []
for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"): for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "fishspeech", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"):
url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
voices = _fetch_backend_voices(settings, backend) voices = _fetch_backend_voices(settings, backend)
health = _backend_health(url) health = _backend_health(url)
@ -455,9 +465,10 @@ async def tts_preview(request: Request):
persona = _load_meta(wav).get("persona", "") persona = _load_meta(wav).get("persona", "")
if persona: if persona:
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
llm_model = settings.get("llm_model") or ""
try: try:
from routes.conversation import _rewrite_with_persona_sync from routes.conversation import _rewrite_with_persona_sync
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url) text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
except Exception as e: except Exception as e:
raise HTTPException(502, f"Persona rewrite failed: {e}") raise HTTPException(502, f"Persona rewrite failed: {e}")
@ -777,6 +788,11 @@ async def openai_speech_proxy(request: Request):
str(data.get("audio_prompt_transcript") or ""), str(data.get("audio_prompt_transcript") or ""),
str(data.get("language") or ""), str(data.get("language") or ""),
) )
elif backend == "fishspeech":
audio, media_type = await asyncio.to_thread(
_fishspeech_request_audio, text, voice, settings,
style_instruction, str(data.get("language") or ""),
)
else: else:
audio, media_type = await asyncio.to_thread( audio, media_type = await asyncio.to_thread(
_tts_request_audio, text, voice, settings, style_instruction, _tts_request_audio, text, voice, settings, style_instruction,

42
scripts/minify.mjs Normal file
View File

@ -0,0 +1,42 @@
// Build an opt-in production bundle WITHOUT changing runtime semantics.
//
// The feature modules share a single global scope and are executed in a fixed order
// (see loader.js). We therefore CONCATENATE them in that exact order into one file and
// minify whitespace/syntax only — identifiers are kept, because inline HTML handlers
// (onclick="navTo(...)") and cross-file references rely on global names.
//
// Run: npm run minify → static/dist/main.min.js
// Enable at runtime with window.APP_USE_BUNDLE=true (or ?bundle=1). Default stays
// per-file so editing static/js/* is live without a rebuild.
import { transform } from 'esbuild';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const jsDir = join(root, 'static', 'js');
const outDir = join(root, 'static', 'dist');
mkdirSync(outDir, { recursive: true });
// Must match loader.js batch C order exactly.
const MAIN = [
'voice-picker', 'voice-inspector', 'voice-sources', 'fishaudio-browser',
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
'benchmark', 'stt', 'rehearser-parse', 'rehearser',
].map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
const { code } = await transform(source, {
loader: 'js',
minify: false,
minifyWhitespace: true,
minifySyntax: true,
minifyIdentifiers: false, // keep global names referenced by HTML & other files
legalComments: 'none',
target: ['es2019', 'safari14'],
});
const out = join(outDir, 'main.min.js');
writeFileSync(out, code);
console.log(`Bundled ${MAIN.length} modules → static/dist/main.min.js (${(code.length / 1024).toFixed(0)} KB)`);

View File

@ -9,6 +9,7 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import Response from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware
from core.constants import STATIC_DIR, _BufferHandler from core.constants import STATIC_DIR, _BufferHandler
from routes import admin, settings, library, stt, sources, docker, tts, conversation from routes import admin, settings, library, stt, sources, docker, tts, conversation
@ -19,6 +20,11 @@ logger = logging.getLogger("uvicorn.error")
app = FastAPI(title="TTS Voice Creator - Clone and Design") app = FastAPI(title="TTS Voice Creator - Clone and Design")
# ── Response compression ──────────────────────────────────────────────────────
# Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the
# large JS bundle and voice-list JSON. Skips already-compressed audio/images.
app.add_middleware(GZipMiddleware, minimum_size=1024)
# ── In-memory log buffer handler ────────────────────────────────────────────── # ── In-memory log buffer handler ──────────────────────────────────────────────
_buf_handler = _BufferHandler() _buf_handler = _BufferHandler()
@ -58,8 +64,13 @@ async def static_cache_headers(request: Request, call_next):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
elif path.startswith("/static/sections/"): elif path.startswith("/static/sections/"):
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
elif path.startswith("/static/vendor/"):
response.headers["Cache-Control"] = "public, max-age=86400"
elif path.startswith("/static/") and not has_version: elif path.startswith("/static/") and not has_version:
response.headers["Cache-Control"] = "public, max-age=3600" # Bootstrap files (style.css, loader.js, nav.js, …) are loaded without a ?v= query.
# Revalidate on every load so GUI edits appear after a normal reload — the server
# returns 304 when the file is unchanged, so this stays cheap.
response.headers["Cache-Control"] = "no-cache"
return response return response

9
static/icon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="96" fill="#2563EB"/>
<g fill="#ffffff">
<rect x="216" y="96" width="80" height="190" rx="40"/>
<path d="M160 240a96 96 0 0 0 192 0h-36a60 60 0 0 1-120 0z"/>
<rect x="238" y="336" width="36" height="56" rx="6"/>
<rect x="188" y="392" width="136" height="32" rx="16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@ -2,8 +2,24 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- viewport-fit=cover respects the iPhone notch/safe areas; allow zoom for accessibility -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
<title>TTS Voice Creator</title> <title>TTS Voice Creator</title>
<script>
/* PWA: register the network-first service worker (offline shell + installable) */
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {}));
}
</script>
<!-- ── Preconnect to CDN (flag SVGs loaded lazily by JS) ──────────────── --> <!-- ── Preconnect to CDN (flag SVGs loaded lazily by JS) ──────────────── -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
@ -91,7 +107,7 @@
<div id="app-shell"> <div id="app-shell">
<!-- Sidebar (renders immediately — no JS needed) --> <!-- Sidebar (renders immediately — no JS needed) -->
<aside id="sidebar"> <aside id="sidebar" tabindex="0" aria-label="Navigation">
<div class="sidebar-brand"> <div class="sidebar-brand">
<h1><span class="mdi mdi-microphone-variant"></span> Voice Creator</h1> <h1><span class="mdi mdi-microphone-variant"></span> Voice Creator</h1>
<p>Clone &middot; Design &middot; Deploy</p> <p>Clone &middot; Design &middot; Deploy</p>
@ -103,21 +119,32 @@
<span class="nav-icon"><span class="mdi mdi-account-voice"></span></span> <span class="nav-icon"><span class="mdi mdi-account-voice"></span></span>
<span class="nav-label">My Voices</span> <span class="nav-label">My Voices</span>
<span class="nav-badge" id="nav-voices-count"></span> <span class="nav-badge" id="nav-voices-count"></span>
<span class="nav-chevron" id="nav-voices-chevron"><span class="mdi mdi-chevron-down"></span></span> <span class="nav-chevron" id="nav-voices-chevron" onclick="event.stopPropagation();toggleNavTree('nav-voices-tree','nav-voices-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div> </div>
<div class="nav-tree open" id="nav-voices-tree"> <div class="nav-tree open" id="nav-voices-tree">
<div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')">All voices <span class="ntc" id="ntc-all"></span></div> <div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')"><span class="mdi mdi-account-voice"></span> All voices <span class="ntc" id="ntc-all"></span></div>
<div class="nav-tree-item" data-voice-cat="cloned" onclick="navVoicesCat('cloned')">Cloned <span class="ntc" id="ntc-cloned"></span></div> <div class="nav-tree-item" data-voice-cat="cloned" onclick="navVoicesCat('cloned')"><span class="mdi mdi-microphone-variant"></span> Cloned <span class="ntc" id="ntc-cloned"></span></div>
<div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')">Designed <span class="ntc" id="ntc-designed"></span></div> <div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')"><span class="mdi mdi-auto-fix"></span> Designed <span class="ntc" id="ntc-designed"></span></div>
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')">Favorites <span class="ntc" id="ntc-favorites"></span></div> <div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')"><span class="mdi mdi-star-outline"></span> Favorites <span class="ntc" id="ntc-favorites"></span></div>
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')">Hidden <span class="ntc" id="ntc-hidden"></span></div> <div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')"><span class="mdi mdi-eye-off-outline"></span> Hidden <span class="ntc" id="ntc-hidden"></span></div>
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')">Library tools</div> <div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')"><span class="mdi mdi-wrench-outline"></span> Library tools</div>
</div> </div>
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')"> <span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice</div> <div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')"> <span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice</div>
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div> <div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div> <div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div>
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div> <div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div>
<div class="nav-item" data-nav-section="s-rehearser" onclick="navTo('s-rehearser')"> <span class="nav-icon"><span class="mdi mdi-theater"></span></span> Script Rehearser</div> <div class="nav-item nav-tree-head" data-nav-section="s-rehearser" id="nav-rehearser-head" onclick="navTo('s-rehearser')">
<span class="nav-icon"><span class="mdi mdi-theater"></span></span>
<span class="nav-label">Script Rehearser</span>
<span class="nav-chevron" id="nav-rehearser-chevron" onclick="event.stopPropagation();toggleNavTree('nav-rehearser-tree','nav-rehearser-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-rehearser-tree">
<div class="nav-tree-item is-active" data-rehearser-phase="1" onclick="navRehearserPhase(1)"><span class="mdi mdi-bookshelf"></span> Library</div>
<div class="nav-tree-item" data-rehearser-phase="2" onclick="navRehearserPhase(2)"><span class="mdi mdi-account-group-outline"></span> Cast</div>
<div class="nav-tree-item" data-rehearser-phase="3" onclick="navRehearserPhase(3)"><span class="mdi mdi-theater"></span> Stage</div>
<div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
</div>
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div> <div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div> <div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
@ -125,31 +152,31 @@
<div class="nav-item nav-tree-head" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')"> <div class="nav-item nav-tree-head" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')">
<span class="nav-icon"><span class="mdi mdi-brain"></span></span> <span class="nav-icon"><span class="mdi mdi-brain"></span></span>
<span class="nav-label">Engines</span> <span class="nav-label">Engines</span>
<span class="nav-chevron" id="nav-engines-chevron"><span class="mdi mdi-chevron-down"></span></span> <span class="nav-chevron" id="nav-engines-chevron" onclick="event.stopPropagation();toggleNavTree('nav-engines-tree','nav-engines-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div> </div>
<div class="nav-tree" id="nav-engines-tree"> <div class="nav-tree" id="nav-engines-tree">
<div class="nav-tree-item is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')">Language Models</div> <div class="nav-tree-item is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')"><span class="mdi mdi-brain"></span> Language Models</div>
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')">Speech to Text</div> <div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')">Text to Speech</div> <div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-text-to-speech"></span> Text to Speech</div>
</div> </div>
<div class="nav-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-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" 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')"> <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-icon"><span class="mdi mdi-cog-outline"></span></span>
<span class="nav-label">Settings</span> <span class="nav-label">Settings</span>
<span class="nav-chevron" id="nav-settings-chevron"><span class="mdi mdi-chevron-down"></span></span> <span class="nav-chevron" id="nav-settings-chevron" onclick="event.stopPropagation();toggleNavTree('nav-settings-tree','nav-settings-chevron')"><span class="mdi mdi-chevron-down"></span></span>
</div> </div>
<div class="nav-tree" id="nav-settings-tree"> <div class="nav-tree" id="nav-settings-tree">
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')">General</div> <div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')"><span class="mdi mdi-tune-vertical"></span> General</div>
<div class="nav-tree-item" data-settings-cat="connections" onclick="navSettingsCat('connections')">Connections</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')">Playback</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')">Captures</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')">Payloads</div> <div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')"><span class="mdi mdi-code-json"></span> Payloads</div>
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')">Storage</div> <div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')"><span class="mdi mdi-folder-outline"></span> Storage</div>
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')">API Keys</div> <div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')"><span class="mdi mdi-key-outline"></span> API Keys</div>
<div class="nav-tree-item" data-settings-cat="backup" onclick="navSettingsCat('backup')">Backup</div> <div class="nav-tree-item" data-settings-cat="backup" onclick="navSettingsCat('backup')"><span class="mdi mdi-backup-restore"></span> Backup</div>
<div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')">Logs</div> <div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')"><span class="mdi mdi-text-box-outline"></span> Logs</div>
<div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')">About</div> <div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')"><span class="mdi mdi-information-outline"></span> About</div>
</div> </div>
</nav> </nav>

View File

@ -159,6 +159,13 @@
toast('Kokoro FastAPI URL saved → Settings. It now appears as "Kokoro FastAPI (82M)" in the TTS backend dropdown.', 'success'); toast('Kokoro FastAPI URL saved → Settings. It now appears as "Kokoro FastAPI (82M)" in the TTS backend dropdown.', 'success');
}); });
$('llm-use-fishspeech-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="fishspeech"]')?.value.trim()
|| 'http://host.docker.internal:38080';
applyAndSaveSettings({ fishspeech_url: url });
toast('Fish-Speech URL saved → Settings. Pick "Fish-Speech (Clone + Emotion)" in Try It Out → Backend or the Rehearser.', 'success');
});
$('llm-use-vibevoice-tts')?.addEventListener('click', () => { $('llm-use-vibevoice-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="vibevoice"]')?.value.trim() const url = document.querySelector('[data-llm-local-key="vibevoice"]')?.value.trim()
|| 'http://192.168.178.8:8027'; || 'http://192.168.178.8:8027';
@ -185,15 +192,99 @@
'llm-use-vllm-llm': { key: 'vllm', fallback: 'http://localhost:8000/v1' }, 'llm-use-vllm-llm': { key: 'vllm', fallback: 'http://localhost:8000/v1' },
'llm-use-lmstudio-llm': { key: 'lmstudio', fallback: 'http://localhost:1234/v1' }, 'llm-use-lmstudio-llm': { key: 'lmstudio', fallback: 'http://localhost:1234/v1' },
'llm-use-llamacpp-llm': { key: 'llamacpp', fallback: 'http://localhost:8080/v1' }, 'llm-use-llamacpp-llm': { key: 'llamacpp', fallback: 'http://localhost:8080/v1' },
'llm-use-llama-swap-llm': { key: 'llama-swap', fallback: 'http://host.docker.internal:28080/v1' },
'llm-use-litellm-llm': { key: 'litellm', fallback: 'http://host.docker.internal:14000/v1' },
}; };
Object.entries(LLM_USE_MAP).forEach(([id, { key, fallback }]) => { Object.entries(LLM_USE_MAP).forEach(([id, { key, fallback }]) => {
$(id)?.addEventListener('click', () => { $(id)?.addEventListener('click', () => {
const url = document.querySelector(`[data-llm-local-key="${key}"]`)?.value.trim() || fallback; const url = document.querySelector(`[data-llm-local-key="${key}"]`)?.value.trim() || fallback;
applyAndSaveSettings({ llm_url: url }); applyAndSaveSettings({ llm_url: url });
toast(`LLM URL set to ${url} → Settings. Used for persona rewrite and transcription refinement.`, 'success'); // Keep the global Active-LLM panel in sync with the card the user applied
window.syncActiveLlmPanel?.(url);
toast(`Active LLM endpoint set to ${url}. Pick a model in "Active Language Model" above.`, 'success');
}); });
}); });
// ── Active Language Model (global picker) ───────────────────────────────
(function initActiveLlmPanel() {
const urlInp = $('llm-active-url');
const modelSel = $('llm-active-model');
const refreshBtn = $('llm-active-refresh');
const statusEl = $('llm-active-status');
if (!urlInp || !modelSel) return;
function setStatus(msg, cls) {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.className = 'llm-active-status' + (cls ? ' ' + cls : '');
}
async function fetchModels(url) {
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());
const models = d.models || [];
modelSel.innerHTML = models.length
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
: '<option value="">No models found</option>';
if (want && models.includes(want)) modelSel.value = want;
setStatus(models.length ? `${models.length} model${models.length === 1 ? '' : 's'}` : 'No models found',
models.length ? 'ok' : 'err');
} catch (e) {
modelSel.innerHTML = '<option value="">Fetch failed</option>';
setStatus('Could not reach endpoint', 'err');
} finally {
if (refreshBtn) refreshBtn.disabled = false;
}
}
// Commit URL on blur or Enter — saves to settings and refetches models
function commitUrl() {
const url = urlInp.value.trim();
if (!url) return;
applyAndSaveSettings({ llm_url: url });
if (_appSettings) _appSettings.llm_url = url;
fetchModels(url);
}
urlInp.addEventListener('blur', commitUrl);
urlInp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); commitUrl(); } });
// Also react when user picks a datalist suggestion (fires 'change')
urlInp.addEventListener('change', commitUrl);
modelSel.addEventListener('change', () => {
applyAndSaveSettings({ llm_model: modelSel.value });
if (_appSettings) _appSettings.llm_model = modelSel.value;
if (modelSel.value) setStatus('Active model: ' + modelSel.value, 'ok');
});
refreshBtn?.addEventListener('click', () => fetchModels());
// Exposed so the "Use as LLM" card buttons can sync this panel
window.syncActiveLlmPanel = function (url) {
urlInp.value = url || '';
fetchModels(url);
};
// Initial state — restore saved endpoint + fetch its models
(async function () {
let url = (_appSettings && _appSettings.llm_url) || '';
if (!url) {
try {
const s = await fetch('/api/settings').then(r => r.json());
url = s.llm_url || '';
if (!_appSettings) _appSettings = s;
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);
})();
})();
// ── Inline STT quick-test panel ───────────────────────────────────────── // ── Inline STT quick-test panel ─────────────────────────────────────────
(async function initSttTestPanel() { (async function initSttTestPanel() {
const panel = $('stt-test-panel'); const panel = $('stt-test-panel');

View File

@ -176,8 +176,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (chatWindow) chatWindow.prepend(warn); if (chatWindow) chatWindow.prepend(warn);
} }
// Restore conv LLM URL from server settings // Restore conv LLM URL from server settings — fall back to the global Active LLM endpoint
if (llmUrlInp && _appSettings && _appSettings.conv_llm_url) llmUrlInp.value = _appSettings.conv_llm_url; if (llmUrlInp && _appSettings) llmUrlInp.value = _appSettings.conv_llm_url || _appSettings.llm_url || '';
if (llmUrlInp) llmUrlInp.addEventListener('input', () => { if (llmUrlInp) llmUrlInp.addEventListener('input', () => {
_patchSettings({ conv_llm_url: llmUrlInp.value }); _patchSettings({ conv_llm_url: llmUrlInp.value });
if (_appSettings) _appSettings.conv_llm_url = llmUrlInp.value; if (_appSettings) _appSettings.conv_llm_url = llmUrlInp.value;
@ -280,6 +280,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
llmModelSel.innerHTML = models.length llmModelSel.innerHTML = models.length
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('') ? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
: '<option value="">No models found</option>'; : '<option value="">No models found</option>';
// Default to the global Active LLM model when available (still overridable here)
const want = _appSettings && _appSettings.llm_model;
if (want && models.includes(want)) llmModelSel.value = want;
} catch(e) { } catch(e) {
llmModelSel.innerHTML = '<option value="">Fetch failed</option>'; llmModelSel.innerHTML = '<option value="">Fetch failed</option>';
} finally { } finally {

View File

@ -0,0 +1,266 @@
// ── Fish.audio public voice library browser ─────────────────────────────────
// Lists voices from /api/fishaudio/voices (proxy to api.fish.audio) and imports a
// clonable sample (MP3 + transcript) into the voice library via /api/quick-import-voice.
(function () {
const $ = id => document.getElementById(id);
const grid = $('fa-grid');
if (!grid) return;
let _page = 1;
let _audio = null, _playingCard = null;
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// 28902 → "28.9K", 698 → "698", 1_240_000 → "1.2M"
function fmtCount(n) {
n = +n || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return String(n);
}
const FLAG = { EN: '🇬🇧', DE: '🇩🇪', FR: '🇫🇷', ES: '🇪🇸', IT: '🇮🇹', PT: '🇵🇹', NL: '🇳🇱', JA: '🇯🇵', ZH: '🇨🇳', KO: '🇰🇷', RU: '🇷🇺', AR: '🇸🇦', PL: '🇵🇱' };
function status(msg, show = true) {
const bar = $('fa-status-bar'), txt = $('fa-status-text');
if (bar) bar.hidden = !show;
if (txt) txt.textContent = msg || '';
}
// Collect active filter values from the popover
function activeVals(group) {
return [...document.querySelectorAll(`#fa-filter-pop .fa-pchips[data-group="${group}"] .fa-pchip.active`)]
.map(c => c.dataset.val).filter(Boolean);
}
function filterCount() {
return activeVals('gender').length + activeVals('age').length + activeVals('tag').length
+ (($('fa-tag')?.value.trim()) ? 1 : 0);
}
function updateFilterBadge() {
const n = filterCount(), b = $('fa-filter-badge'), btn = $('fa-filter-btn');
if (b) { b.textContent = String(n); b.hidden = n === 0; }
btn?.classList.toggle('active', n > 0);
}
function params() {
const p = new URLSearchParams({ page: String(_page), page_size: '24' });
const s = $('fa-search')?.value.trim(); if (s) p.set('search', s);
const l = $('fa-lang')?.value; if (l) p.set('language', l);
const sort = $('fa-sort')?.value; if (sort) p.set('sort_by', sort);
const g = activeVals('gender')[0]; if (g) p.set('gender', g);
const a = activeVals('age')[0]; if (a) p.set('age', a);
activeVals('tag').forEach(t => p.append('tag', t)); // use-case + quality (multi)
const t = $('fa-tag')?.value.trim(); if (t) p.append('tag', t);
return p;
}
function card(v) {
const can = !!v.sample_audio;
const meta = [
v.language ? `<span class="fa-meta-chip">${FLAG[v.language] || '🌐'} ${esc(v.language)}</span>` : '',
v.gender ? `<span class="fa-meta-chip">${esc(v.gender)}</span>` : '',
v.age ? `<span class="fa-meta-chip">${esc(v.age)}</span>` : '',
].join('');
const avatar = v.image
? `<img class="fa-avatar" src="${esc(v.image)}" alt="" loading="lazy" onerror="this.parentElement.classList.add('fa-avatar-fallback');this.remove();">`
: '';
return `<div class="fa-card${can ? '' : ' fa-card-disabled'}" data-id="${esc(v.id)}">
<div class="fa-card-avatar${v.image ? '' : ' fa-avatar-fallback'}">
${avatar}
${can ? `<button class="fa-play" title="Preview"><span class="mdi mdi-play"></span></button>` : ''}
</div>
<div class="fa-card-body">
<div class="fa-card-top">
<span class="fa-card-name" title="${esc(v.title)}">${esc(v.title) || 'Untitled'}</span>
${v.author ? `<span class="fa-card-author">· ${esc(v.author)}</span>` : ''}
</div>
<div class="fa-card-desc">${esc(v.description || v.sample_text || '')}</div>
<div class="fa-card-meta">${meta}</div>
<div class="fa-card-foot">
<span class="fa-stat" title="Generations"><span class="mdi mdi-poll"></span> ${fmtCount(v.plays)}</span>
<span class="fa-stat" title="Likes"><span class="mdi mdi-heart-outline"></span> ${fmtCount(v.likes)}</span>
${can ? `<button class="btn-primary btn-sm fa-import" title="Import as a clonable voice"><span class="mdi mdi-download"></span> Use</button>` : ''}
</div>
</div>
</div>`;
}
function render(data) {
const items = data.items || [];
if (!items.length) { grid.innerHTML = '<div class="el-idle">No voices found — try a different search, language or filter.</div>'; }
else {
grid.innerHTML = items.map(card).join('');
grid.querySelectorAll('.fa-card').forEach((el, i) => {
const v = items[i];
el.querySelector('.fa-play')?.addEventListener('click', e => { e.stopPropagation(); preview(v, el); });
el.querySelector('.fa-import')?.addEventListener('click', e => { e.stopPropagation(); importVoice(v, el); });
});
}
const pager = $('fa-pager');
if (pager) {
pager.hidden = false;
$('fa-prev').disabled = _page <= 1;
const pages = Math.max(1, Math.ceil((data.total || 0) / (data.page_size || 24)));
$('fa-next').disabled = _page >= pages;
$('fa-pager-info').textContent = `Page ${_page} · ${(data.total || 0).toLocaleString()} voices`;
}
}
async function browse() {
status('Loading voices from fish.audio…');
grid.innerHTML = '<div class="el-idle"><span class="reh-imsdb-spinner"></span> Loading…</div>';
try {
const d = await fetch('/api/fishaudio/voices?' + params().toString()).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
render(d);
status(d.offline
? '⚠ fish.audio unreachable — showing cached results'
: `${(d.total || 0).toLocaleString()} matching voices`, !!d.offline);
} catch (e) {
grid.innerHTML = `<div class="el-idle">Failed to load: ${esc(e.message)}</div>`;
status('');
}
}
function stopPreview() {
if (_audio) { _audio.pause(); _audio = null; }
_playingCard?.classList.remove('fa-card-playing');
_playingCard = null;
}
function preview(v, el) {
if (_playingCard === el) { stopPreview(); return; }
stopPreview();
if (!v.sample_audio) return;
_audio = new Audio(v.sample_audio);
_playingCard = el; el.classList.add('fa-card-playing');
_audio.addEventListener('ended', stopPreview);
_audio.play().catch(() => { stopPreview(); typeof toast === 'function' && toast('Could not play preview', 'error'); });
}
async function importVoice(v, el) {
const btn = el.querySelector('.fa-import');
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
const lang = (v.language || 'EN').slice(0, 2).toUpperCase();
const base = (v.title || 'fishaudio').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice';
const voiceId = `${lang}_${base}`;
try {
const r = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: voiceId, audio_url: v.sample_audio, transcript: v.sample_text || v.default_text || '' }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
if (typeof saveMeta === 'function') {
await saveMeta(d.voice_id, {
name: v.title || base, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned',
gender: (v.gender || '').charAt(0).toUpperCase(), note: (v.description || '').slice(0, 180),
}).catch(() => {});
}
// Import the fish.audio cover image as the voice's profile picture
if (v.image) {
await fetch('/api/voice/picture-url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: d.voice_id, image_url: v.image }),
}).catch(() => {});
}
btn.innerHTML = '<span class="mdi mdi-check"></span> Imported';
el.classList.add('fa-card-imported');
if (typeof toast === 'function') toast(`Imported "${v.title}" as ${d.voice_id}`, 'success');
if (typeof loadVoiceLibrary === 'function') loadVoiceLibrary().catch(() => {});
} catch (e) {
btn.disabled = false; btn.innerHTML = orig;
if (typeof toast === 'function') toast('Import failed: ' + e.message, 'error');
}
}
// ── Wiring ──────────────────────────────────────────────────────────────
$('fa-fetch')?.addEventListener('click', () => { _page = 1; browse(); });
$('fa-search')?.addEventListener('keydown', e => { if (e.key === 'Enter') { _page = 1; browse(); } });
$('fa-tag')?.addEventListener('keydown', e => { if (e.key === 'Enter') { _page = 1; browse(); applyFilters(); } });
$('fa-lang')?.addEventListener('change', () => { _page = 1; browse(); });
$('fa-sort')?.addEventListener('change', () => { _page = 1; browse(); });
$('fa-prev')?.addEventListener('click', () => { if (_page > 1) { _page--; browse(); } });
$('fa-next')?.addEventListener('click', () => { _page++; browse(); });
// ── Filter popover ──────────────────────────────────────────────────────
const pop = $('fa-filter-pop');
// The popover lives inside .el-browser (overflow:hidden), so anchor it as a
// fixed-position layer next to the filter button to avoid being clipped.
function positionPop() {
const btn = $('fa-filter-btn');
if (!pop || !btn || pop.hidden) return;
const r = btn.getBoundingClientRect();
const w = pop.offsetWidth || 320;
const margin = 8;
let left = Math.min(r.right - w, window.innerWidth - w - margin);
if (left < margin) left = margin;
pop.style.position = 'fixed';
pop.style.right = 'auto';
pop.style.left = left + 'px';
pop.style.top = (r.bottom + 8) + 'px';
pop.style.maxHeight = (window.innerHeight - r.bottom - 16) + 'px';
}
function openPop(show) {
if (!pop) return;
pop.hidden = (show === undefined) ? !pop.hidden : !show;
if (!pop.hidden) positionPop();
}
$('fa-filter-btn')?.addEventListener('click', e => { e.stopPropagation(); openPop(); });
$('fa-filter-close')?.addEventListener('click', e => { e.stopPropagation(); openPop(false); });
pop?.addEventListener('click', e => e.stopPropagation());
document.addEventListener('click', () => openPop(false));
document.addEventListener('keydown', e => { if (e.key === 'Escape') openPop(false); });
window.addEventListener('resize', positionPop);
window.addEventListener('scroll', positionPop, true);
// Chip selection: single-select for gender/age, multi-select for tag groups
document.querySelectorAll('#fa-filter-pop .fa-pchip').forEach(chip => {
chip.addEventListener('click', () => {
const wrap = chip.closest('.fa-pchips');
if (wrap.classList.contains('fa-pchips-multi')) chip.classList.toggle('active');
else { wrap.querySelectorAll('.fa-pchip').forEach(c => c.classList.remove('active')); chip.classList.add('active'); }
updateFilterBadge();
});
});
$('fa-tag')?.addEventListener('input', updateFilterBadge);
function applyFilters() { _page = 1; openPop(false); browse(); }
$('fa-filter-apply')?.addEventListener('click', applyFilters);
$('fa-filter-reset')?.addEventListener('click', () => {
document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c => c.classList.remove('active'));
['gender', 'age'].forEach(g => {
const wrap = document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);
wrap?.querySelectorAll('.fa-pchip').forEach((c, i) => c.classList.toggle('active', i === 0));
});
if ($('fa-tag')) $('fa-tag').value = '';
updateFilterBadge(); applyFilters();
});
})();
// ── Get Voices Online — source tabs (show one library at a time) ────────────
(function () {
const tabs = document.getElementById('gvo-tabs');
if (!tabs) return;
const map = { direct: 'tab-getvoices', fish: 'fa-browser-card', eleven: 'el-browser-card' };
function show(src) {
if (!map[src]) src = 'direct';
Object.entries(map).forEach(([k, id]) => {
const el = document.getElementById(id);
if (!el) return;
// tab-getvoices is a .tab-content with `display:flex !important`, so a plain
// inline display:none won't hide it — set/clear the property with priority.
if (k === src) el.style.removeProperty('display');
else el.style.setProperty('display', 'none', 'important');
});
tabs.querySelectorAll('.gvo-tab').forEach(t => t.classList.toggle('active', t.dataset.src === src));
try { localStorage.setItem('gvo-src', src); } catch (_) {}
}
tabs.querySelectorAll('.gvo-tab').forEach(t => t.addEventListener('click', () => show(t.dataset.src)));
let saved = 'direct';
try { saved = localStorage.getItem('gvo-src') || 'direct'; } catch (_) {}
show(saved);
})();

View File

@ -98,6 +98,12 @@ function historyPush(voice, text, backend, blob, url) {
renderHistory(); renderHistory();
} }
function _histAvatarColor(id) {
const palette = ['#3b82f6','#10b981','#8b5cf6','#f59e0b','#ef4444','#ec4899','#06b6d4','#84cc16'];
let h = 0; for (let i = 0; i < (id||'').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function renderHistory() { function renderHistory() {
const list = $('history-list'); const list = $('history-list');
if (!list) return; if (!list) return;
@ -109,11 +115,17 @@ function renderHistory() {
const t = new Date(item.ts); const t = new Date(item.ts);
const ts = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0'); const ts = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0');
const preview = escHtml(item.text.length > 90 ? item.text.slice(0,90) + '…' : item.text); const preview = escHtml(item.text.length > 90 ? item.text.slice(0,90) + '…' : item.text);
const v = (window._voices || []).find(vx => vx.id === item.voice);
const avatar = v?.has_picture
? `<img src="/api/voice/picture/${encodeURIComponent(item.voice)}" class="hist-avatar" alt="">`
: `<span class="hist-avatar hist-avatar-init" style="background:${_histAvatarColor(item.voice)}">${(item.voice||'?')[0].toUpperCase()}</span>`;
return `<div class="history-item" data-hid="${escHtml(item.id)}"> return `<div class="history-item" data-hid="${escHtml(item.id)}">
<div class="history-item-row1"> <div class="history-item-row1">
${avatar}
<span class="history-voice">${escHtml(item.voice)}</span> <span class="history-voice">${escHtml(item.voice)}</span>
<span class="history-backend">${escHtml(item.backend)}</span> <span class="history-backend">${escHtml(item.backend)}</span>
<span class="history-time">${ts}</span> <span class="history-time">${ts}</span>
<button class="hist-del-btn" title="Delete"><span class="mdi mdi-close"></span></button>
</div> </div>
<div class="history-item-text">${preview}</div> <div class="history-item-text">${preview}</div>
<div class="history-item-actions"> <div class="history-item-actions">
@ -124,6 +136,14 @@ function renderHistory() {
</div>`; </div>`;
}).join(''); }).join('');
list.querySelectorAll('.hist-del-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const hid = btn.closest('[data-hid]')?.dataset.hid;
const idx = _genHistory.findIndex(h => h.id === hid);
if (idx !== -1) { _genHistory.splice(idx, 1); renderHistory(); }
});
});
list.querySelectorAll('.hist-play-btn').forEach(btn => { list.querySelectorAll('.hist-play-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid); const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid);
@ -339,7 +359,7 @@ let _refineOriginal = null;
// loadSettings() will overwrite with the server value; localStorage is the fast initial fallback // loadSettings() will overwrite with the server value; localStorage is the fast initial fallback
const saved = (_appSettings && _appSettings.refine_llm_url) || localStorage.getItem('refine-llm-url'); const saved = (_appSettings && _appSettings.refine_llm_url) || localStorage.getItem('refine-llm-url');
if (saved) inp.value = saved; if (saved) inp.value = saved;
else inp.value = (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls['ollama']) || localStorage.getItem('llm-local-url-ollama') || 'http://localhost:11434/v1'; else inp.value = (_appSettings && _appSettings.llm_url) || (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls['ollama']) || localStorage.getItem('llm-local-url-ollama') || 'http://localhost:11434/v1';
inp.addEventListener('input', () => { inp.addEventListener('input', () => {
localStorage.setItem('refine-llm-url', inp.value); localStorage.setItem('refine-llm-url', inp.value);
_patchSettings({ refine_llm_url: inp.value }); _patchSettings({ refine_llm_url: inp.value });
@ -362,8 +382,8 @@ $('refine-btn')?.addEventListener('click', async () => {
const r = await fetch('/api/refine-text', { method:'POST', headers:{'Content-Type':'application/json'}, const r = await fetch('/api/refine-text', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ body: JSON.stringify({
text, text,
llm_url: $('refine-llm-url')?.value?.trim() || 'http://localhost:11434/v1', llm_url: $('refine-llm-url')?.value?.trim() || _appSettings?.llm_url || 'http://localhost:11434/v1',
model: $('refine-model')?.value?.trim() || '', model: $('refine-model')?.value?.trim() || _appSettings?.refine_model || _appSettings?.llm_model || '',
toggles: { toggles: {
fillers: $('refine-fillers')?.checked ?? true, fillers: $('refine-fillers')?.checked ?? true,
repetitions: $('refine-repetitions')?.checked ?? true, repetitions: $('refine-repetitions')?.checked ?? true,

126
static/js/i18n.js Normal file
View File

@ -0,0 +1,126 @@
// ── Lightweight i18n ─────────────────────────────────────────────────────────
// English is the source language (keys = the exact English UI text). Switching
// language persists the choice and reloads, so translations are applied once to the
// freshly-rendered English DOM (no fragile reverse-translation / re-render tracking).
//
// Coverage v1: the static UI chrome (nav, section titles/subtitles, common
// buttons/labels). Add more by adding "English": "translation" pairs below, or call
// window.t('English') for JS-generated strings. Dynamic lists can be translated by
// calling window.applyI18n(container) after rendering.
const I18N_LANGS = { en: 'English', de: 'Deutsch' };
const I18N_DICT = {
de: {
// Brand / sidebar groups
'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Klonen · Entwerfen · Bereitstellen',
'Voices': 'Stimmen', 'Setup': 'Einrichtung', 'Tags': 'Tags',
// Sidebar nav
'My Voices': 'Meine Stimmen', 'All voices': 'Alle Stimmen', 'Cloned': 'Geklont',
'Designed': 'Entworfen', 'Favorites': 'Favoriten', 'Hidden': 'Ausgeblendet',
'Library tools': 'Bibliothekswerkzeuge',
'Clone a Voice': 'Stimme klonen', 'Design a Voice': 'Stimme entwerfen',
'Get Voices Online': 'Stimmen online holen', 'Try It Out': 'Ausprobieren',
'Script Rehearser': 'Skript-Probe', 'Library': 'Bibliothek', 'Cast': 'Besetzung',
'Stage': 'Bühne', 'Summary': 'Zusammenfassung', 'Import / Export': 'Import / Export',
'Conversation': 'Unterhaltung', 'Benchmark': 'Benchmark', 'Engines': 'Engines',
'Language Models': 'Sprachmodelle', 'Speech to Text': 'Sprache-zu-Text',
'Text to Speech': 'Text-zu-Sprache', 'App Routing': 'App-Routing',
'Connect Apps': 'Apps verbinden', 'Settings': 'Einstellungen',
// Section titles
'Conversation Playground': 'Unterhaltungs-Spielplatz',
// Section subtitles
'Pick a voice on the left, edit on the right.': 'Wähle links eine Stimme, bearbeite sie rechts.',
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
'Nimm 3–20 Sekunden saubere Sprache auf, schneide sie zu, benenne sie und speichere sie als wiederverwendbaren Stimmklon.',
'Describe a voice in words and let the AI create it. No recording needed.':
'Beschreibe eine Stimme in Worten und lass sie von der KI erstellen. Keine Aufnahme nötig.',
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
'Durchsuche öffentliche Stimm-Quellen, höre Audiodateien vorab an und importiere Stimmen aus dem Web.',
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
'Erzeuge Sprache aus Text mit beliebigem Backend und Stimme. Transkribiere Audio und sprich es neu ein.',
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
'Lade ein Skript hoch, besetze Figuren mit TTS-Stimmen oder deinem Mikrofon und probe Szene für Szene.',
// Common buttons / labels
'Save changes': 'Änderungen speichern', 'Save to library': 'In Bibliothek speichern',
'Save to Voice Library': 'In Stimmbibliothek speichern', 'Delete voice': 'Stimme löschen',
'Cancel': 'Abbrechen', 'Refresh': 'Aktualisieren', 'Delete': 'Löschen', 'Save': 'Speichern',
'Back': 'Zurück', 'Apply →': 'Anwenden →', 'Browse': 'Durchsuchen', 'Download': 'Herunterladen',
'Record': 'Aufnehmen', 'Stop': 'Stopp', 'Play': 'Abspielen', 'Play selection': 'Auswahl abspielen',
'Check level': 'Pegel prüfen', 'Stop monitor': 'Monitor stoppen', 'Auto trim': 'Auto-Zuschnitt',
'Auto-transcribe': 'Auto-Transkription', 'Active': 'Aktiv', 'copy ID': 'ID kopieren',
'edit ID': 'ID bearbeiten', 'New voice': 'Neue Stimme', 'All languages': 'Alle Sprachen',
'All genders': 'Alle Geschlechter', 'Microphone': 'Mikrofon', 'Upload file': 'Datei hochladen',
'Sort': 'Sortieren', 'Cards': 'Karten', 'List': 'Liste', 'Develop': 'Ausarbeiten',
'Match local': 'Lokal zuordnen', 'Match online': 'Online zuordnen', 'Design all': 'Alle entwerfen',
'I play this': 'Ich spiele das', 'Save to library': 'In Bibliothek speichern',
'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen',
'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript',
'Preview': 'Vorschau', 'Trim your sample': 'Probe zuschneiden', 'Language': 'Sprache',
'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Tags': 'Tags', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
// Common placeholders
'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...',
'Filter name or tag…': 'Name oder Tag filtern…',
},
};
let _appLang = 'en';
try { _appLang = localStorage.getItem('app-lang') || 'en'; } catch (_) {}
window.__APP_LANG__ = _appLang;
// Translate a single source string (for JS-generated UI).
window.t = function (s) {
if (_appLang === 'en' || s == null) return s;
const d = I18N_DICT[_appLang];
return (d && d[String(s).trim()] != null) ? d[String(s).trim()] : s;
};
const _I18N_SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'CODE', 'PRE', 'OPTION']);
// Translate visible text nodes + common attributes within `root` (default: whole doc).
window.applyI18n = function (root) {
if (_appLang === 'en') return;
const dict = I18N_DICT[_appLang]; if (!dict) return;
root = root || document.body; if (!root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
if (!n.nodeValue || !n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
const p = n.parentNode;
if (p && _I18N_SKIP.has(p.nodeName)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
for (const n of nodes) {
const key = n.nodeValue.trim();
if (dict[key] != null) n.nodeValue = n.nodeValue.replace(key, dict[key]);
}
root.querySelectorAll('[placeholder],[title],[aria-label]').forEach(el => {
['placeholder', 'title', 'aria-label'].forEach(a => {
const v = el.getAttribute(a);
if (v && dict[v.trim()] != null) el.setAttribute(a, dict[v.trim()]);
});
});
};
// Language change → persist + reload (translations re-apply to fresh English DOM).
window.setAppLang = function (lang) {
if (!I18N_LANGS[lang]) return;
try { localStorage.setItem('app-lang', lang); } catch (_) {}
location.reload();
};
// Inject the picker into the sidebar brand once the DOM is ready.
window.initLangPicker = function () {
const brand = document.querySelector('.sidebar-brand');
if (!brand || document.getElementById('app-lang-picker')) return;
const sel = document.createElement('select');
sel.id = 'app-lang-picker';
sel.className = 'app-lang-picker';
sel.setAttribute('aria-label', 'Interface language');
sel.innerHTML = Object.entries(I18N_LANGS)
.map(([code, name]) => `<option value="${code}"${code === _appLang ? ' selected' : ''}>${name}</option>`).join('');
sel.addEventListener('change', () => window.setAppLang(sel.value));
brand.appendChild(sel);
};

View File

@ -0,0 +1,172 @@
// ── Script parser (extracted from rehearser.js — global scope, load before it) ──
// Pure functions: parse screenplay/theatre text into typed lines, and derive the
// initial cast. No load-time dependencies (SPEAKER_COLORS is read only at call time).
function parseScript(text) {
const rawLines = text.split('\n');
const result = [];
let state = 'action', currentSpeaker = null, dialogBuffer = [], actionBuffer = [];
// Screenplay title page: drop everything before the first FADE IN / scene heading
// so title-page ALL-CAPS lines (movie title, revision marks, studio info) aren't
// read as characters. Only kicks in when the script actually has screenplay markers
// (leaves theatre / "NAME:" scripts parsed from the top).
const FADE_IN_RE = /^FADE\s+IN[\s:.\-]*$/i;
const FIRST_SCENE_RE = /^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?(INT\.|EXT\.|INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.)/i;
let scanLines = rawLines;
const firstIdx = rawLines.findIndex(l => { const s = l.trim(); return FADE_IN_RE.test(s) || FIRST_SCENE_RE.test(s); });
if (firstIdx > 0) scanLines = rawLines.slice(firstIdx);
function flushDialog() {
if (currentSpeaker && dialogBuffer.length) {
const t = dialogBuffer.join(' ').trim();
if (t) result.push({ type:'dialog', speaker:currentSpeaker, text:t, isDirection:false, emotion:'' });
}
dialogBuffer = [];
}
function flushAction() {
if (actionBuffer.length) {
const t = actionBuffer.join(' ').trim();
if (t) result.push({ type:'action', speaker:'', text:t, isDirection:true });
actionBuffer = [];
}
}
for (const rawLine of scanLines) {
const line = rawLine.trim();
if (!line) {
flushDialog(); flushAction();
if (state === 'dialog') { state = 'action'; currentSpeaker = null; }
continue;
}
// Screenplay noise — never characters or dialogue: dated page slugs/headers/footers
// and revision marks, lone scene numbers, CONTINUED and OMITTED markers.
if (
(/\d{1,2}\/\d{1,2}\/\d{2,4}/.test(line) && line.length <= 60) ||
/^[A-Z]{0,3}\d{1,4}[A-Z]?$/.test(line) ||
/^\(?CONTINUED\)?:?$/i.test(line) ||
/^[A-Z]{0,3}\d{1,4}[A-Z]?\s+CONTINUED:?(\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line) ||
/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?OMITTED(?:\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)
) {
flushDialog(); flushAction();
if (state === 'dialog') { state = 'action'; currentSpeaker = null; }
continue;
}
// PDF page-break marker — preserved from importPDFScript
if (line === '\f') {
flushDialog(); flushAction();
result.push({ type: 'pagebreak', speaker: '', text: '', isDirection: true });
state = 'action'; currentSpeaker = null;
continue;
}
if (line.startsWith('#')) {
flushDialog(); flushAction();
const dir = line.slice(1).trim();
if (dir) result.push({ type:'direction', speaker:'', text:dir, isDirection:true });
state = 'action'; currentSpeaker = null;
continue;
}
// [Bracket] theatre stage direction
if (/^\[.*\]$/.test(line)) {
if (state === 'dialog') { flushDialog(); state = 'character'; }
result.push({ type:'direction', speaker:currentSpeaker||'', text:line.slice(1,-1), isDirection:true });
continue;
}
// Screenplay scene heading — tolerates flanking scene numbers ("A1 EXT. … EVENINGA1")
{
const m = line.match(/^(?:([A-Z]{0,3}\d{1,4}[A-Z]?)\s+)?((?:INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.|INT\.|EXT\.).*)$/i);
if (m) {
flushDialog(); flushAction();
let head = m[2].trim();
// Trailing scene number is usually the leading one repeated (e.g. "…EVENINGA1")
if (m[1] && head.toUpperCase().endsWith(m[1].toUpperCase())) {
head = head.slice(0, head.length - m[1].length).trim();
}
result.push({ type:'scene', speaker:'', text:head.toUpperCase(), isDirection:true });
state = 'action'; currentSpeaker = null;
continue;
}
}
// Theatre ACT heading
if (/^ACT\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)) {
flushDialog(); flushAction();
result.push({ type:'act', speaker:'', text:line.toUpperCase(), isDirection:true });
state = 'action'; currentSpeaker = null;
continue;
}
// Theatre SCENE heading
if (/^SCENE\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)) {
flushDialog(); flushAction();
result.push({ type:'scene', speaker:'', text:line.toUpperCase(), isDirection:true });
state = 'action'; currentSpeaker = null;
continue;
}
// Cinematic / theatre transitions
if (/^(FADE\s+(IN|OUT|TO)|CUT\s+TO|SMASH\s+CUT|MATCH\s+CUT|DISSOLVE\s+TO|BLACKOUT|LIGHTS\s+(UP|DOWN|FADE)|CURTAIN|INTERMISSION|END\s+OF\s+(PLAY|ACT))[.:]?\s*$/i.test(line)) {
flushDialog(); flushAction();
result.push({ type:'transition', speaker:'', text:line, isDirection:true });
state = 'action'; currentSpeaker = null;
continue;
}
// "CHAR: dialog" simple format — speaker must be a plausible cue (≤3 words, ≤24 chars)
// so title lines / page slugs ("PIRATES OF THE CARIBBEAN: …", "POTC: …") aren't cues
const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/);
if (colonMatch && colonMatch[1].trim().length <= 24 && colonMatch[1].trim().split(/\s+/).length <= 3) {
flushDialog(); flushAction();
currentSpeaker = colonMatch[1].trim();
dialogBuffer = [colonMatch[2].trim()];
state = 'dialog';
continue;
}
// Parenthetical: (text) alone on a line
if (/^\(.*\)$/.test(line)) {
if (state === 'dialog') { flushDialog(); state = 'character'; }
result.push({ type:'direction', speaker:currentSpeaker||'', text:line, isDirection:true });
continue;
}
// Character name: ALL-CAPS, optionally followed by (modifier)
const nameRaw = line.replace(/\s*\([^)]*\)\s*$/, '').trim();
if (
nameRaw.length >= 2 && nameRaw.length <= 42 &&
nameRaw === nameRaw.toUpperCase() &&
/^[A-Z][A-Z0-9 '.\-]+$/.test(nameRaw) &&
!/^\d+$/.test(nameRaw) &&
!/\.$/.test(nameRaw) // reject sentence fragments ("FERDINAND.", "EYES OPEN.")
) {
flushDialog(); flushAction();
currentSpeaker = nameRaw;
state = 'character';
continue;
}
if (state === 'character') { dialogBuffer = [line]; state = 'dialog'; continue; }
if (state === 'dialog') { dialogBuffer.push(line); continue; }
actionBuffer.push(line);
state = 'action';
}
flushDialog(); flushAction();
return result;
}
function detectCharacters(lines) {
const speakers = [...new Set(lines.filter(l => l.type === 'dialog').map(l => l.speaker))];
const cast = {};
speakers.forEach((sp, i) => {
cast[sp] = { voice: '', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length], instruct: '', voiceData: null };
});
return cast;
}

File diff suppressed because it is too large Load Diff

View File

@ -193,13 +193,13 @@ function renderRoutingList() {
$('routing-list').innerHTML = _ttsRoutes.map((r, i) => ` $('routing-list').innerHTML = _ttsRoutes.map((r, i) => `
<div class="routing-grid routing-row" data-index="${i}"> <div class="routing-grid routing-row" data-index="${i}">
<label class="toggle" title="Enable route"> <label class="toggle" title="Enable route">
<input type="checkbox" class="route-enabled" ${r.enabled !== false ? 'checked' : ''}> <input type="checkbox" class="route-enabled" aria-label="Enable route" ${r.enabled !== false ? 'checked' : ''}>
<span class="t-slider"></span> <span class="t-slider"></span>
</label> </label>
<input class="route-app" value="${escHtml(r.app || 'Open WebUI')}" placeholder="Open WebUI, SillyTavern, Home Assistant, or *" title="Matches app/client JSON, X-TTS-App header, or detected client name. Use * for any app."> <input class="route-app" aria-label="App / client" value="${escHtml(r.app || 'Open WebUI')}" placeholder="Open WebUI, SillyTavern, Home Assistant, or *" title="Matches app/client JSON, X-TTS-App header, or detected client name. Use * for any app.">
<input class="route-input" value="${escHtml(r.input_voice || 'default')}" placeholder="default"> <input class="route-input" aria-label="Input voice" value="${escHtml(r.input_voice || 'default')}" placeholder="default">
<select class="route-lang">${routeSelectOptions(String(r.language || '*').toUpperCase())}</select> <select class="route-lang" aria-label="Route language">${routeSelectOptions(String(r.language || '*').toUpperCase())}</select>
<select class="route-backend" title="Voice Clone uses the normal TTS URL, Streaming uses the streaming URL, Voice Design uses vd_ presets, NVIDIA Magpie uses fixed NVIDIA voices, NVIDIA Zeroshot/Flow use saved library WAVs as audio prompts.">${routeBackendOptions(r.backend || 'voice_clone')}</select> <select class="route-backend" aria-label="Route backend" title="Voice Clone uses the normal TTS URL, Streaming uses the streaming URL, Voice Design uses vd_ presets, NVIDIA Magpie uses fixed NVIDIA voices, NVIDIA Zeroshot/Flow use saved library WAVs as audio prompts.">${routeBackendOptions(r.backend || 'voice_clone')}</select>
<input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset"> <input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset">
<div class="route-sound-cell"> <div class="route-sound-cell">
<input class="route-before-sound" value="${escHtml(r.before_sound || '')}" placeholder="sounds/start.wav" list="routing-sound-options"> <input class="route-before-sound" value="${escHtml(r.before_sound || '')}" placeholder="sounds/start.wav" list="routing-sound-options">

View File

@ -429,7 +429,7 @@ $('voice-id-input').addEventListener('input', () => {
$('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : ''; $('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : '';
$('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : ''; $('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : '';
}); });
$('helper-apply-btn').addEventListener('click', () => { $('helper-apply-btn')?.addEventListener('click', () => {
const name = $('name-input').value.trim(); const name = $('name-input').value.trim();
if (!name) { toast('Enter a name first', 'error'); return; } if (!name) { toast('Enter a name first', 'error'); return; }
$('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`; $('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`;

View File

@ -86,9 +86,9 @@ function updatePreviewVoiceMatchPanel() {
personaBtn.disabled = true; personaBtn.disabled = true;
personaBtn.textContent = 'Rewriting…'; personaBtn.textContent = 'Rewriting…';
try { try {
const llmUrl = localStorage.getItem('refine-llm-url') || 'http://localhost:11434/v1'; const llmUrl = localStorage.getItem('refine-llm-url') || _appSettings?.llm_url || 'http://localhost:11434/v1';
const r = await fetch('/api/rewrite-with-persona', { method:'POST', headers:{'Content-Type':'application/json'}, const r = await fetch('/api/rewrite-with-persona', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ text, persona: lv.persona, llm_url: llmUrl, mode:'rewrite' }) }); body: JSON.stringify({ text, persona: lv.persona, llm_url: llmUrl, model: _appSettings?.llm_model || '', mode:'rewrite' }) });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
$('preview-text-area').value = d.text; $('preview-text-area').value = d.text;
@ -145,12 +145,16 @@ $('fetch-tts-voices-btn').addEventListener('click', async () => {
voices = voices.filter(v => activeIds.has(backendVoiceId(v))); voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
} }
const sel = $('tts-voice-select'), prev = sel.value; const sel = $('tts-voice-select'), prev = sel.value;
const ids = voices.map(backendVoiceId);
if (window.VoicePicker) {
VoicePicker.upgrade('tts-voice-select');
VoicePicker.populate('tts-voice-select', ids);
if (prev && ids.includes(prev)) VoicePicker.setValue('tts-voice-select', prev);
} else {
sel.innerHTML = '<option value="">— select after fetch —</option>'; sel.innerHTML = '<option value="">— select after fetch —</option>';
voices.forEach(v => { ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); });
const id = backendVoiceId(v); if (prev && ids.includes(prev)) sel.value = prev;
const opt = document.createElement('option'); opt.value = opt.textContent = id; sel.appendChild(opt); }
});
if(prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev;
updatePreviewVoiceMatchPanel(); updatePreviewVoiceMatchPanel();
const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices'; const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices';
toast('Fetched '+voices.length+suffix,'success'); toast('Fetched '+voices.length+suffix,'success');

View File

@ -21,6 +21,22 @@ function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
} }
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; } function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
// Log a non-fatal error instead of silently swallowing it (was `.catch(()=>{})`).
// Use in best-effort paths so failures are visible in the console for debugging.
function logErr(context, err) { try { console.warn(`[${context}]`, err && (err.message || err)); } catch (_) {} }
// Run an async worker over items with bounded concurrency (default 4). `onProgress(done,total)`
// fires after each item. Replaces slow serial `for…await` loops over network calls.
async function runPool(items, worker, concurrency = 4, onProgress) {
const arr = [...items]; let i = 0, done = 0; const total = arr.length;
async function run() {
while (i < arr.length) {
const idx = i++;
try { await worker(arr[idx], idx); } catch (e) { logErr('runPool', e); }
done++; if (onProgress) { try { onProgress(done, total); } catch (_) {} }
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, arr.length) }, run));
}
async function copyText(text) { async function copyText(text) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@ -62,6 +78,7 @@ async function clientAutoTrimBounds(fid) {
const audioData = await resp.arrayBuffer(); const audioData = await resp.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)(); const ctx = new (window.AudioContext || window.webkitAudioContext)();
const buffer = await ctx.decodeAudioData(audioData.slice(0)); const buffer = await ctx.decodeAudioData(audioData.slice(0));
try { ctx.close(); } catch (_) {} // iOS Safari hard-limits live AudioContexts — free it now
const samples = buffer.getChannelData(0); const samples = buffer.getChannelData(0);
const sr = buffer.sampleRate; const sr = buffer.sampleRate;
const dur = buffer.duration; const dur = buffer.duration;

View File

@ -11,15 +11,28 @@ const CLONE_SAMPLE_TEXTS = {
PL: 'Cześć! Mam na imię Anna i to jest mój głos. Mogę mówić cicho lub z całą mocą. Mroźne zimowe powietrze, ciepłe światło ognia i dźwięk deszczu za oknem — to są rzeczy, które kocham. Czy słyszysz, jak wyraźnie mówię?', PL: 'Cześć! Mam na imię Anna i to jest mój głos. Mogę mówić cicho lub z całą mocą. Mroźne zimowe powietrze, ciepłe światło ognia i dźwięk deszczu za oknem — to są rzeczy, które kocham. Czy słyszysz, jak wyraźnie mówię?',
}; };
// Placeholder name baked into each template — replaced with the user's voice name.
const CLONE_SAMPLE_NAMES = { EN: 'Sam', DE: 'Alex', IT: 'Marco', ES: 'Carlos', FR: 'Sophie', PT: 'Ana', NL: 'Laura', PL: 'Anna' };
// Build the sample sentence for a language, injecting the current voice name (if any)
// in place of that language's default name. Tracks the injected name on the textarea
// so a later language switch keeps the user's name instead of reverting to "Alex".
function cloneSampleForLang(lang) {
const txt = $('clone-sample-text');
const def = CLONE_SAMPLE_NAMES[lang] || '';
let t = CLONE_SAMPLE_TEXTS[lang] || CLONE_SAMPLE_TEXTS.EN;
const name = ($('clone-your-name')?.value || '').trim();
if (name && def) t = t.replace(new RegExp('\\b' + def.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b'), name);
if (txt) { txt.value = t; txt.dataset.sampleName = name || def; }
}
window.initCloneSampleText = function () { window.initCloneSampleText = function () {
const sel = $('clone-sample-lang'); const sel = $('clone-sample-lang');
const txt = $('clone-sample-text'); const txt = $('clone-sample-text');
if (!sel || !txt) return; if (!sel || !txt) return;
txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN; cloneSampleForLang(sel.value);
if (!sel._cloneSampleBound) { if (!sel._cloneSampleBound) {
sel.addEventListener('change', () => { sel.addEventListener('change', () => cloneSampleForLang(sel.value));
txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN;
});
sel._cloneSampleBound = true; sel._cloneSampleBound = true;
} }
}; };
@ -359,6 +372,9 @@ $('trim-btn').addEventListener('click', async () => {
const d = await r.json(); trimmedFileId=d.id; designedFileId=null; const d = await r.json(); trimmedFileId=d.id; designedFileId=null;
$('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none'; $('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none';
switchTab('save'); toast('Trim done','success'); switchTab('save'); toast('Trim done','success');
// Bring the transcript section into view so the auto-transcribe is visibly happening
$('transcript-area')?.closest('.card')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window._cloneAutoTranscribe?.(); // streamlined flow: transcribe as soon as the clip is trimmed
} catch(e) { toast('Trim failed: '+e.message,'error'); } } catch(e) { toast('Trim failed: '+e.message,'error'); }
}); });
@ -909,14 +925,116 @@ $('clone-refresh-stt-btn')?.addEventListener('click', async () => {
$('transcribe-btn').addEventListener('click', async () => { $('transcribe-btn').addEventListener('click', async () => {
const id = trimmedFileId||designedFileId||currentFileId; const id = trimmedFileId||designedFileId||currentFileId;
if (!id) { toast('No audio to transcribe','error'); return; } if (!id) { toast('No audio to transcribe','error'); return; }
$('transcribe-btn').disabled=true; $('transcribe-status').textContent='Transcribing…'; const btn = $('transcribe-btn'), status = $('transcribe-status'), area = $('transcript-area');
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Transcribing…';
if (status) { status.className = 'clone-tr-status working'; status.innerHTML = '<span class="reh-imsdb-spinner"></span> Listening to your recording…'; }
if (area) { area.classList.add('transcribing'); area.placeholder = 'Transcribing your audio — please wait…'; }
try { try {
const backend = $('clone-stt-backend')?.value || 'configured'; const backend = $('clone-stt-backend')?.value || 'configured';
const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id, backend})}); const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id, backend})});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const d = await r.json(); $('transcript-area').value=d.text; $('transcribe-status').textContent='Done'; const d = await r.json();
if (area) area.value = d.text;
if (status) { status.className = 'clone-tr-status done'; status.innerHTML = '<span class="mdi mdi-check-circle"></span> Transcribed'; }
toast('Transcription complete','success'); toast('Transcription complete','success');
} catch(e) { $('transcribe-status').textContent='Failed: '+e.message; toast('Transcription failed: '+e.message,'error'); } window._cloneScheduleAutoSave?.(); // streamlined flow: save once transcript is in
finally { $('transcribe-btn').disabled=false; } } catch(e) {
if (status) { status.className = 'clone-tr-status error'; status.innerHTML = `<span class="mdi mdi-alert-circle"></span> Failed: ${escHtml(e.message || String(e))}`; }
toast('Transcription failed: '+e.message,'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
if (area) { area.classList.remove('transcribing'); area.placeholder = 'Type or auto-transcribe the spoken text…'; }
}
}); });
// ── Streamlined clone flow: name-first + auto transcribe / ID / save ─────────
(function () {
const g = id => document.getElementById(id);
let _idManual = false; // user hand-edited the final ID → stop auto-overwriting it
let _prevName = 'Sam'; // last name injected into the read-aloud sentence
let _autoSaveTimer = null;
let _lastAutoSaved = ''; // signature of last auto-save, avoids duplicate writes
function buildVoiceId() {
if (_idManual) { scheduleAutoSave(); return; }
const lang = g('lang-select')?.value || 'EN';
const gender = g('gender-select')?.value || 'N';
const name = (g('name-input')?.value || '').trim().replace(/\s+/g, '');
const vid = g('voice-id-input');
if (vid && name) { vid.value = `${lang}_${gender}_${name}`; vid.dispatchEvent(new Event('input')); }
scheduleAutoSave();
}
// ① Name-first field in the Read-aloud box
const nameField = g('clone-your-name');
nameField?.addEventListener('input', () => {
const name = nameField.value.trim();
if (!name) return;
const sample = g('clone-sample-text');
if (sample) {
// Replace whatever name is currently in the sentence — tracked across language
// switches via dataset.sampleName, falling back to the last-injected name.
const prev = sample.dataset.sampleName || _prevName;
const re = new RegExp('\\b' + prev.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
if (re.test(sample.value)) sample.value = sample.value.replace(re, name);
sample.dataset.sampleName = name;
}
_prevName = name;
const ni = g('name-input'); if (ni) ni.value = name.replace(/\s+/g, '');
buildVoiceId();
});
// Live ID building from lang / gender / name — no "Apply" click needed
['lang-select', 'gender-select'].forEach(id => g(id)?.addEventListener('change', buildVoiceId));
g('name-input')?.addEventListener('input', buildVoiceId);
// Respect a manually edited final ID (only real keystrokes set the manual flag)
g('voice-id-input')?.addEventListener('input', e => { if (e.isTrusted) _idManual = true; scheduleAutoSave(); });
g('transcript-area')?.addEventListener('input', scheduleAutoSave);
// Auto-transcribe once the clip is trimmed (kept if the user already typed a transcript)
window._cloneAutoTranscribe = function () {
const ta = g('transcript-area');
if (ta && ta.value.trim()) { scheduleAutoSave(); return; }
g('transcribe-btn')?.click();
};
function canAutoSave() {
const vid = (g('voice-id-input')?.value || '').trim();
const tr = (g('transcript-area')?.value || '').trim();
const hasAudio = (typeof trimmedFileId !== 'undefined' && trimmedFileId) ||
(typeof designedFileId !== 'undefined' && designedFileId);
return !!(hasAudio && vid && tr && (typeof validateVoiceId !== 'function' || validateVoiceId(vid)));
}
function scheduleAutoSave() {
const toggle = g('clone-autosave-toggle');
if (!toggle || !toggle.checked) return;
clearTimeout(_autoSaveTimer);
_autoSaveTimer = setTimeout(() => {
if (!canAutoSave()) return;
const sig = (g('voice-id-input').value + '|' + g('transcript-area').value).trim();
if (sig === _lastAutoSaved) return;
_lastAutoSaved = sig;
g('save-btn')?.click();
}, 1600);
}
window._cloneScheduleAutoSave = scheduleAutoSave;
})();
// ── Clone source picker — File / URL / Microphone (one at a time) ────────────
(function () {
const picker = document.getElementById('clone-src-picker');
if (!picker) return;
const cards = [...document.querySelectorAll('.clone-src-card')];
const tabs = [...picker.querySelectorAll('.clone-src-tab')];
const KEY = 'clone-src-choice';
function show(src) {
cards.forEach(c => { c.hidden = c.dataset.src !== src; });
tabs.forEach(t => t.classList.toggle('active', t.dataset.src === src));
try { localStorage.setItem(KEY, src); } catch (_) {}
}
tabs.forEach(t => t.addEventListener('click', () => show(t.dataset.src)));
show(localStorage.getItem(KEY) || 'mic');
})();

View File

@ -47,17 +47,22 @@ function selectVoice(wrap) {
const rating = v.rating || 0; const rating = v.rating || 0;
const nameParts = voiceId.split('_'); const nameParts = voiceId.split('_');
const dispName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId; // Prefer an explicit display name (e.g. the character name for Rehearser voices);
// otherwise fall back to the last segment of the ID (EN_F_Anna → "Anna").
const dispName = (v.name && String(v.name).trim())
? String(v.name).trim()
: (nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId);
const initial = (dispName[0] || voiceId[0] || '?').toUpperCase(); const initial = (dispName[0] || voiceId[0] || '?').toUpperCase();
const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null; const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null;
const inspFlagCc = flagCc || langCode; const inspFlagCc = flagCc || langCode;
const inspFlagIcon = inspFlagCc const inspFlagIcon = inspFlagCc
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null; ? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
const inspIconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 48) : null;
const avatarHtml = picSrcInsp const avatarHtml = picSrcInsp
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">` ? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
: inspFlagIcon || initial; : inspIconHtml || inspFlagIcon || initial;
const avatarBgStyle = (picSrcInsp || inspFlagIcon) ? '' : `style="background:${color}"`; const avatarBgStyle = (picSrcInsp || inspIconHtml || inspFlagIcon) ? '' : `style="background:${color}"`;
const flagIconHtml = inspFlagIcon || ''; const flagIconHtml = inspFlagIcon || '';
@ -90,7 +95,8 @@ function selectVoice(wrap) {
</div> </div>
<div class="insp-hd-row2"> <div class="insp-hd-row2">
<div class="insp-id-block"> <div class="insp-id-block">
<span class="insp-full-id" title="Double-click to rename">${escHtml(voiceId)}</span> <span class="insp-full-id" title="Click to rename the voice ID">${escHtml(voiceId)}</span>
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button> <button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
</div> </div>
<div class="insp-actions-active"></div> <div class="insp-actions-active"></div>
@ -185,6 +191,8 @@ function selectVoice(wrap) {
}; };
dispNameEl.addEventListener('dblclick', startInspRename); dispNameEl.addEventListener('dblclick', startInspRename);
fullIdEl.addEventListener('dblclick', startInspRename); fullIdEl.addEventListener('dblclick', startInspRename);
fullIdEl.addEventListener('click', startInspRename);
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
nameEditEl.addEventListener('blur', commitInspRename); nameEditEl.addEventListener('blur', commitInspRename);
nameEditEl.addEventListener('keydown', e => { nameEditEl.addEventListener('keydown', e => {
if (e.key === 'Enter') nameEditEl.blur(); if (e.key === 'Enter') nameEditEl.blur();
@ -202,7 +210,10 @@ function selectVoice(wrap) {
await saveMeta(voiceId, { flag: cc }); await saveMeta(voiceId, { flag: cc });
}; };
flagSpan.addEventListener('click', () => { flagSpan.addEventListener('click', () => {
const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]); // Show this language's accents first, then the complete world list — accent is
// decoupled from language, so e.g. an English voice can still be set to German.
const langList = FLAG_OPTIONS[langCode.toUpperCase()] || [];
const items = uniqueFlagOptions([langList, ALL_FLAGS]).map(([cc, name]) => [cc.toLowerCase(), name]);
createSearchablePicker(flagSpan, items, applyFlag, { createSearchablePicker(flagSpan, items, applyFlag, {
placeholder: 'Country or accent…', placeholder: 'Country or accent…',
renderItem: (cc, name) => renderItem: (cc, name) =>

View File

@ -123,7 +123,18 @@ function syncSortHeaders() {
// since the button is injected into the DOM after script execution). // since the button is injected into the DOM after script execution).
document.addEventListener('click', e => { document.addEventListener('click', e => {
if (e.target.closest('#voice-sort-dir')) toggleSortDir(); if (e.target.closest('#voice-sort-dir')) toggleSortDir();
if (e.target.closest('#voice-group-tag-btn')) {
window._voiceGroupByTag = !window._voiceGroupByTag;
try { localStorage.setItem('vl-group-by-tag', window._voiceGroupByTag ? '1' : '0'); } catch (_) {}
const btn = document.getElementById('voice-group-tag-btn');
btn?.classList.toggle('active', window._voiceGroupByTag);
const icon = btn?.querySelector('.mdi');
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
renderVoiceList();
}
}); });
// Restore the grouped-by-tag preference on load
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
document.addEventListener('change', e => { document.addEventListener('change', e => {
if (e.target.id === 'voice-sort-field') setSort(e.target.value); if (e.target.id === 'voice-sort-field') setSort(e.target.value);
}); });
@ -211,7 +222,7 @@ function libraryFilterMatch(v) {
if (wanted > 1 && r < wanted) return false; if (wanted > 1 && r < wanted) return false;
} }
if (f.text) { if (f.text) {
const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender].map(x => String(x || '').toLowerCase()).join(' '); const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin].map(x => String(x || '').toLowerCase()).join(' ');
if (!hay.includes(f.text)) return false; if (!hay.includes(f.text)) return false;
} }
return true; return true;
@ -674,10 +685,10 @@ async function runVoiceBenchmark(voiceId = '', opts = {}) {
} }
async function runVoiceBenchmarkBatch() { async function runVoiceBenchmarkBatch() {
const voices = _voices.filter(v => v.enabled !== false); const voices = benchmarkTargetVoices();
const text = benchmarkSampleText(); const text = benchmarkSampleText();
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; } if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
if (!voices.length) { toast('No active voices to benchmark', 'error'); return null; } if (!voices.length) { toast('No voices to benchmark', 'error'); return null; }
const total = voices.length; const total = voices.length;
const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true}; const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true};
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''}; const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''};
@ -727,13 +738,22 @@ function activeBenchmarkVoices() {
return (_voices || []).filter(v => v.enabled !== false); return (_voices || []).filter(v => v.enabled !== false);
} }
// Benchmark target: the checked/selected voices when any are selected, else all active.
function benchmarkTargetVoices() {
if (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0) {
return (_voices || []).filter(v => _bulkSelected.has(v.id));
}
return activeBenchmarkVoices();
}
function showBenchmarkConfirm() { function showBenchmarkConfirm() {
const voices = activeBenchmarkVoices(); const voices = benchmarkTargetVoices();
const onlySelected = (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0);
const text = benchmarkSampleText(); const text = benchmarkSampleText();
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; } if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; }
if (!voices.length) { toast('No active voices to benchmark', 'error'); return; } if (!voices.length) { toast('No voices to benchmark', 'error'); return; }
const staleCount = voices.filter(v => v.needs_tts_restart).length; const staleCount = voices.filter(v => v.needs_tts_restart).length;
$('benchmark-confirm-title').textContent = `Benchmark ${voices.length} active voices?`; $('benchmark-confirm-title').textContent = `Benchmark ${voices.length} ${onlySelected ? 'selected' : 'active'} voice${voices.length === 1 ? '' : 's'}?`;
$('benchmark-confirm-text').textContent = 'This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice.' + $('benchmark-confirm-text').textContent = 'This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice.' +
(staleCount ? ` ${staleCount} edited voice${staleCount === 1 ? '' : 's'} should be restarted first, otherwise cached old voices may be benchmarked.` : ''); (staleCount ? ` ${staleCount} edited voice${staleCount === 1 ? '' : 's'} should be restarted first, otherwise cached old voices may be benchmarked.` : '');
$('benchmark-confirm').hidden = false; $('benchmark-confirm').hidden = false;
@ -1342,25 +1362,37 @@ function renderVoiceList() {
const list = $('voice-list'); const list = $('voice-list');
list.innerHTML = ''; list.innerHTML = '';
renderVoiceGroupsBar();
populateLibraryFilters(); populateLibraryFilters();
readLibraryFilters(); readLibraryFilters();
const _gtBtn = $('voice-group-tag-btn');
if (_gtBtn) {
_gtBtn.classList.toggle('active', !!window._voiceGroupByTag);
const ic = _gtBtn.querySelector('.mdi');
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
}
// Apply sidebar category filter // Apply sidebar category filter
const cat = window._voiceSidebarCat || 'all'; const cat = window._voiceSidebarCat || 'all';
const enabledOk = v => showDisabled || v.enabled !== false;
let filtered = _voices.filter(v => { let filtered = _voices.filter(v => {
if (cat === 'cloned') return v.has_ref; // 'designed' = prompt-built voices (explicit origin marker, OR no reference WAV).
if (cat === 'designed') return !v.has_ref; // 'cloned' = real WAV-identity voices that were NOT prompt-designed.
if (cat === 'favorites') return (v.rating || 0) >= 4; if (cat === 'cloned') return enabledOk(v) && v.has_ref && v.origin !== 'designed';
if (cat === 'hidden') return v.enabled === false; if (cat === 'designed') return enabledOk(v) && (v.origin === 'designed' || !v.has_ref);
if (cat === 'tools') return false; // no rows for tools view if (cat === 'favorites') return enabledOk(v) && (v.rating || 0) >= 4;
// 'all' — respect show-disabled toggle if (cat === 'hidden') return v.enabled === false; // always show hidden voices in hidden tab
return showDisabled || v.enabled !== false; // 'tools' shows all voices (same as 'all') so bulk-edit works
return enabledOk(v);
}); });
// For hidden/favorites cats always show all regardless of show-disabled toggle // Apply active group filter (from the groups bar)
if (cat !== 'all' && cat !== 'tools') { if (window._voiceGroupFilter) {
// already filtered above — no extra enabled filter needed filtered = filtered.filter(v => (v.group || '').trim() === window._voiceGroupFilter);
} else if (cat === 'all') { }
// already applied enabled filter in the lambda above // Apply active tag "subfolder" filter (from the My Voices nav tree)
if (window._voiceTagFilter) {
filtered = filtered.filter(v => String(v.tag || '').split(',').map(t => t.trim()).includes(window._voiceTagFilter));
} }
const visibleCount = filtered.length; const visibleCount = filtered.length;
filtered = filtered.filter(libraryFilterMatch); filtered = filtered.filter(libraryFilterMatch);
@ -1511,7 +1543,8 @@ function renderVoiceList() {
} }
return; return;
} }
filtered.forEach(v => list.appendChild(makeVoiceRow(v))); if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
else filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
if (_pendingSelectId) { if (_pendingSelectId) {
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`); const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
if (wrap) { _pendingSelectId = null; selectVoice(wrap); } if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
@ -1519,6 +1552,54 @@ function renderVoiceList() {
syncSortHeaders(); syncSortHeaders();
} }
// Group the list into collapsible virtual "folders" by tag (a voice with several
// comma-separated tags appears under each; untagged voices fall into one bucket).
function renderVoicesByTag(list, filtered) {
const UNTAG = '— Untagged —';
const groups = new Map();
filtered.forEach(v => {
const tags = String(v.tag || '').split(',').map(t => t.trim()).filter(Boolean);
(tags.length ? tags : [UNTAG]).forEach(t => {
if (!groups.has(t)) groups.set(t, []);
groups.get(t).push(v);
});
});
let collapsed = {};
try { collapsed = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {}
const names = [...groups.keys()].sort((a, b) =>
a === UNTAG ? 1 : b === UNTAG ? -1 : a.toLowerCase().localeCompare(b.toLowerCase()));
names.forEach(tag => {
const voices = groups.get(tag);
const isCol = !!collapsed[tag];
const sec = document.createElement('div');
sec.className = 'vl-tag-group';
const head = document.createElement('button');
head.className = 'vl-tag-head' + (isCol ? ' collapsed' : '');
head.type = 'button';
head.innerHTML = `<span class="mdi mdi-${isCol ? 'chevron-right' : 'chevron-down'} vl-tag-caret"></span>`
+ `<span class="mdi mdi-folder${isCol ? '' : '-open'}-outline"></span>`
+ `<span class="vl-tag-name">${escHtml(tag)}</span>`
+ `<span class="vl-tag-count">${voices.length}</span>`;
const body = document.createElement('div');
body.className = 'vl-tag-body';
if (isCol) body.style.display = 'none';
voices.forEach(v => body.appendChild(makeVoiceRow(v)));
head.addEventListener('click', () => {
const nowCol = body.style.display !== 'none';
body.style.display = nowCol ? 'none' : '';
head.classList.toggle('collapsed', nowCol);
head.querySelector('.vl-tag-caret').className = 'mdi mdi-' + (nowCol ? 'chevron-right' : 'chevron-down') + ' vl-tag-caret';
head.querySelector('.mdi:nth-child(2)').className = 'mdi mdi-folder' + (nowCol ? '' : '-open') + '-outline';
let c = {}; try { c = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {}
c[tag] = nowCol; localStorage.setItem('vl-tag-collapsed', JSON.stringify(c));
});
sec.appendChild(head);
sec.appendChild(body);
list.appendChild(sec);
});
}
async function decodeVoiceAudio(v) { async function decodeVoiceAudio(v) {
const resp = await fetch(voiceFileUrl(v), {cache:'no-store'}); const resp = await fetch(voiceFileUrl(v), {cache:'no-store'});
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
@ -1605,6 +1686,11 @@ function makeVoiceRow(v) {
wrap.dataset.lang = v.lang || v.id.split('_')[0].toUpperCase(); wrap.dataset.lang = v.lang || v.id.split('_')[0].toUpperCase();
const langCode = v.lang || v.id.split('_')[0].toUpperCase(); const langCode = v.lang || v.id.split('_')[0].toUpperCase();
// Display name: explicit meta.name (e.g. Rehearser character) wins, else last ID segment.
const _idParts = String(v.id).split('_');
const dispName = (v.name && String(v.name).trim())
? String(v.name).trim()
: (_idParts.length > 1 ? _idParts[_idParts.length - 1] : v.id);
const langOpts = FLAG_OPTIONS[langCode] || []; const langOpts = FLAG_OPTIONS[langCode] || [];
// Use language-specific variants if there are multiple; fall back to world picker otherwise // Use language-specific variants if there are multiple; fall back to world picker otherwise
const flagOpts = langOpts.length > 1 ? langOpts : ALL_FLAGS; const flagOpts = langOpts.length > 1 ? langOpts : ALL_FLAGS;
@ -1640,24 +1726,27 @@ function makeVoiceRow(v) {
: `<span style="font-size:12px;color:var(--subtext);padding:3px 6px">No regional variants</span>`; : `<span style="font-size:12px;color:var(--subtext);padding:3px 6px">No regional variants</span>`;
// Compact list item elements (visible in list pane, hidden in inspector) // Compact list item elements (visible in list pane, hidden in inspector)
const isClone = v.has_ref; const isClone = v.has_ref && v.origin !== 'designed';
const initial = v.id[0] ? v.id[0].toUpperCase() : '?'; const initial = v.id[0] ? v.id[0].toUpperCase() : '?';
const flagIconHtml = currentFlag const flagIconHtml = currentFlag
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>` ? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
: null; : null;
// Avatar: photo > flag icon > initial letter (with color) // Avatar: photo > gender/type icon > flag icon > initial letter (with color)
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo' : flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial'; const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
const avatarStyle = picSrc || flagIconHtml ? '' : `style="background:${color}"`; const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
: iconHtml ? 'vl-avatar vl-avatar-icon'
: flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
const avatarStyle = (picSrc || iconHtml || flagIconHtml) ? '' : `style="background:${color}"`;
const avatarContent = picSrc const avatarContent = picSrc
? `<img src="${picSrc}" alt="" class="vl-avatar-img">` ? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
: flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`; : iconHtml || flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`;
wrap.innerHTML = ` wrap.innerHTML = `
<div class="vl-compact"> <div class="vl-compact">
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div> <div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
<div class="vl-info"> <div class="vl-info">
<span class="vl-name">${escHtml(v.id)}</span> <span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>
<div class="vl-meta"> <div class="vl-meta">
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span> <span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
${gender ? `<span class="vl-gender-label ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMap[gender]||'?'} ${genderLabel[gender]||''}</span>` : ''} ${gender ? `<span class="vl-gender-label ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMap[gender]||'?'} ${genderLabel[gender]||''}</span>` : ''}
@ -1665,14 +1754,14 @@ function makeVoiceRow(v) {
</div> </div>
</div> </div>
<div class="vr-play-group"> <div class="vr-play-group">
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording"><span class="mdi mdi-play"></span></button></div> <div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div> <div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
</div> </div>
</div> </div>
<div class="vr-main-row"> <div class="vr-main-row">
<div class="vr-photo" title="Click to upload photo"> <div class="vr-photo" title="Click to upload photo">
${picSrc ? `<img src="${picSrc}" alt="">` : '<div class="ph-icon"><span class="mdi mdi-account-outline"></span></div>'} ${picSrc ? `<img src="${picSrc}" alt="" loading="lazy" decoding="async">` : '<div class="ph-icon"><span class="mdi mdi-account-outline"></span></div>'}
<input type="file" class="photo-input" accept="image/*"> <input type="file" class="photo-input" accept="image/*">
</div> </div>
@ -2661,6 +2750,62 @@ function makeVoiceRow(v) {
return wrap; return wrap;
} }
// ── Voice groups bar (bulk edit/remove a tagged set, e.g. a rehearser play) ──
function renderVoiceGroupsBar() {
const bar = $('voice-groups-bar');
if (!bar) return;
// Tally groups across all voices
const groups = {};
(_voices || []).forEach(v => {
const g = (v.group || '').trim();
if (g) groups[g] = (groups[g] || 0) + 1;
});
const names = Object.keys(groups).sort();
if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; }
bar.hidden = false;
const active = window._voiceGroupFilter || '';
bar.innerHTML =
`<span class="vg-label"><span class="mdi mdi-folder-multiple-outline"></span> Groups</span>` +
names.map(g =>
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
${escHtml(g)} <span class="vg-count">${groups[g]}</span>
<button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">&times;</button>
</span>`
).join('') +
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
bar.querySelectorAll('.vg-chip').forEach(chip => {
chip.addEventListener('click', e => {
if (e.target.closest('.vg-del')) return;
const g = chip.dataset.group;
window._voiceGroupFilter = (window._voiceGroupFilter === g) ? '' : g;
renderVoiceList();
});
});
bar.querySelectorAll('.vg-del').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); });
});
bar.querySelector('.vg-clear')?.addEventListener('click', () => {
window._voiceGroupFilter = ''; renderVoiceList();
});
}
async function deleteVoiceGroup(group) {
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return;
try {
const r = await fetch('/api/voices/delete-group', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success');
await loadVoiceLibrary();
} catch(e) { toast('Delete failed: ' + e.message, 'error'); }
}
async function saveMeta(voiceId, patch) { async function saveMeta(voiceId, patch) {
const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'}, const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ voice_id:voiceId, ...patch }) }); body: JSON.stringify({ voice_id:voiceId, ...patch }) });
@ -2677,3 +2822,218 @@ document.addEventListener('click', () => {
document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending')); document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending'));
}, { passive:true }); }, { passive:true });
// ── Bulk-edit ────────────────────────────────────────────────────────────────
const _bulkSelected = new Set(); // voice IDs currently checked
function _bulkUpdate() {
const bar = $('vl-bulk-bar');
const count = $('vl-bulk-count');
const n = _bulkSelected.size;
if (bar) bar.hidden = n === 0;
if (count) count.textContent = `${n} selected`;
// Sync checkboxes in the DOM to match the set
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
cb.checked = _bulkSelected.has(cb.dataset.id);
});
}
function _bulkToggle(id, checked) {
if (checked) _bulkSelected.add(id); else _bulkSelected.delete(id);
_bulkUpdate();
}
// Inject checkbox into each voice row after renderVoiceList builds the DOM
const _origRenderVoiceList = renderVoiceList;
renderVoiceList = function() {
_origRenderVoiceList.apply(this, arguments);
_bulkInjectCheckboxes();
};
function _bulkInjectCheckboxes() {
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
if (row.querySelector('.vl-bulk-cb')) return; // already injected
const id = row.dataset.id;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'vl-bulk-cb';
cb.dataset.id = id;
cb.checked = _bulkSelected.has(id);
cb.title = 'Select for bulk edit';
cb.addEventListener('change', e => { e.stopPropagation(); _bulkToggle(id, cb.checked); });
cb.addEventListener('click', e => e.stopPropagation());
// Prepend into vl-compact
const compact = row.querySelector('.vl-compact');
if (compact) compact.prepend(cb);
});
}
// ── Toolbar button wiring ───────────────────────────────────────────────────
$('vl-bulk-select-all')?.addEventListener('click', () => {
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
if (row.dataset.id) _bulkSelected.add(row.dataset.id);
});
_bulkUpdate();
});
// Always-visible Select-all toggle in the sort bar (the bulk bar only appears once
// something is selected, so this is the entry point). Selects every visible row, or
// clears the selection if they're all already selected.
$('vl-select-all-visible')?.addEventListener('click', () => {
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
if (allSelected) {
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
} else {
rows.forEach(r => _bulkSelected.add(r.dataset.id));
}
_bulkUpdate();
});
$('vl-bulk-deselect')?.addEventListener('click', () => {
_bulkSelected.clear();
_bulkUpdate();
});
$('vl-bulk-hide')?.addEventListener('click', async () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const r = await _bulkSetEnabled(ids, false);
toast(`Hidden ${r} voice${r!==1?'s':''}`, 'success');
_bulkSelected.clear();
await loadVoiceLibrary();
});
$('vl-bulk-unhide')?.addEventListener('click', async () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const r = await _bulkSetEnabled(ids, true);
toast(`Unhidden ${r} voice${r!==1?'s':''}`, 'success');
_bulkSelected.clear();
await loadVoiceLibrary();
});
$('vl-bulk-tag')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
// Show a small inline prompt inside the toolbar
const bar = $('vl-bulk-bar');
let inp = bar.querySelector('.vl-bulk-tag-inp');
if (!inp) {
inp = document.createElement('input');
inp.type = 'text';
inp.className = 'vl-bulk-tag-inp';
inp.placeholder = 'tag1, tag2…';
inp.autocomplete = 'off';
const applyBtn = document.createElement('button');
applyBtn.className = 'vl-bulk-btn';
applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
applyBtn.addEventListener('click', async () => {
const tagVal = inp.value.trim();
let done = 0;
for (const id of ids) {
await saveMeta(id, { tag: tagVal }).catch(() => {});
done++;
}
toast(`Tag set on ${done} voice${done!==1?'s':''}`, 'success');
inp.remove(); applyBtn.remove();
_bulkSelected.clear();
await loadVoiceLibrary();
});
bar.appendChild(inp);
bar.appendChild(applyBtn);
}
inp.focus();
});
$('vl-bulk-rating')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const bar = $('vl-bulk-bar');
let sel = bar.querySelector('.vl-bulk-rating-sel');
if (!sel) {
sel = document.createElement('select');
sel.className = 'vl-bulk-rating-sel';
sel.innerHTML = '<option value="">⭐ Pick rating…</option>' +
[1,2,3,4,5].map(n => `<option value="${n}">${'★'.repeat(n)}</option>`).join('');
sel.addEventListener('change', async () => {
const rating = Number(sel.value);
if (!rating) return;
let done = 0;
for (const id of ids) {
await saveMeta(id, { rating }).catch(() => {});
done++;
}
toast(`Rated ${done} voice${done!==1?'s':''}`, 'success');
sel.remove();
_bulkSelected.clear();
await loadVoiceLibrary();
});
bar.appendChild(sel);
sel.focus();
}
});
$('vl-bulk-delete')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
_showBulkDeleteConfirm([..._bulkSelected]);
});
function _showBulkDeleteConfirm(ids) {
document.querySelector('.vl-bdc-overlay')?.remove();
const plural = ids.length !== 1 ? 's' : '';
const names = ids.slice(0, 12).map(id => `<span class="vl-bdc-id" title="${escHtml(id)}">${escHtml(id)}</span>`).join('');
const more = ids.length > 12 ? `<span class="vl-bdc-more">+${ids.length - 12} more</span>` : '';
const ov = document.createElement('div');
ov.className = 'vl-bdc-overlay';
ov.innerHTML = `
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices">
<div class="vl-bdc-title"><span class="mdi mdi-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div>
<p class="vl-bdc-sub">This permanently removes the selected voice${plural} from your library. This cannot be undone.</p>
<div class="vl-bdc-list">${names}${more}</div>
<div class="vl-bdc-actions">
<button type="button" class="vl-bdc-cancel">Cancel</button>
<button type="button" class="vl-bdc-go">Delete ${ids.length} voice${plural}</button>
</div>
</div>`;
const close = () => { ov.remove(); document.removeEventListener('keydown', onKey); };
function onKey(e) { if (e.key === 'Escape') close(); }
ov.addEventListener('click', e => { if (e.target === ov) close(); });
ov.querySelector('.vl-bdc-cancel').addEventListener('click', close);
document.addEventListener('keydown', onKey);
ov.querySelector('.vl-bdc-go').addEventListener('click', async () => {
const goBtn = ov.querySelector('.vl-bdc-go');
const cancelBtn = ov.querySelector('.vl-bdc-cancel');
goBtn.disabled = cancelBtn.disabled = true;
let done = 0, errors = 0;
await runPool(ids, async (id) => {
try {
const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (r.ok) done++; else errors++;
} catch (_) { errors++; }
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Deleting ${n}/${ids.length}…`; });
close();
toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
_bulkSelected.clear();
await loadVoiceLibrary();
});
document.body.appendChild(ov);
ov.querySelector('.vl-bdc-cancel').focus();
}
// ── Bulk API helpers ────────────────────────────────────────────────────────
async function _bulkSetEnabled(ids, enabled) {
let done = 0;
for (const id of ids) {
await saveMeta(id, { enabled }).catch(() => {});
done++;
}
return done;
}

224
static/js/voice-picker.js Normal file
View File

@ -0,0 +1,224 @@
// ── Voice Picker ──────────────────────────────────────────────────────────
// Replaces <select> elements with a searchable dropdown that shows voice
// avatars. Usage:
// VoicePicker.upgrade('tts-voice-select'); // upgrades by select id
// VoicePicker.populate('tts-voice-select', voices); // fill options
// VoicePicker.getValue('tts-voice-select'); // get current value
// VoicePicker.setValue('tts-voice-select', id); // set value
(function () {
'use strict';
const _pickers = {}; // selectId → { root, input, list, select }
function _voiceData(id) {
return (window._voices || []).find(v => v.id === id) || null;
}
// Shared gender/type avatar icons (male/female/neutral/robot/animal). Returns null
// when the voice has no avatar key, so callers fall back to photo/flag/letter.
const VOICE_AVATAR_ICONS = {
male: 'mdi-face-man', female: 'mdi-face-woman', neutral: 'mdi-account',
robot: 'mdi-robot-outline', animal: 'mdi-paw',
};
const VOICE_AVATAR_COLORS = {
male: '#3b82f6', female: '#ec4899', neutral: '#6b7280', robot: '#0ea5e9', animal: '#f59e0b',
};
window.VOICE_AVATAR_ICONS = VOICE_AVATAR_ICONS;
window.voiceAvatarIcon = function (avatarKey, size) {
const icon = VOICE_AVATAR_ICONS[avatarKey];
if (!icon) return null;
const s = size + 'px', r = Math.round(size / 2) + 'px';
const bg = VOICE_AVATAR_COLORS[avatarKey] || '#6b7280';
return `<span class="vp-avatar" style="width:${s};height:${s};border-radius:${r};background:${bg};color:#fff;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0"><span class="mdi ${icon}" style="font-size:${Math.round(size * 0.58)}px"></span></span>`;
};
function _avatarHtml(id, size) {
const v = _voiceData(id);
const s = size + 'px';
const r = Math.round(size / 2) + 'px';
if (v?.has_picture) {
return `<img src="/api/voice/picture/${encodeURIComponent(id)}" class="vp-avatar" style="width:${s};height:${s};border-radius:${r};object-fit:cover;flex-shrink:0" alt="">`;
}
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(v?.avatar, size) : null;
if (icon) return icon;
const lang = v?.lang || '';
const color = _langColor(lang, id);
const init = (id || '?')[0].toUpperCase();
return `<span class="vp-avatar" style="width:${s};height:${s};border-radius:${r};background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.44)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${init}</span>`;
}
function _langColor(lang, id) {
const str = (lang || id || '').toLowerCase();
if (str.startsWith('de')) return '#3b82f6';
if (str.startsWith('en')) return '#10b981';
if (str.startsWith('fr')) return '#8b5cf6';
if (str.startsWith('es')) return '#f59e0b';
if (str.startsWith('it')) return '#ef4444';
if (str.startsWith('zh')) return '#ec4899';
if (str.startsWith('ja')) return '#f97316';
// deterministic fallback from string hash
const palette = ['#3b82f6','#10b981','#8b5cf6','#f59e0b','#ef4444','#ec4899','#06b6d4','#84cc16'];
let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function _flagSpan(v) {
if (!v) return '';
if (v.flag) return `<span class="vp-flag">${v.flag}</span>`;
return '';
}
function _buildItem(id, label) {
const v = _voiceData(id);
return `<div class="vp-item" data-value="${_esc(id)}" tabindex="-1">
${_avatarHtml(id, 26)}
<span class="vp-item-name">${_esc(label || id)}</span>
${_flagSpan(v)}
</div>`;
}
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function upgrade(selectId) {
const sel = document.getElementById(selectId);
if (!sel || sel.dataset.vpUpgraded) return;
sel.dataset.vpUpgraded = '1';
sel.style.display = 'none';
// Build wrapper
const root = document.createElement('div');
root.className = 'vp-root';
root.dataset.vpFor = selectId;
sel.parentNode.insertBefore(root, sel);
// Trigger button
const trigger = document.createElement('div');
trigger.className = 'vp-trigger';
trigger.tabIndex = 0;
trigger.innerHTML = `<span class="vp-trigger-content"><span class="vp-trigger-placeholder">— select after fetch —</span></span><span class="mdi mdi-chevron-down vp-chevron"></span>`;
root.appendChild(trigger);
// Dropdown
const drop = document.createElement('div');
drop.className = 'vp-drop';
drop.hidden = true;
drop.innerHTML = `<div class="vp-search-wrap"><span class="mdi mdi-magnify vp-search-icon"></span><input class="vp-search" placeholder="Search voices…" autocomplete="off" spellcheck="false"></div><div class="vp-list"></div>`;
root.appendChild(drop);
const searchInp = drop.querySelector('.vp-search');
const list = drop.querySelector('.vp-list');
function _renderList(filter) {
const opts = Array.from(sel.options).filter(o => o.value);
const q = (filter || '').toLowerCase();
const filtered = q ? opts.filter(o => o.value.toLowerCase().includes(q) || o.textContent.toLowerCase().includes(q)) : opts;
if (!filtered.length) { list.innerHTML = '<div class="vp-empty">No voices found</div>'; return; }
list.innerHTML = filtered.map(o => _buildItem(o.value, o.textContent)).join('');
// highlight current
list.querySelectorAll('.vp-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === sel.value);
});
}
function _open() {
drop.hidden = false;
trigger.classList.add('open');
searchInp.value = '';
_renderList('');
searchInp.focus();
// scroll selected into view
requestAnimationFrame(() => {
const sel2 = list.querySelector('.vp-item.selected');
if (sel2) sel2.scrollIntoView({ block: 'nearest' });
});
}
function _close() {
drop.hidden = true;
trigger.classList.remove('open');
}
function _select(value) {
sel.value = value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
_syncTrigger();
_close();
}
function _syncTrigger() {
const v = sel.value;
const content = trigger.querySelector('.vp-trigger-content');
if (!v) {
content.innerHTML = '<span class="vp-trigger-placeholder">— select after fetch —</span>';
} else {
const label = Array.from(sel.options).find(o => o.value === v)?.textContent || v;
content.innerHTML = `${_avatarHtml(v, 24)}<span class="vp-trigger-name">${_esc(label)}</span>`;
}
}
trigger.addEventListener('click', () => drop.hidden ? _open() : _close());
trigger.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _open(); } });
searchInp.addEventListener('input', () => _renderList(searchInp.value));
searchInp.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.stopPropagation(); _close(); trigger.focus(); }
if (e.key === 'ArrowDown') { e.preventDefault(); list.querySelector('.vp-item')?.focus(); }
});
list.addEventListener('click', e => {
const item = e.target.closest('.vp-item');
if (item) _select(item.dataset.value);
});
list.addEventListener('keydown', e => {
const item = e.target.closest('.vp-item');
if (!item) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _select(item.dataset.value); }
if (e.key === 'ArrowDown') { e.preventDefault(); (item.nextElementSibling || item).focus(); }
if (e.key === 'ArrowUp') { e.preventDefault(); (item.previousElementSibling || item)?.focus() || searchInp.focus(); }
if (e.key === 'Escape') { _close(); trigger.focus(); }
});
// Close on outside click
document.addEventListener('click', e => {
if (!root.contains(e.target)) _close();
}, true);
// Watch for programmatic changes to the underlying select
const mo = new MutationObserver(() => { _syncTrigger(); });
mo.observe(sel, { childList: true, attributes: true, subtree: true });
_pickers[selectId] = { root, trigger, drop, searchInp, list, sel, renderList: _renderList, syncTrigger: _syncTrigger };
}
function populate(selectId, voices) {
const p = _pickers[selectId];
if (!p) return;
const { sel, renderList, syncTrigger } = p;
const prev = sel.value;
sel.innerHTML = '<option value="">— select after fetch —</option>';
voices.forEach(id => {
const opt = document.createElement('option');
opt.value = opt.textContent = id;
sel.appendChild(opt);
});
if (prev && voices.includes(prev)) sel.value = prev;
renderList('');
syncTrigger();
}
function getValue(selectId) {
return document.getElementById(selectId)?.value || '';
}
function setValue(selectId, value) {
const sel = document.getElementById(selectId);
if (!sel) return;
sel.value = value;
_pickers[selectId]?.syncTrigger();
}
window.VoicePicker = { upgrade, populate, getValue, setValue };
})();

View File

@ -108,7 +108,7 @@ function renderGetVoices() {
return; return;
} }
list.innerHTML = shown.map(item => { list.innerHTML = shown.map(item => {
const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="">` : `<div class="voice-source-thumb"></div>`; const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="" loading="lazy" decoding="async">` : `<div class="voice-source-thumb"></div>`;
const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : ''; const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : '';
const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : ''; const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : '';
const canGetVoice = !!(item.import_url || item.audio_url); const canGetVoice = !!(item.import_url || item.audio_url);
@ -249,6 +249,21 @@ async function getSourceVoiceInLibrary(meta) {
}); });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
// Tag the imported voice with its source (host of the source page) + a picture if any
let srcTag = 'online';
try { if (meta.pageUrl) srcTag = new URL(meta.pageUrl).hostname.replace(/^www\./, '').split('.')[0] || 'online'; } catch (_) {}
if (typeof saveMeta === 'function') {
await saveMeta(d.voice_id, {
name: meta.name || d.voice_id, tag: srcTag, group: srcTag, origin: 'cloned',
gender: (gender || '').toUpperCase(), note: (meta.description || '').slice(0, 180),
}).catch(() => {});
}
if (meta.imageUrl) {
await fetch('/api/voice/picture-url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: d.voice_id, image_url: meta.imageUrl }),
}).catch(() => {});
}
_pendingSelectId = d.voice_id; _pendingSelectId = d.voice_id;
navTo('s-voices'); navTo('s-voices');
_libraryLoadPromise = null; _libraryLoadPromise = null;

View File

@ -84,6 +84,7 @@
// A — foundation (sequential: each must finish before the next batch starts) // A — foundation (sequential: each must finish before the next batch starts)
await _load('/static/js/utils.js'); await _load('/static/js/utils.js');
await _load('/static/js/i18n.js'); // defines window.t / applyI18n before features run
await _load('/static/js/settings.js'); await _load('/static/js/settings.js');
// Wait for background sections BEFORE feature modules run. // Wait for background sections BEFORE feature modules run.
@ -92,10 +93,14 @@
// settings.js completes, so this await is typically instant. // settings.js completes, so this await is typically instant.
await _bgSections; await _bgSections;
// C — feature modules (9 files fetched in parallel, executed in order) // C — feature modules (executed in order; share a global scope).
await _loadBatch([ // Opt-in single bundle (npm run minify) keeps these as one minified request;
// default loads them individually so editing static/js/* is live without a build.
var _featureFiles = [
'/static/js/voice-picker.js',
'/static/js/voice-inspector.js', '/static/js/voice-inspector.js',
'/static/js/voice-sources.js', '/static/js/voice-sources.js',
'/static/js/fishaudio-browser.js',
'/static/js/integrations.js', '/static/js/integrations.js',
'/static/js/routing.js', '/static/js/routing.js',
'/static/js/voice-clone.js', '/static/js/voice-clone.js',
@ -103,8 +108,16 @@
'/static/js/tts-preview.js', '/static/js/tts-preview.js',
'/static/js/benchmark.js', '/static/js/benchmark.js',
'/static/js/stt.js', '/static/js/stt.js',
'/static/js/rehearser-parse.js',
'/static/js/rehearser.js', '/static/js/rehearser.js',
]); ];
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
var _bundleOk = false;
if (_useBundle) {
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
catch (_) { _bundleOk = false; } // not built / missing → fall back to per-file
}
if (!_bundleOk) await _loadBatch(_featureFiles);
// D — init (needs everything above to be defined) // D — init (needs everything above to be defined)
await _load('/static/js/init.js'); await _load('/static/js/init.js');
@ -122,6 +135,10 @@
s.style.display = ''; s.style.display = '';
}); });
// ── i18n: add the language picker and translate the (now fully-rendered) UI ──
if (window.initLangPicker) window.initLangPicker();
if (window.applyI18n) window.applyI18n(document);
// E — post-init modules load in background AFTER the UI is already visible. // E — post-init modules load in background AFTER the UI is already visible.
// Engines, AI backends, generation, and conversation sections load while // Engines, AI backends, generation, and conversation sections load while
// the user browses Voices / Clone / Design, not before. // the user browses Voices / Clone / Design, not before.

View File

@ -0,0 +1,14 @@
{
"name": "TTS Voice Creator",
"short_name": "Voice Creator",
"description": "Clone, design and deploy voices.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#F5F3EE",
"theme_color": "#2563EB",
"icons": [
{ "src": "/static/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/static/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
]
}

View File

@ -40,6 +40,34 @@
}); });
} }
// ── Tree open/close state — each tree is independent ─────────────────────
// Load persisted open state (default: open voices + the active section's tree)
var _treeOpen = {};
try { _treeOpen = JSON.parse(localStorage.getItem('ttsvc_trees') || '{}'); } catch(_) {}
var TREES = [
{ tree: 'nav-voices-tree', chevron: 'nav-voices-chevron', section: 's-voices' },
{ tree: 'nav-engines-tree', chevron: 'nav-engines-chevron', section: 's-llms' },
{ tree: 'nav-settings-tree', chevron: 'nav-settings-chevron', section: 's-settings' },
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
];
function _applyTreeStates() {
TREES.forEach(function (t) {
var open = !!_treeOpen[t.tree];
var treeEl = document.getElementById(t.tree);
var chevEl = document.getElementById(t.chevron);
if (treeEl) treeEl.classList.toggle('open', open);
if (chevEl) chevEl.classList.toggle('open', open);
});
}
window.toggleNavTree = function (treeId, chevronId) {
_treeOpen[treeId] = !_treeOpen[treeId];
localStorage.setItem('ttsvc_trees', JSON.stringify(_treeOpen));
_applyTreeStates();
};
function showSection(sectionId) { function showSection(sectionId) {
localStorage.setItem('ttsvc_section', sectionId); localStorage.setItem('ttsvc_section', sectionId);
SECTIONS.forEach(function (id) { SECTIONS.forEach(function (id) {
@ -50,23 +78,14 @@
item.classList.toggle('active', item.dataset.navSection === sectionId); item.classList.toggle('active', item.dataset.navSection === sectionId);
}); });
// Voices nav tree // Navigating to a section auto-opens its tree (but never closes others)
var voicesTree = document.getElementById('nav-voices-tree'); TREES.forEach(function (t) {
var voicesChevron = document.getElementById('nav-voices-chevron'); if (t.section === sectionId && !_treeOpen[t.tree]) {
if (voicesTree) voicesTree.classList.toggle('open', sectionId === 's-voices'); _treeOpen[t.tree] = true;
if (voicesChevron) voicesChevron.classList.toggle('open', sectionId === 's-voices'); localStorage.setItem('ttsvc_trees', JSON.stringify(_treeOpen));
}
// Settings nav tree });
var settingsTree = document.getElementById('nav-settings-tree'); _applyTreeStates();
var settingsChevron = document.getElementById('nav-settings-chevron');
if (settingsTree) settingsTree.classList.toggle('open', sectionId === 's-settings');
if (settingsChevron) settingsChevron.classList.toggle('open', sectionId === 's-settings');
// Engines nav tree
var enginesTree = document.getElementById('nav-engines-tree');
var enginesChevron = document.getElementById('nav-engines-chevron');
if (enginesTree) enginesTree.classList.toggle('open', sectionId === 's-llms');
if (enginesChevron) enginesChevron.classList.toggle('open', sectionId === 's-llms');
// When entering settings, show the active sub-page (default: connections) // When entering settings, show the active sub-page (default: connections)
if (sectionId === 's-settings') { if (sectionId === 's-settings') {
@ -100,12 +119,30 @@
// ── Voice tree sub-navigation ───────────────────────────────────────────── // ── Voice tree sub-navigation ─────────────────────────────────────────────
window._voiceSidebarCat = 'all'; window._voiceSidebarCat = 'all';
window._voiceTagFilter = '';
window.navVoicesCat = function (cat) { window.navVoicesCat = function (cat) {
navTo('s-voices'); navTo('s-voices');
window._voiceSidebarCat = cat; window._voiceSidebarCat = cat;
window._voiceTagFilter = ''; // a fixed category clears any tag subfolder filter
document.querySelectorAll('[data-voice-cat]').forEach(function (el) { document.querySelectorAll('[data-voice-cat]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.voiceCat === cat); el.classList.toggle('is-active', el.dataset.voiceCat === cat);
}); });
document.querySelectorAll('[data-voice-tag]').forEach(function (el) { el.classList.remove('is-active'); });
if (typeof renderVoiceList === 'function') renderVoiceList();
};
// Click a tag "subfolder" in the My Voices tree → filter the list to that tag.
window.navVoicesTag = function (tag) {
navTo('s-voices');
window._voiceTagFilter = (window._voiceTagFilter === tag) ? '' : tag; // toggle off if re-clicked
window._voiceSidebarCat = 'all';
document.querySelectorAll('[data-voice-cat]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.voiceCat === 'all' && !window._voiceTagFilter);
});
document.querySelectorAll('[data-voice-tag]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.voiceTag === window._voiceTagFilter);
});
if (typeof renderVoiceList === 'function') renderVoiceList(); if (typeof renderVoiceList === 'function') renderVoiceList();
}; };
@ -119,6 +156,32 @@
set('ntc-favorites',n(function (v) { return (v.rating || 0) >= 4; })); set('ntc-favorites',n(function (v) { return (v.rating || 0) >= 4; }));
set('ntc-hidden', n(function (v) { return v.enabled === false; })); set('ntc-hidden', n(function (v) { return v.enabled === false; }));
set('nav-voices-count', voices.length); set('nav-voices-count', voices.length);
// ── Virtual tag "subfolders" under the fixed categories ──────────────────
var tree = document.getElementById('nav-voices-tree');
if (!tree) return;
tree.querySelectorAll('.nav-tree-tag').forEach(function (e) { e.remove(); });
var counts = {};
voices.forEach(function (v) {
String(v.tag || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean)
.forEach(function (t) { counts[t] = (counts[t] || 0) + 1; });
});
var names = Object.keys(counts).sort(function (a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); });
if (!names.length) return;
var lbl = document.createElement('div');
lbl.className = 'nav-tree-tag nav-tree-tag-label';
lbl.textContent = 'Tags';
tree.appendChild(lbl);
names.forEach(function (tag) {
var el = document.createElement('div');
el.className = 'nav-tree-item nav-tree-tag' + (window._voiceTagFilter === tag ? ' is-active' : '');
el.dataset.voiceTag = tag;
var icon = document.createElement('span'); icon.className = 'mdi mdi-tag-outline'; el.appendChild(icon);
el.appendChild(document.createTextNode(' ' + tag + ' '));
var c = document.createElement('span'); c.className = 'ntc'; c.textContent = counts[tag]; el.appendChild(c);
el.addEventListener('click', function () { window.navVoicesTag(tag); });
tree.appendChild(el);
});
}; };
// ── Settings tree sub-navigation ────────────────────────────────────────── // ── Settings tree sub-navigation ──────────────────────────────────────────
@ -151,8 +214,46 @@
if ((cat === 'tts' || cat === 'stt') && typeof loadLocalContainers === 'function') loadLocalContainers(); if ((cat === 'tts' || cat === 'stt') && typeof loadLocalContainers === 'function') loadLocalContainers();
}; };
// ── Rehearser tree sub-navigation ────────────────────────────────────────
window.navRehearserImpEx = function () {
navTo('s-rehearser');
// Clear phase highlights — Import/Export is its own panel, not a numbered phase
document.querySelectorAll('[data-rehearser-phase]').forEach(function (el) {
el.classList.remove('is-active');
});
document.getElementById('nav-reh-impex')?.classList.add('is-active');
if (typeof showRehImpEx === 'function') showRehImpEx();
else window._rehearserStartImpEx = true;
};
window.navRehearserPhase = function (phase) {
navTo('s-rehearser');
// highlight the correct tree item immediately
document.querySelectorAll('[data-rehearser-phase]').forEach(function (el) {
el.classList.toggle('is-active', Number(el.dataset.rehearserPhase) === phase);
});
// delegate to rehearser's own showPhase() when it's ready
if (typeof showPhase === 'function') {
showPhase(phase);
} else {
// rehearser.js not yet loaded — store the intent and let init pick it up
window._rehearserStartPhase = phase;
}
};
// Keep sidebar tree in sync when rehearser changes phase internally
window.onRehearserPhaseChange = function (phase) {
document.querySelectorAll('[data-rehearser-phase]').forEach(function (el) {
el.classList.toggle('is-active', Number(el.dataset.rehearserPhase) === phase);
});
};
// Restore last-used section from localStorage (fallback: My Voices) // Restore last-used section from localStorage (fallback: My Voices)
var _savedSection = localStorage.getItem('ttsvc_section'); var _savedSection = localStorage.getItem('ttsvc_section');
// On very first visit, default voices tree open
if (!localStorage.getItem('ttsvc_trees')) {
_treeOpen['nav-voices-tree'] = true;
}
window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections'; window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections';
window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm'; window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
var _startSection = (_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices'; var _startSection = (_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices';

View File

@ -2,37 +2,31 @@
<span class="section-icon"><span class="mdi mdi-microphone-variant"></span></span> <span class="section-icon"><span class="mdi mdi-microphone-variant"></span></span>
<div class="section-title"> <div class="section-title">
<h2>Clone a Voice</h2> <h2>Clone a Voice</h2>
<p>Upload or record 3-20 seconds of audio, trim it, then save it as a reusable voice clone.</p> <p>Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.</p>
</div> </div>
</div> </div>
<!-- Step 1: Load audio --> <!-- Audio source — integrated tabs (record / upload / link) -->
<div class="tab-content" id="tab-source"> <div class="tab-content" id="tab-source">
<div class="card"> <div class="clone-tabbox">
<h2>Step 1 &mdash; Load audio</h2> <div class="clone-src-picker" id="clone-src-picker" role="tablist">
<p class="card-subtitle">Drop a file, paste a URL, or record directly from your microphone.</p> <button class="clone-src-tab active" data-src="mic" role="tab"><span class="mdi mdi-microphone"></span> Microphone</button>
<div id="drop-zone"> <button class="clone-src-tab" data-src="file" role="tab"><span class="mdi mdi-file-music-outline"></span> Upload file</button>
<strong>Drop an audio / video file here</strong> <button class="clone-src-tab" data-src="url" role="tab"><span class="mdi mdi-link-variant"></span> URL / YouTube</button>
<span>WAV &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; WEBM</span>
<span style="margin-top:10px;font-size:12px;color:var(--subtext)">or click to browse</span>
<input type="file" id="file-input" accept=".wav,.mp3,.ogg,.opus,.flac,.m4a,.mp4,.mkv,.webm,.mov,.avi,audio/*,video/*">
</div> </div>
<div class="clone-srcpanel">
<!-- Microphone -->
<div class="clone-src-card" id="clone-src-mic" data-src="mic">
<p class="clone-src-intro"><span class="mdi mdi-microphone"></span> Record a fresh sample with live input-level monitoring.</p>
<div class="field" style="max-width:320px">
<label for="clone-your-name">Voice name</label>
<input type="text" id="clone-your-name" placeholder="e.g. Anna" spellcheck="false" autocomplete="off">
<p class="note">Sets the voice name and is dropped into the sentence below.</p>
</div> </div>
<div class="card"> <div class="sample-read-box" style="margin-top:12px">
<h2>YouTube / URL</h2>
<p class="card-subtitle">Download audio from a direct link or YouTube video.</p>
<div class="url-row">
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
<button class="btn-primary" id="yt-btn">Download</button>
</div>
<div id="yt-progress"></div>
</div>
<div class="card">
<h2>Microphone</h2>
<p class="card-subtitle">Record a fresh sample with live input level monitoring.</p>
<div class="sample-read-box" style="margin-bottom:12px">
<div class="sample-head"> <div class="sample-head">
<label for="clone-sample-lang">Read aloud</label> <label for="clone-sample-lang">Read this aloud</label>
<select id="clone-sample-lang"> <select id="clone-sample-lang">
<option value="EN">English</option> <option value="EN">English</option>
<option value="DE">Deutsch</option> <option value="DE">Deutsch</option>
@ -44,10 +38,10 @@
<option value="PL">Polski</option> <option value="PL">Polski</option>
</select> </select>
</div> </div>
<textarea class="sample-sentence" id="clone-sample-text" spellcheck="true">Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain — these are the things I love. Can you hear how clearly I speak?</textarea> <textarea class="sample-sentence" id="clone-sample-text" aria-label="Sample sentence to read aloud" spellcheck="true">Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain — these are the things I love. Can you hear how clearly I speak?</textarea>
<p class="note" style="margin-top:6px"><span class="mdi mdi-pencil-outline"></span> Replace <strong>the name</strong> (Sam / Alex / Marco …) with your own name before recording.</p> <p class="note" style="margin-top:6px"><span class="mdi mdi-pencil-outline"></span> Edit the sentence freely before recording.</p>
</div> </div>
<div class="btn-row"> <div class="btn-row" style="margin-top:12px">
<button class="btn-secondary" id="clone-monitor-btn"><span class="mdi mdi-waveform"></span> Check level</button> <button class="btn-secondary" id="clone-monitor-btn"><span class="mdi mdi-waveform"></span> Check level</button>
<button class="btn-secondary" id="clone-monitor-stop" disabled>Stop monitor</button> <button class="btn-secondary" id="clone-monitor-stop" disabled>Stop monitor</button>
<button class="btn-red" id="rec-start-btn"><span class="mdi mdi-record-circle-outline"></span> Record</button> <button class="btn-red" id="rec-start-btn"><span class="mdi mdi-record-circle-outline"></span> Record</button>
@ -69,8 +63,34 @@
<div class="note" style="margin-top:4px">Best peaks: −18 to −9 dB. Never red.</div> <div class="note" style="margin-top:4px">Best peaks: −18 to −9 dB. Never red.</div>
</div> </div>
</div> </div>
<!-- Upload file -->
<div class="clone-src-card" id="clone-src-file" data-src="file" hidden>
<p class="clone-src-intro"><span class="mdi mdi-file-music-outline"></span> Drop an audio or video file, or click to browse.</p>
<div id="drop-zone">
<strong>Drop an audio / video file here</strong>
<span>WAV &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; WEBM</span>
<span style="margin-top:10px;font-size:12px;color:var(--subtext)">or click to browse</span>
<input type="file" id="file-input" accept=".wav,.mp3,.ogg,.opus,.flac,.m4a,.mp4,.mkv,.webm,.mov,.avi,audio/*,video/*">
</div>
</div>
<!-- URL / YouTube -->
<div class="clone-src-card" id="clone-src-url" data-src="url" hidden>
<p class="clone-src-intro"><span class="mdi mdi-link-variant"></span> Paste a direct audio link or a YouTube URL to grab the audio.</p>
<div class="url-row">
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
<button class="btn-primary" id="yt-btn">Download</button>
</div>
<div id="yt-progress"></div>
</div>
</div><!-- /clone-srcpanel -->
</div><!-- /clone-tabbox -->
<!-- Trim (appears once audio is loaded) -->
<div class="card" id="waveform-card" style="display:none"> <div class="card" id="waveform-card" style="display:none">
<h2>Step 2 &mdash; Trim selection</h2> <h2><span class="mdi mdi-content-cut"></span> Trim your sample</h2>
<p class="card-subtitle">Select the best 3–20 s of clean speech from the waveform.</p> <p class="card-subtitle">Select the best 3–20 s of clean speech from the waveform.</p>
<div id="waveform-wrap"><div id="waveform"></div></div> <div id="waveform-wrap"><div id="waveform"></div></div>
<div class="time-row"> <div class="time-row">
@ -87,21 +107,15 @@
</div> </div>
</div><!-- /tab-source --> </div><!-- /tab-source -->
<!-- Step 3: Name and save --> <!-- Name, transcribe, preview & save -->
<div class="tab-content" id="tab-save"> <div class="tab-content" id="tab-save">
<div class="card"> <div class="card">
<h2>Step 3 &mdash; Name the voice</h2> <h2><span class="mdi mdi-account-voice"></span> Name your voice</h2>
<p class="card-subtitle">Assign a unique ID, language, and gender — e.g. <code>EN_F_Anna</code>.</p> <p class="card-subtitle">Choose language &amp; gender and type a name — the ID builds automatically (e.g. <code>EN_F_Anna</code>). You can also type a custom ID.</p>
<div class="voice-id-row"> <div class="clone-id-row">
<input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false">
</div>
<div id="voice-id-hint" class="note" style="min-height:1.2em"></div>
<details class="helper">
<summary>Build from parts &nbsp;(LANG &middot; GENDER &middot; Name helper)</summary>
<div class="helper-body">
<div class="field"> <div class="field">
<label>Language</label> <label for="lang-select">Language</label>
<select id="lang-select"> <select id="lang-select" aria-label="Voice language">
<option>EN</option><option>DE</option><option>ZH</option><option>FR</option> <option>EN</option><option>DE</option><option>ZH</option><option>FR</option>
<option>ES</option><option>JA</option><option>KO</option><option>IT</option> <option>ES</option><option>JA</option><option>KO</option><option>IT</option>
<option>PT</option><option>RU</option><option>AR</option><option>PL</option> <option>PT</option><option>RU</option><option>AR</option><option>PL</option>
@ -109,27 +123,33 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Gender</label> <label for="gender-select">Gender</label>
<select id="gender-select"> <select id="gender-select" aria-label="Voice gender">
<option value="F">F &mdash; Female</option> <option value="F">F &mdash; Female</option>
<option value="M">M &mdash; Male</option> <option value="M">M &mdash; Male</option>
<option value="N">N &mdash; Neutral</option> <option value="N">N &mdash; Neutral</option>
</select> </select>
</div> </div>
<div class="field" style="flex:1;min-width:120px"> <div class="field" style="flex:1;min-width:140px">
<label>Name (no spaces)</label> <label>Name (no spaces)</label>
<input type="text" id="name-input" placeholder="Anna"> <input type="text" id="name-input" placeholder="Anna">
</div> </div>
<button class="btn-secondary" id="helper-apply-btn" style="align-self:flex-end">Apply &rarr;</button> <button class="btn-secondary" id="helper-apply-btn" hidden>Apply &rarr;</button>
</div>
<div class="field" style="margin-top:14px">
<label for="voice-id-input">Final voice ID</label>
<div class="voice-id-row">
<input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false">
</div>
<div id="voice-id-hint" class="note" style="min-height:1.2em"></div>
</div> </div>
</details>
</div> </div>
<div class="card"> <div class="card">
<h2>Transcript (reference text)</h2> <h2><span class="mdi mdi-message-text-outline"></span> Reference transcript</h2>
<p class="card-subtitle">The spoken text that matches this audio — type it or auto-transcribe.</p> <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"> <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> <label style="font-size:13px;color:var(--subtext);white-space:nowrap">Recognition engine:</label>
<select id="clone-stt-backend" style="flex:1;min-width:160px"><option value="configured">Configured Whisper/STT</option></select> <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> <button class="btn-secondary" id="clone-refresh-stt-btn" type="button">Refresh</button>
</div> </div>
<div class="btn-row"> <div class="btn-row">
@ -139,16 +159,20 @@
<textarea id="transcript-area" placeholder="Type or auto-transcribe the spoken text&hellip;"></textarea> <textarea id="transcript-area" placeholder="Type or auto-transcribe the spoken text&hellip;"></textarea>
</div> </div>
<div class="card"> <div class="card">
<h2>Audio preview</h2> <h2><span class="mdi mdi-play-circle-outline"></span> Preview</h2>
<p class="card-subtitle">Play back the trimmed audio before saving to the library.</p> <p class="card-subtitle">Listen to the trimmed audio before saving.</p>
<audio id="trim-audio" controls style="display:none"></audio> <audio id="trim-audio" controls style="display:none"></audio>
<p id="no-audio-hint" class="note">No audio loaded yet. Load a file above (Step 1) or use Voice Design below.</p> <p id="no-audio-hint" class="note">No audio yet — record or load a sample above, or use Voice Design.</p>
</div> </div>
<div class="card"> <div class="card">
<h2>Step 4 &mdash; Save to library</h2> <h2><span class="mdi mdi-content-save-outline"></span> Save to library</h2>
<p class="card-subtitle">Write the cloned voice to your active voices folder.</p> <p class="card-subtitle">Write the cloned voice to your active voices folder.</p>
<div class="btn-row"> <div class="btn-row" style="align-items:center;gap:12px">
<button class="btn-green" id="save-btn"><span class="mdi mdi-memory"></span> Save to Voice Library</button> <button class="btn-green" id="save-btn"><span class="mdi mdi-memory"></span> Save to Voice Library</button>
<label class="chunk-toggle-label" style="font-size:12px;gap:5px" title="Save automatically once audio, ID and transcript are all ready (re-saves when you change details)">
<input type="checkbox" id="clone-autosave-toggle" checked>
<span>Auto-save when ready</span>
</label>
</div> </div>
<div id="save-result" class="note" style="display:none"></div> <div id="save-result" class="note" style="display:none"></div>
</div> </div>

View File

@ -11,22 +11,22 @@
<div class="conv-config-row"> <div class="conv-config-row">
<div class="conv-config-group"> <div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label> <label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
<select id="conv-stt-select"><option value="configured">Checking...</option></select> <select id="conv-stt-select" aria-label="Speech-to-text engine"><option value="configured">Checking...</option></select>
</div> </div>
<div class="conv-config-group"> <div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label> <label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<input id="conv-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false"> <input id="conv-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
<button class="btn-secondary" id="conv-llm-fetch-btn" title="Fetch models"><span class="mdi mdi-refresh"></span></button> <button class="btn-secondary" id="conv-llm-fetch-btn" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="conv-llm-model-select"><option value="">— fetch models —</option></select> <select id="conv-llm-model-select" aria-label="Language model"><option value="">— fetch models —</option></select>
</div> </div>
</div> </div>
<div class="conv-config-group"> <div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label> <label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<select id="conv-tts-backend-select"><option value="">Checking...</option></select> <select id="conv-tts-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="conv-tts-fetch-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button> <button class="btn-secondary" id="conv-tts-fetch-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
<select id="conv-tts-voice-select"><option value="">— fetch voices —</option></select> <select id="conv-tts-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
</div> </div>
</div> </div>
<div class="conv-config-group conv-config-group--actions"> <div class="conv-config-group conv-config-group--actions">
@ -35,7 +35,7 @@
</div> </div>
<div class="conv-prompt-row"> <div class="conv-prompt-row">
<label class="conv-cfg-label"><span class="mdi mdi-text-box-outline"></span> System prompt</label> <label class="conv-cfg-label"><span class="mdi mdi-text-box-outline"></span> System prompt</label>
<textarea id="conv-system-prompt" class="conv-system-textarea" rows="1" spellcheck="false">You are a helpful voice assistant. Keep replies short and conversational.</textarea> <textarea id="conv-system-prompt" aria-label="System prompt" class="conv-system-textarea" rows="1" spellcheck="false">You are a helpful voice assistant. Keep replies short and conversational.</textarea>
</div> </div>
</div> </div>

View File

@ -27,7 +27,7 @@
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px"> <div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field"> <div class="field">
<label>Gender</label> <label>Gender</label>
<select class="lang-select" id="design-gender"> <select class="lang-select" id="design-gender" aria-label="Voice gender">
<option value="N">Neutral</option> <option value="N">Neutral</option>
<option value="F">Female</option> <option value="F">Female</option>
<option value="M">Male</option> <option value="M">Male</option>
@ -35,7 +35,7 @@
</div> </div>
<div class="field"> <div class="field">
<label>Language</label> <label>Language</label>
<select class="lang-select" id="design-language"> <select class="lang-select" id="design-language" aria-label="Voice language">
<option value="Auto">Auto-detect</option> <option value="Auto">Auto-detect</option>
<option value="English">English</option><option value="Chinese">Chinese</option> <option value="English">English</option><option value="Chinese">Chinese</option>
<option value="Japanese">Japanese</option><option value="Korean">Korean</option> <option value="Japanese">Japanese</option><option value="Korean">Korean</option>
@ -132,7 +132,7 @@
<div class="preset-row"> <div class="preset-row">
<div class="field"> <div class="field">
<label>Preset</label> <label>Preset</label>
<select id="design-preset-select"><option value="">— preset —</option></select> <select id="design-preset-select" aria-label="Voice preset"><option value="">— preset —</option></select>
</div> </div>
<button class="btn-secondary" id="design-preset-load">Load</button> <button class="btn-secondary" id="design-preset-load">Load</button>
<button class="btn-secondary" id="design-preset-delete">Delete</button> <button class="btn-secondary" id="design-preset-delete">Delete</button>

View File

@ -12,7 +12,7 @@
<div class="llm-sec-header"> <div class="llm-sec-header">
<div> <div>
<h3 class="llm-sec-title">Language Models</h3> <h3 class="llm-sec-title">Language Models</h3>
<p class="llm-sec-note">Used for persona rewriting and transcription refinement. Click <strong>Use as LLM</strong> to apply a URL to this app&rsquo;s LLM setting.</p> <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>
<div style="display:flex;gap:8px;align-items:center"> <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> <button class="btn-secondary" type="button" onclick="openAddEngineDialog('llm')"><span class="mdi mdi-plus"></span> Add Custom</button>
@ -20,6 +20,34 @@
</div> </div>
</div> </div>
<!-- ── Active Language Model (global picker) ──────────────── -->
<div class="llm-active-panel card">
<div class="llm-active-head">
<span class="llm-active-icon"><span class="mdi mdi-star-four-points"></span></span>
<div>
<h4 class="llm-active-title">Active Language Model</h4>
<p class="llm-active-sub">Default endpoint &amp; model for all LLM tasks. Per-task dropdowns can still override it.</p>
</div>
</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>
<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>
<span id="llm-active-status" class="llm-active-status"></span>
</div>
</div>
<div id="dc-grid-llm" class="dc-grid" style="margin-bottom:14px"></div> <div id="dc-grid-llm" class="dc-grid" style="margin-bottom:14px"></div>
<h4 class="llm-local-cat">Local</h4> <h4 class="llm-local-cat">Local</h4>
@ -151,6 +179,73 @@ Check "Enable CORS" for browser access</pre>
<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> <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>
<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> </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> <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>
@ -492,6 +587,38 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
<h4 class="llm-local-cat">Other local TTS</h4> <h4 class="llm-local-cat">Other local TTS</h4>
<div class="llm-local-grid"> <div class="llm-local-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-card">
<div class="llm-local-head"> <div class="llm-local-head">
<span class="llm-local-icon"><span class="mdi mdi-speaker-outline"></span></span> <span class="llm-local-icon"><span class="mdi mdi-speaker-outline"></span></span>

View File

@ -2,100 +2,370 @@
<span class="section-icon"><span class="mdi mdi-theater"></span></span> <span class="section-icon"><span class="mdi mdi-theater"></span></span>
<div class="section-title"> <div class="section-title">
<h2>Script Rehearser</h2> <h2>Script Rehearser</h2>
<p>Upload a script, assign voices to characters, then rehearse with TTS synthesis and microphone recording.</p> <p>Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.</p>
</div> </div>
</div> </div>
<!-- Phase sub-tabs -->
<div class="reh-subtabs" id="reh-subtabs">
<button class="reh-subtab active" data-phase="1"><span class="mdi mdi-bookshelf"></span> <span>Library</span></button>
<button class="reh-subtab" data-phase="2" disabled><span class="mdi mdi-account-group-outline"></span> <span>Cast</span></button>
<button class="reh-subtab" data-phase="3" disabled><span class="mdi mdi-theater"></span> <span>Stage</span></button>
<button class="reh-subtab" data-phase="4" disabled><span class="mdi mdi-check-circle-outline"></span> <span>Summary</span></button>
</div>
<div class="tab-content" id="tab-rehearser"> <div class="tab-content" id="tab-rehearser">
<!-- ── Phase 1: Library ───────────────────────────────────────── --> <!-- ── Import / Export panel (hidden unless nav item clicked) ── -->
<div class="reh-phase" id="reh-impex-panel" hidden>
<div class="card">
<h2><span class="mdi mdi-swap-vertical"></span> Import / Export</h2>
<p class="card-subtitle">Import a script or saved rehearsal, or export the current session in any format.</p>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<h4 class="reh-impex-cat" style="margin:0"><span class="mdi mdi-web"></span> Import from URL</h4>
<button class="btn-secondary btn-sm" id="reh-browse-imsdb-btn-impex" title="Browse 1300+ movie scripts from IMSDb"><span class="mdi mdi-movie-open-outline"></span> Browse IMSDb</button>
</div>
<div class="reh-impex-url-row">
<span class="mdi mdi-web reh-impex-url-icon"></span>
<input id="reh-impex-url" type="text" class="reh-impex-url-inp"
placeholder="https://www.imsdb.com/scripts/… or any plain-text screenplay URL"
spellcheck="false" autocomplete="off">
<button class="btn-primary" id="reh-impex-url-btn" type="button">
<span class="mdi mdi-download"></span> Fetch
</button>
</div>
<p class="reh-impex-note" id="reh-impex-url-status"></p>
<h4 class="reh-impex-cat" style="margin-top:14px"><span class="mdi mdi-import"></span> Import from file</h4>
<div class="reh-impex-grid">
<label class="reh-impex-btn">
<span class="mdi mdi-text-box-outline"></span>
<span>.txt / .md</span>
<input type="file" accept=".txt,.md" class="reh-impex-file" data-impex-type="text" style="display:none">
</label>
<label class="reh-impex-btn">
<span class="mdi mdi-file-pdf-box"></span>
<span>.pdf</span>
<input type="file" accept=".pdf" class="reh-impex-file" data-impex-type="pdf" style="display:none">
</label>
<label class="reh-impex-btn">
<span class="mdi mdi-fountain-pen-tip"></span>
<span>.fountain</span>
<input type="file" accept=".fountain" class="reh-impex-file" data-impex-type="fountain" style="display:none">
</label>
<label class="reh-impex-btn">
<span class="mdi mdi-file-code-outline"></span>
<span>.fdx / .osf</span>
<input type="file" accept=".fdx,.osf" class="reh-impex-file" data-impex-type="fdx" style="display:none">
</label>
<label class="reh-impex-btn reh-impex-btn--accent">
<span class="mdi mdi-book-open-variant"></span>
<span>.reh (session)</span>
<input type="file" accept=".reh,.json" class="reh-impex-file" data-impex-type="reh" style="display:none">
</label>
</div>
<h4 class="reh-impex-cat" style="margin-top:20px"><span class="mdi mdi-export"></span> Export</h4>
<p class="reh-impex-note" id="reh-impex-export-note"></p>
<div class="reh-impex-grid">
<button class="reh-impex-btn" id="reh-impex-reh-btn">
<span class="mdi mdi-book-open-variant"></span>
<span>.reh (session)</span>
</button>
<button class="reh-impex-btn" id="reh-impex-fountain-btn">
<span class="mdi mdi-fountain-pen-tip"></span>
<span>.fountain</span>
</button>
<button class="reh-impex-btn" id="reh-impex-fdx-btn">
<span class="mdi mdi-file-code-outline"></span>
<span>.fdx</span>
</button>
<button class="reh-impex-btn" id="reh-impex-txt-btn">
<span class="mdi mdi-text-box-outline"></span>
<span>.txt</span>
</button>
<button class="reh-impex-btn" id="reh-impex-md-btn">
<span class="mdi mdi-language-markdown-outline"></span>
<span>.md</span>
</button>
<button class="reh-impex-btn" id="reh-impex-osf-btn">
<span class="mdi mdi-xml"></span>
<span>.osf</span>
</button>
</div>
<div class="btn-row" style="margin-top:18px">
<button class="btn-secondary" onclick="navRehearserPhase(1)"><span class="mdi mdi-arrow-left"></span> Back to Library</button>
</div>
</div>
</div>
<!-- ── Phase 1: Library + new script ─────────────────────────── -->
<div class="reh-phase" id="reh-phase-1"> <div class="reh-phase" id="reh-phase-1">
<div class="card"> <div class="card">
<div class="reh-lib-header"> <div class="reh-lib-header">
<h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> Rehearsal library</h2> <h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> Rehearsal library</h2>
<div class="btn-row" style="gap:8px;margin:0"> <div style="display:flex;gap:8px;align-items:center">
<button class="btn-secondary btn-sm" id="reh-browse-imsdb-btn" title="Browse 1300+ movie scripts from IMSDb"><span class="mdi mdi-movie-open-outline"></span> Browse IMSDb</button>
<button class="btn-secondary btn-sm" id="reh-lib-view-toggle" title="Toggle shelf / list view"><span class="mdi mdi-view-list"></span></button>
<label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px"> <label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<span class="mdi mdi-import"></span> Import .reh <span class="mdi mdi-import"></span> Import .reh
<input type="file" id="reh-lib-import-file" accept=".reh,.json" style="display:none"> <input type="file" id="reh-lib-import-file" accept=".reh,.json" style="display:none">
</label> </label>
</div> </div>
</div> </div>
<div id="reh-library-list" class="reh-lib-list"> <div id="reh-library-list" class="reh-bookshelf">
<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below to start a new one.</p></div> <div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below to start.</p></div>
</div> </div>
</div> </div>
<!-- Script input -->
<!-- Drag & drop importer -->
<div class="reh-dropzone" id="reh-dropzone">
<span class="mdi mdi-cloud-upload-outline reh-dropzone-icon"></span>
<strong>Drag &amp; drop a script here</strong>
<span class="note">.txt · .md · .fountain · .pdf · .fdx · .osf · .reh</span>
</div>
<div class="card"> <div class="card">
<h2><span class="mdi mdi-script-text-outline"></span> New script</h2> <div class="reh-new-script-head">
<p class="card-subtitle">Format: <code>CHARACTER: dialog</code> on one line, or screenplay style (ALL-CAPS character name on its own line, dialog below). Lines starting with <code>#</code> are stage directions.</p> <h2 style="margin:0"><span class="mdi mdi-script-text-outline"></span> New script</h2>
<input type="text" id="reh-script-title" class="reh-script-title-inline" placeholder="Script title…">
</div>
<p class="card-subtitle">Paste a script below. Supports standard screenplay format (INT./EXT. headings, CHARACTER name on own line, dialog follows) as well as the simple <code>CHARACTER: text</code> format. Lines starting with <code>#</code> are explicit stage directions.</p>
<div class="reh-input-row"> <div class="reh-input-row">
<textarea id="reh-script-text" placeholder="ALICE&#10;Hello, how are you today?&#10;&#10;BOB&#10;I'm doing great, thank you for asking!&#10;&#10;ALICE&#10;That's wonderful to hear." spellcheck="false"></textarea> <textarea id="reh-script-text" placeholder="EXT. LOCATION - DAY&#10;&#10;Scene description goes here.&#10;&#10;ALICE&#10;Hello, how are you?&#10;&#10;BOB&#10;I'm doing great, thank you!" spellcheck="false"></textarea>
<div class="reh-input-side"> <div class="reh-input-side">
<label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px"> <label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<span class="mdi mdi-upload"></span> Upload .txt <span class="mdi mdi-upload"></span> .txt / .md
<input type="file" id="reh-file-input" accept=".txt,.md" style="display:none"> <input type="file" id="reh-file-input" accept=".txt,.md,.fountain" style="display:none">
</label> </label>
<div class="field" style="margin-top:10px"> <label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<label style="font-size:12px">Script title (optional)</label> <span class="mdi mdi-file-pdf-box"></span> .pdf
<input type="text" id="reh-script-title" placeholder="My Script"> <input type="file" id="reh-pdf-input" accept=".pdf" style="display:none">
</div> </label>
<p class="note" style="margin-top:8px;font-size:12px">Supported formats:<br>• <code>CHARACTER: text</code><br>• All-caps name + dialog below<br>• <code># stage direction</code></p> <label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<span class="mdi mdi-file-code-outline"></span> .fdx / .osf
<input type="file" id="reh-fdx-input" accept=".fdx,.osf,.xml" style="display:none">
</label>
<p class="note" style="font-size:11px;margin-top:8px">Supports:<br>• Screenplay: INT./EXT., CAPS names<br>• Theatre: ACT I / SCENE 1<br>• <code>CHAR: text</code> · <code># direction</code><br>• Import: .txt .fountain .pdf .fdx .osf</p>
</div> </div>
</div> </div>
<div class="btn-row" style="margin-top:12px"> <div class="btn-row" style="margin-top:12px">
<button class="btn-primary" id="reh-parse-btn"><span class="mdi mdi-auto-fix"></span> Parse characters</button> <button class="btn-primary" id="reh-parse-btn"><span class="mdi mdi-auto-fix"></span> Parse &amp; cast</button>
</div> </div>
</div> </div>
</div> </div>
<!-- ── Phase 2: Cast assignment ───────────────────────────────── --> <!-- ── Phase 2: Cast ──────────────────────────────────────────── -->
<div class="reh-phase" id="reh-phase-2" hidden> <div class="reh-phase" id="reh-phase-2" hidden>
<div class="card"> <div class="card">
<h2><span class="mdi mdi-account-group-outline"></span> Step 2 — Cast your characters</h2> <h2><span class="mdi mdi-account-group-outline"></span> Cast your characters</h2>
<p class="card-subtitle">Assign a TTS voice to each character, or toggle <strong>I play this role</strong> — those lines will pause and wait for your microphone recording.</p> <p class="card-subtitle">Assign a TTS voice or your mic to each character. Add a speaking-style note per character so the voice matches their personality.</p>
<div class="field" style="margin-bottom:14px"> <div class="reh-cast2-backend">
<label>TTS backend for synthesis</label> <div class="field" style="flex:1;min-width:160px">
<div style="display:flex;gap:8px;align-items:center"> <label>TTS backend</label>
<select id="reh-backend-select"><option value="">Checking…</option></select> <select id="reh-backend-select"><option value="">Checking…</option></select>
<button class="btn-secondary" id="reh-fetch-voices-btn"><span class="mdi mdi-refresh"></span> Fetch voices</button> </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 -->
<!-- Auto-cast: match characters to voices (local / online) or design new ones -->
<div class="reh-autodesign" role="group" aria-labelledby="reh-autocast-title">
<div class="reh-autodesign-head">
<span class="mdi mdi-auto-fix" style="color:var(--accent)" aria-hidden="true"></span>
<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>
<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> </div>
</div> </div>
<div id="reh-cast-list" class="reh-cast-list"></div>
<!-- Three casting options -->
<div class="reh-cast-options">
<div class="reh-cast-opt">
<div class="reh-co-num">1</div>
<div class="reh-co-body">
<strong><span class="mdi mdi-bookshelf" aria-hidden="true"></span> Match from local library</strong>
<p>The LLM picks the best voice you already have (cloned · designed · imported) for each character. <em>No TTS engine required.</em></p>
<button class="btn-secondary" id="reh-matchlib-btn" title="LLM picks the best existing library voice for each character">
<span class="mdi mdi-account-search-outline" aria-hidden="true"></span> Match local
</button>
</div>
</div>
<div class="reh-cast-opt">
<div class="reh-co-num">2</div>
<div class="reh-co-body">
<strong><span class="mdi mdi-earth" aria-hidden="true"></span> Match from online (fish.audio)</strong>
<p>Search fish.audio's 2M public voices for the best fit per character and import them to your library. <em>Needs internet.</em></p>
<button class="btn-secondary" id="reh-matchonline-btn" title="Search fish.audio for a matching voice per character and import it">
<span class="mdi mdi-cloud-search-outline" aria-hidden="true"></span> Match online
</button>
</div>
</div>
<div class="reh-cast-opt">
<div class="reh-co-num">3</div>
<div class="reh-co-body">
<strong><span class="mdi mdi-magic-staff" aria-hidden="true"></span> Design new voices</strong>
<p>Generate a unique voice per character from the LLM's description. <em>Requires the <b>Voice Design</b> engine (Qwen3-TTS&nbsp;:8021).</em></p>
<button class="btn-primary" id="reh-autodesign-btn" title="Design a brand-new voice for each character via the Voice Design engine">
<span class="mdi mdi-magic-staff" aria-hidden="true"></span> Design all
</button>
</div>
</div>
</div>
<div class="reh-autodesign-progress" id="reh-autodesign-progress" hidden role="progressbar" aria-label="Casting progress">
<div class="reh-synth-track"><div class="reh-synth-fill" id="reh-autodesign-fill"></div></div>
<span id="reh-autodesign-label" class="reh-synth-label">0 / 0</span>
<button class="btn-secondary btn-sm" id="reh-autodesign-cancel" style="margin-left:8px">Cancel</button>
</div>
</div>
<div class="reh-cast-toolbar">
<span class="reh-cast-count" id="reh-cast-count"></span>
<div class="reh-cast-controls">
<input type="search" id="reh-cast-search" class="reh-cast-ctl reh-cast-search" placeholder="Filter name or tag…" autocomplete="off">
<select id="reh-cast-filter-gender" class="reh-cast-ctl" title="Filter by gender">
<option value="">All genders</option>
<option value="F">♀ Female</option>
<option value="M">♂ Male</option>
<option value="N">⚥ Diverse</option>
</select>
<select id="reh-cast-filter-lang" class="reh-cast-ctl" title="Filter by language">
<option value="">All languages</option>
<option>English</option><option>German</option><option>French</option>
<option>Spanish</option><option>Italian</option><option>Portuguese</option>
<option>Dutch</option><option>Polish</option><option>Auto</option>
</select>
<select id="reh-cast-sort" class="reh-cast-ctl" title="Sort characters by">
<option value="name">Sort: Name</option>
<option value="gender">Sort: Gender</option>
<option value="lang">Sort: Language</option>
<option value="lines">Sort: Lines</option>
<option value="tag">Sort: Tag</option>
</select>
<button type="button" id="reh-cast-sort-dir" class="reh-cast-ctl reh-cast-sortdir" title="Toggle ascending / descending" data-dir="asc"><span class="mdi mdi-sort-ascending"></span></button>
</div>
<div class="reh-view-toggle" id="reh-cast-view-toggle" role="tablist" aria-label="Cast view">
<button type="button" class="reh-view-btn active" data-view="card" title="Card view"><span class="mdi mdi-card-account-details-outline"></span> Cards</button>
<button type="button" class="reh-view-btn" data-view="list" title="List view"><span class="mdi mdi-format-list-bulleted"></span> List</button>
</div>
</div>
<div id="reh-cast-list" class="reh-cast-list reh-cast-view-card"></div>
<div class="btn-row" style="margin-top:14px"> <div class="btn-row" style="margin-top:14px">
<button class="btn-secondary" id="reh-back-1-btn"><span class="mdi mdi-arrow-left"></span> Back</button> <button class="btn-secondary" id="reh-back-1-btn"><span class="mdi mdi-arrow-left"></span> Back</button>
<button class="btn-secondary" id="reh-cast-save-btn"><span class="mdi mdi-content-save-outline"></span> Save to library</button>
<button class="btn-primary" id="reh-start-btn"><span class="mdi mdi-theater"></span> Open rehearsal</button> <button class="btn-primary" id="reh-start-btn"><span class="mdi mdi-theater"></span> Open rehearsal</button>
</div> </div>
</div> </div>
</div> </div>
<!-- ── Phase 3: Rehearsal — A4 script view ────────────────────── --> <!-- ── Phase 3: Rehearsal ─────────────────────────────────────── -->
<div class="reh-phase" id="reh-phase-3" hidden> <div class="reh-phase reh-stage-wrap" id="reh-phase-3" hidden style="position:relative">
<!-- Transport controls --> <!-- Director's console -->
<div class="reh-transport-bar" id="reh-transport-bar"> <div class="reh-console" id="reh-transport-bar">
<!-- Cast strip -->
<div class="reh-cast-strip" id="reh-cast-strip"></div> <div class="reh-cast-strip" id="reh-cast-strip"></div>
<!-- Transport buttons -->
<div class="reh-transport-btns"> <div class="reh-console-transport">
<button class="reh-tb-btn" id="reh-tb-prev" title="Previous line"><span class="mdi mdi-skip-previous"></span></button> <button class="reh-tb-btn" id="reh-tb-prev" title="Previous line"><span class="mdi mdi-skip-previous"></span></button>
<button class="reh-tb-btn reh-tb-play" id="reh-tb-play" title="Play all"><span class="mdi mdi-play"></span></button> <button class="reh-tb-btn reh-tb-play" id="reh-tb-play" title="Play all"><span class="mdi mdi-play"></span></button>
<button class="reh-tb-btn" id="reh-tb-stop" title="Stop &amp; reset"><span class="mdi mdi-stop"></span></button> <button class="reh-tb-btn" id="reh-tb-stop" title="Stop"><span class="mdi mdi-stop"></span></button>
<button class="reh-tb-btn" id="reh-tb-next" title="Next line"><span class="mdi mdi-skip-next"></span></button> <button class="reh-tb-btn" id="reh-tb-next" title="Next line"><span class="mdi mdi-skip-next"></span></button>
<button class="reh-tb-btn" id="reh-tb-repeat" title="Loop script"><span class="mdi mdi-repeat"></span></button> <button class="reh-tb-btn" id="reh-tb-repeat" title="Loop"><span class="mdi mdi-repeat"></span></button>
</div> </div>
<!-- Progress -->
<div class="reh-transport-progress"> <div class="reh-console-progress">
<div class="reh-tb-progress-wrap"><div class="reh-tb-progress" id="reh-tb-progress"></div></div> <div class="reh-tb-progress-wrap"><div class="reh-tb-progress" id="reh-tb-progress"></div></div>
<span class="reh-tb-label" id="reh-tb-label">0 / 0</span> <span class="reh-tb-label" id="reh-tb-label">0 / 0</span>
<span id="reh-practice-info" class="reh-practice-info" hidden>
<span style="color:#10b981">▶</span><span id="reh-practice-from">1</span>
<span style="color:#ef4444;margin-left:3px">■</span><span id="reh-practice-to">1</span>
<button class="reh-tb-btn" id="reh-practice-clear" style="width:18px;height:18px;font-size:9px" title="Clear practice range">✕</button>
</span>
</div> </div>
<div style="display:flex;gap:6px;margin-left:auto">
<button class="btn-secondary btn-sm" id="reh-tb-save" title="Save rehearsal to library"><span class="mdi mdi-content-save-outline"></span> Save</button> <div class="reh-console-actions">
<button class="btn-secondary btn-sm" id="reh-tb-export" title="Export to .reh file"><span class="mdi mdi-export"></span></button> <button class="btn-secondary btn-sm" id="reh-tb-synth-all" title="Pre-synthesize all TTS lines for instant playback">
<span class="mdi mdi-lightning-bolt"></span> Synth all
</button>
<button class="btn-secondary btn-sm" id="reh-tb-resynth-stale" title="Re-synthesize only lines whose tone changed" hidden>
<span class="mdi mdi-refresh" style="color:var(--red)"></span> Re-synth stale
</button>
<label class="chunk-toggle-label" style="font-size:11px;gap:4px;white-space:nowrap" title="Skip scene headings &amp; action descriptions during playback">
<input type="checkbox" id="reh-skip-desc-toggle" checked>
<span>Skip desc.</span>
</label>
<button class="btn-secondary btn-sm" id="reh-bulk-toggle" title="Bulk edit lines — select to ignore, hide or delete"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select</button>
<button class="btn-secondary btn-sm" id="reh-edit-script-btn" title="Edit script &amp; title"><span class="mdi mdi-pencil"></span></button>
<button class="btn-secondary btn-sm" id="reh-tb-save" title="Save to library"><span class="mdi mdi-content-save-outline"></span> Save</button>
<button class="btn-secondary btn-sm" id="reh-tb-export" title="Export .reh"><span class="mdi mdi-export"></span></button>
<button class="btn-secondary btn-sm" id="reh-fountain-btn" title="Export as .fountain (plain text)"><span class="mdi mdi-file-document-outline"></span></button>
<button class="btn-secondary btn-sm" id="reh-page-mode-btn" title="Switch between A4 pages / scroll / PDF pages"><span class="mdi mdi-file-document-outline"></span> A4 pages</button>
<button class="btn-secondary btn-sm" id="reh-train-open-btn" title="Train mode — practice your lines with cue playback &amp; speech recognition"><span class="mdi mdi-school-outline"></span> Train</button>
<button class="btn-secondary btn-sm" id="reh-exit-btn">Exit</button> <button class="btn-secondary btn-sm" id="reh-exit-btn">Exit</button>
</div> </div>
</div> </div>
<!-- Bulk-edit action bar (shown when "Select" mode is on) -->
<div class="reh-bulk-bar" id="reh-bulk-bar" hidden>
<span class="reh-bulk-count" id="reh-bulk-count">0 selected</span>
<button class="btn-secondary btn-sm" id="reh-bulk-all" title="Select all visible lines"><span class="mdi mdi-select-all"></span> All</button>
<button class="btn-secondary btn-sm" id="reh-bulk-none" title="Clear selection">Clear</button>
<span class="reh-bulk-sep"></span>
<button class="btn-secondary btn-sm" id="reh-bulk-ignore" title="Grey out &amp; skip these lines during playback/synth"><span class="mdi mdi-eye-off-outline"></span> Ignore</button>
<button class="btn-secondary btn-sm" id="reh-bulk-unignore" title="Restore ignored lines"><span class="mdi mdi-eye-outline"></span> Un-ignore</button>
<button class="btn-secondary btn-sm" id="reh-bulk-hide" title="Hide these lines from the script view"><span class="mdi mdi-minus-circle-outline"></span> Hide</button>
<button class="btn-secondary btn-sm" id="reh-bulk-delete" title="Delete these lines permanently"><span class="mdi mdi-trash-can-outline" style="color:var(--red)"></span> Delete</button>
<span class="reh-bulk-sep"></span>
<label class="chunk-toggle-label" style="font-size:11px;gap:4px;white-space:nowrap" title="Reveal hidden lines so you can restore them">
<input type="checkbox" id="reh-bulk-show-hidden">
<span>Show hidden</span>
</label>
<span class="reh-bulk-spacer"></span>
<button class="btn-secondary btn-sm" id="reh-bulk-done" title="Leave bulk-edit mode"><span class="mdi mdi-check"></span> Done</button>
</div>
<!-- Tone / style warning (shown when backend doesn't support style instructions) -->
<div class="reh-tone-warn" id="reh-tone-warn" hidden>
<span class="mdi mdi-alert-outline"></span>
<span id="reh-tone-warn-txt">The current TTS backend has weak style support — tone selections may have little or no effect.</span>
<button class="reh-tone-warn-close" id="reh-tone-warn-close" title="Dismiss">✕</button>
</div>
<!-- Synthesis progress bar -->
<div class="reh-synth-bar" id="reh-synth-bar" hidden>
<span class="reh-imsdb-spinner" style="flex-shrink:0"></span>
<span class="reh-synth-bar-lbl"><span class="mdi mdi-lightning-bolt"></span> Synthesizing</span>
<div class="reh-synth-track"><div class="reh-synth-fill" id="reh-synth-fill"></div></div>
<span id="reh-synth-label" class="reh-synth-label">0 / 0</span>
<button class="btn-secondary btn-sm" id="reh-synth-cancel">Cancel</button>
</div>
<!-- Stage -->
<div class="reh-stage-area">
<!-- A4 script page --> <!-- A4 script page -->
<div class="reh-page-wrap"> <div class="reh-page-wrap">
<div class="reh-a4-page" id="reh-a4-page"> <div class="reh-a4-page" id="reh-a4-page">
@ -103,54 +373,153 @@
<div class="reh-script-lines" id="reh-script-lines"></div> <div class="reh-script-lines" id="reh-script-lines"></div>
</div> </div>
</div> </div>
</div>
<!-- Recording overlay (visible when my turn) --> <!-- TTS now-playing bar -->
<div class="reh-tts-status-bar" id="reh-tts-status-bar" hidden>
<span class="mdi mdi-volume-high" style="font-size:16px;color:var(--accent)"></span>
<span id="reh-tts-status-txt" style="flex:1;font-size:13px"></span>
<audio id="reh-tts-audio" controls style="max-width:240px;height:28px"></audio>
</div>
<!-- My-turn recording panel -->
<div class="reh-rec-overlay" id="reh-rec-overlay" hidden> <div class="reh-rec-overlay" id="reh-rec-overlay" hidden>
<div class="reh-rec-overlay-inner"> <div class="reh-rec-inner">
<div class="reh-rec-cue" id="reh-rec-cue"></div> <div class="reh-rec-cue" id="reh-rec-cue"></div>
<div class="mic-monitor-box" style="margin-bottom:10px"> <div class="reh-rec-controls">
<div class="reh-rec-meter-col">
<div class="mic-monitor-head"> <div class="mic-monitor-head">
<span>Input level</span> <span>Input level</span>
<span class="meter-readout" id="reh-db-readout">-∞ dB</span> <span class="meter-readout" id="reh-db-readout">-∞ dB</span>
</div> </div>
<div class="mic-meter" id="reh-mic-meter" aria-hidden="true"></div> <div class="mic-meter" id="reh-mic-meter" aria-hidden="true"></div>
<canvas id="reh-live-wave" class="mic-live-wave" width="300" height="48" aria-hidden="true"></canvas> <canvas id="reh-live-wave" class="mic-live-wave" width="300" height="40" aria-hidden="true"></canvas>
</div> </div>
<div class="btn-row" style="gap:8px"> <div class="reh-rec-btn-col">
<div class="btn-row" style="gap:8px;flex-wrap:wrap">
<button class="btn-red" id="reh-rec-start"><span class="mdi mdi-record-circle-outline"></span> Record</button> <button class="btn-red" id="reh-rec-start"><span class="mdi mdi-record-circle-outline"></span> Record</button>
<button class="btn-secondary" id="reh-rec-stop" disabled><span class="mdi mdi-stop-circle-outline"></span> Stop</button> <button class="btn-secondary" id="reh-rec-stop" disabled><span class="mdi mdi-stop-circle-outline"></span> Stop</button>
<span class="mic-timer" id="reh-rec-time">0:00</span> <span class="mic-timer" id="reh-rec-time">0:00</span>
<button class="btn-secondary" id="reh-skip-line"><span class="mdi mdi-skip-next"></span> Skip</button> <button class="btn-secondary" id="reh-skip-line">Skip</button>
</div> </div>
<audio id="reh-rec-preview" controls style="display:none;margin-top:8px;width:100%"></audio> <audio id="reh-rec-preview" controls style="display:none;width:100%;margin-top:8px"></audio>
<div class="btn-row" style="margin-top:6px" id="reh-rec-confirm-row" hidden> <div class="btn-row" style="margin-top:8px" id="reh-rec-confirm-row" hidden>
<button class="btn-green" id="reh-rec-keep"><span class="mdi mdi-check"></span> Keep &amp; continue</button> <button class="btn-green" id="reh-rec-keep"><span class="mdi mdi-check"></span> Keep &amp; continue</button>
<button class="btn-secondary" id="reh-rec-redo"><span class="mdi mdi-refresh"></span> Re-record</button> <button class="btn-secondary" id="reh-rec-redo"><span class="mdi mdi-refresh"></span> Re-record</button>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- TTS playback status (slim bar below transport) --> <!-- ── Train Mode panel ─────────────────────────────────────── -->
<div class="reh-tts-status-bar" id="reh-tts-status-bar" hidden> <div class="reh-train-panel" id="reh-train-panel" hidden>
<span id="reh-tts-status-txt"></span> <div class="reh-train-header">
<audio id="reh-tts-audio" controls style="flex:1;min-width:0;max-width:320px"></audio> <span class="reh-train-badge"><span class="mdi mdi-school-outline"></span> TRAIN</span>
<span id="reh-train-progress" class="reh-train-progress">Turn 1 / 1</span>
<div style="flex:1"></div>
<button class="btn-secondary btn-sm" id="reh-train-exit-btn"><span class="mdi mdi-close"></span> Exit train mode</button>
</div>
<!-- Cue card: what the other character says -->
<div class="reh-train-cue card" id="reh-train-cue">
<div class="reh-train-cue-head">
<span id="reh-train-cue-avatar" class="reh-train-cue-avatar"></span>
<span id="reh-train-cue-speaker" class="reh-train-cue-speaker"></span>
<span class="reh-train-cue-tag">Cue</span>
<span id="reh-train-cue-state" class="reh-train-cue-state"></span>
</div>
<div id="reh-train-cue-text" class="reh-train-cue-text"></div>
</div>
<!-- My line card -->
<div class="reh-train-myline card">
<div class="reh-train-myline-head">
<span class="mdi mdi-microphone" style="color:var(--accent)"></span>
<span id="reh-train-my-speaker" class="reh-train-cue-speaker"></span>
<span class="reh-train-cue-tag reh-train-me-tag">Your line</span>
</div>
<div id="reh-train-my-text" class="reh-train-my-text"></div>
</div>
<!-- Recording area -->
<div class="reh-train-rec card" id="reh-train-rec">
<div class="reh-train-rec-head">
<span id="reh-train-rec-state" class="reh-train-rec-state">Read the lines above · press ▶ to hear the cue, then 🎤 to record</span>
<span class="reh-train-rec-timer" id="reh-train-rec-timer" hidden>0:00</span>
</div>
<div class="reh-train-meters" id="reh-train-meters" hidden>
<div class="mic-monitor-head"><span>Input level</span><span class="meter-readout" id="reh-train-db">-∞ dB</span></div>
<div class="mic-meter" id="reh-train-meter"></div>
<canvas id="reh-train-wave" class="mic-live-wave" width="300" height="36"></canvas>
</div>
<audio id="reh-train-audio-preview" style="display:none;width:100%;margin-top:8px" controls></audio>
</div>
<!-- Comparison result -->
<div class="reh-train-result card" id="reh-train-result" hidden>
<div class="reh-train-result-head">
<span class="mdi mdi-compare-horizontal"></span> Result
<span id="reh-train-score" class="reh-train-score"></span>
</div>
<div id="reh-train-expected" class="reh-train-compare-row">
<span class="reh-train-compare-lbl">Script:</span>
<span id="reh-train-expected-text" class="reh-train-compare-text"></span>
</div>
<div id="reh-train-actual" class="reh-train-compare-row">
<span class="reh-train-compare-lbl">You said:</span>
<span id="reh-train-actual-text" class="reh-train-compare-text"></span>
</div>
</div>
<!-- Controls -->
<div class="reh-train-controls">
<button class="reh-tb-btn" id="reh-train-prev" title="Previous turn"><span class="mdi mdi-skip-previous"></span></button>
<button class="reh-tb-btn" id="reh-train-play" title="Play cue"><span class="mdi mdi-play"></span></button>
<button class="reh-tb-btn reh-train-rec-btn" id="reh-train-record" title="Start recording" disabled><span class="mdi mdi-microphone"></span></button>
<button class="reh-tb-btn" id="reh-train-stop-rec" title="Stop recording" disabled hidden><span class="mdi mdi-stop-circle-outline"></span></button>
<button class="reh-tb-btn" id="reh-train-next" title="Next turn"><span class="mdi mdi-skip-next"></span></button>
<button class="reh-tb-btn" id="reh-train-repeat" title="Repeat cue"><span class="mdi mdi-repeat"></span></button>
</div>
</div> </div>
</div><!-- /phase-3 --> </div><!-- /phase-3 -->
<!-- ── Phase 4: Session summary ────────────────────────────────── --> <!-- ── Phase 4: Summary ───────────────────────────────────────── -->
<div class="reh-phase" id="reh-phase-4" hidden> <div class="reh-phase" id="reh-phase-4" hidden>
<div class="card"> <div class="card">
<h2><span class="mdi mdi-check-circle-outline"></span> Rehearsal complete</h2> <h2><span class="mdi mdi-check-circle-outline"></span> Session complete</h2>
<p class="card-subtitle">All recorded and synthesized clips from this session.</p> <p class="card-subtitle">All recorded and synthesized clips from this session.</p>
<div id="reh-summary-list" class="reh-summary-list"></div> <div id="reh-summary-list" class="reh-summary-list"></div>
<div class="btn-row" style="margin-top:14px"> <div class="btn-row" style="margin-top:14px;flex-wrap:wrap">
<button class="btn-primary" id="reh-save-session-btn"><span class="mdi mdi-content-save"></span> Save to library</button> <button class="btn-primary" id="reh-save-session-btn"><span class="mdi mdi-content-save"></span> Save to library</button>
<button class="btn-secondary" id="reh-export-session-btn"><span class="mdi mdi-export"></span> Export .reh</button> <button class="btn-secondary" id="reh-export-session-btn" title="Export full session with audio"><span class="mdi mdi-export"></span> .reh</button>
<button class="btn-secondary" id="reh-fountain-export-p4" title="Export script as .fountain text"><span class="mdi mdi-fountain-pen-tip"></span> .fountain</button>
<button class="btn-secondary" id="reh-fdx-export-btn" title="Export as Final Draft XML"><span class="mdi mdi-file-code-outline"></span> .fdx</button>
<button class="btn-secondary" id="reh-osf-export-btn" title="Export as Open Screenplay Format XML"><span class="mdi mdi-xml"></span> .osf</button>
<button class="btn-secondary" id="reh-resume-btn"><span class="mdi mdi-theater"></span> Resume</button> <button class="btn-secondary" id="reh-resume-btn"><span class="mdi mdi-theater"></span> Resume</button>
<button class="btn-secondary" id="reh-new-session-btn"><span class="mdi mdi-refresh"></span> New script</button> <button class="btn-secondary" id="reh-new-session-btn"><span class="mdi mdi-refresh"></span> New script</button>
</div> </div>
</div> </div>
</div> </div>
<!-- ── IMSDb browser modal (global, accessible from Library + Import/Export) ── -->
<div class="reh-imsdb-modal" id="reh-imsdb-modal" hidden>
<div class="reh-imsdb-box">
<div class="reh-imsdb-head">
<h3><span class="mdi mdi-movie-open-outline"></span> IMSDb Script Library</h3>
<input type="text" id="reh-imsdb-search" class="reh-imsdb-search" placeholder="Search 1300+ movie scripts…" spellcheck="false" autocomplete="off">
<span id="reh-imsdb-count" class="reh-imsdb-count"></span>
<div class="reh-imsdb-view-btns">
<button class="reh-imsdb-view-btn active" id="reh-imsdb-cover-btn" title="Cover view"><span class="mdi mdi-view-grid-outline"></span></button>
<button class="reh-imsdb-view-btn" id="reh-imsdb-list-btn" title="List view"><span class="mdi mdi-view-list-outline"></span></button>
</div>
<button class="btn-secondary btn-sm" id="reh-imsdb-close"><span class="mdi mdi-close"></span></button>
</div>
<div class="reh-imsdb-grid" id="reh-imsdb-grid">
<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>
</div>
</div>
</div>
</div><!-- /tab-rehearser --> </div><!-- /tab-rehearser -->

View File

@ -150,7 +150,7 @@
<label>Groq API key <span class="s-label-note">(Groq Whisper &amp; LLM)</span></label> <label>Groq API key <span class="s-label-note">(Groq Whisper &amp; LLM)</span></label>
<div class="s-key-row"> <div class="s-key-row">
<input type="password" id="s-groq-api-key" placeholder="gsk_&hellip;" autocomplete="off"> <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"><span class="mdi mdi-eye-outline"></span></button> <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> </div>
<span class="s-hint">Used for Groq Whisper STT and LLM. Free: 2 000 req/day.</span> <span class="s-hint">Used for Groq Whisper STT and LLM. Free: 2 000 req/day.</span>
</div> </div>

View File

@ -6,6 +6,13 @@
</div> </div>
</div> </div>
<!-- Source selector — show one library at a time to keep things tidy -->
<div class="gvo-tabs" id="gvo-tabs" role="tablist">
<button class="gvo-tab active" data-src="direct" role="tab"><span class="mdi mdi-link-variant"></span> Direct sources <span class="gvo-tab-sub">scrape URLs</span></button>
<button class="gvo-tab" data-src="fish" role="tab"><span class="mdi mdi-fishbowl-outline"></span> Fish.audio <span class="gvo-tab-sub">2M+ clonable</span></button>
<button class="gvo-tab" data-src="eleven" role="tab"><span class="gvo-eleven-logo">11</span> ElevenLabs <span class="gvo-tab-sub">12k shared</span></button>
</div>
<div class="tab-content" id="tab-getvoices"> <div class="tab-content" id="tab-getvoices">
<div class="card"> <div class="card">
<h2>Get voices</h2> <h2>Get voices</h2>
@ -20,10 +27,10 @@
</div> </div>
<div class="getvoices-toolbar"> <div class="getvoices-toolbar">
<input type="search" id="getvoices-search" placeholder="Search voices, languages, datasets..." autocomplete="off"> <input type="search" id="getvoices-search" placeholder="Search voices, languages, datasets..." autocomplete="off">
<select id="getvoices-source-filter"><option value="all">Source: all</option></select> <select id="getvoices-source-filter" aria-label="Filter by source"><option value="all">Source: all</option></select>
<select id="getvoices-language-filter"><option value="all">Language: all</option></select> <select id="getvoices-language-filter" aria-label="Filter by language"><option value="all">Language: all</option></select>
<select id="getvoices-gender-filter"><option value="all">Sex: all</option></select> <select id="getvoices-gender-filter" aria-label="Filter by sex"><option value="all">Sex: all</option></select>
<select id="getvoices-filetype-filter"><option value="all">Filetype: all</option></select> <select id="getvoices-filetype-filter" aria-label="Filter by file type"><option value="all">Filetype: all</option></select>
<label style="font-size:14px;color:var(--subtext);display:flex;align-items:center;gap:7px;cursor:pointer;white-space:nowrap"> <label style="font-size:14px;color:var(--subtext);display:flex;align-items:center;gap:7px;cursor:pointer;white-space:nowrap">
<input type="checkbox" id="getvoices-direct-only"> Direct audio only <input type="checkbox" id="getvoices-direct-only"> Direct audio only
</label> </label>
@ -138,3 +145,101 @@
</div> </div>
</div><!-- /el-browser-card --> </div><!-- /el-browser-card -->
<!-- ── Fish.audio public voice library ──────────────────────────────── -->
<div class="card el-browser" id="fa-browser-card">
<div class="el-head">
<div class="el-brand">
<span class="el-brand-logo" style="background:#0ea5e9">🐟</span>
<div>
<h2>Fish.audio Voice Library</h2>
<p class="note">Browse 2 million+ public voices and import a clonable sample (MP3 + transcript) straight into your library. <a href="https://fish.audio/" target="_blank" rel="noopener">fish.audio ↗</a></p>
</div>
</div>
</div>
<div class="fa-toolbar">
<div class="fa-search-wrap">
<span class="mdi mdi-magnify fa-search-icon"></span>
<input type="search" class="fa-search-big" id="fa-search" placeholder="Search voices&hellip;" autocomplete="off">
</div>
<select id="fa-lang" class="fa-pill-select" title="Language">
<option value="">🌐 All languages</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><option value="ko">🇰🇷 Korean</option>
<option value="ru">🇷🇺 Russian</option><option value="ar">🇸🇦 Arabic</option>
</select>
<select id="fa-sort" class="fa-pill-select" title="Sort by">
<option value="score">Recommended</option>
<option value="task_count">Most used</option>
<option value="created_at">Newest</option>
</select>
<div class="fa-filter-anchor">
<button class="fa-filter-btn" id="fa-filter-btn" title="Filters"><span class="mdi mdi-tune-variant"></span><span class="fa-filter-badge" id="fa-filter-badge" hidden>0</span></button>
<div class="fa-filter-pop" id="fa-filter-pop" hidden>
<div class="fa-pop-head"><strong>Filter</strong><button class="fa-pop-reset" id="fa-filter-reset"><span class="mdi mdi-restore"></span> Reset</button><button class="fa-pop-close" id="fa-filter-close" title="Close"><span class="mdi mdi-close"></span></button></div>
<div class="fa-pop-group">
<label>Gender</label>
<div class="fa-pchips" data-group="gender">
<button class="fa-pchip active" data-val="">Any</button>
<button class="fa-pchip" data-val="male">Male</button>
<button class="fa-pchip" data-val="female">Female</button>
<button class="fa-pchip" data-val="neutral">Neutral</button>
</div>
</div>
<div class="fa-pop-group">
<label>Age</label>
<div class="fa-pchips" data-group="age">
<button class="fa-pchip active" data-val="">Any</button>
<button class="fa-pchip" data-val="young">Young</button>
<button class="fa-pchip" data-val="middle-aged">Middle&nbsp;aged</button>
<button class="fa-pchip" data-val="old">Old</button>
</div>
</div>
<div class="fa-pop-group">
<label>Use case</label>
<div class="fa-pchips fa-pchips-multi" data-group="tag">
<button class="fa-pchip" data-val="conversational">Conversational</button>
<button class="fa-pchip" data-val="narration">Narration</button>
<button class="fa-pchip" data-val="character-voice">Character</button>
<button class="fa-pchip" data-val="social-media">Social media</button>
<button class="fa-pchip" data-val="education">Education</button>
<button class="fa-pchip" data-val="advertising">Advertising</button>
<button class="fa-pchip" data-val="audiobook">Audiobook</button>
<button class="fa-pchip" data-val="gaming">Gaming</button>
</div>
</div>
<div class="fa-pop-group">
<label>Voice quality</label>
<div class="fa-pchips fa-pchips-multi" data-group="tag">
<button class="fa-pchip" data-val="deep">Deep</button>
<button class="fa-pchip" data-val="high">High</button>
<button class="fa-pchip" data-val="soft">Soft</button>
<button class="fa-pchip" data-val="bright">Bright</button>
<button class="fa-pchip" data-val="warm">Warm</button>
<button class="fa-pchip" data-val="calm">Calm</button>
<button class="fa-pchip" data-val="energetic">Energetic</button>
<button class="fa-pchip" data-val="raspy">Raspy</button>
</div>
</div>
<div class="fa-pop-group">
<label>Custom tag</label>
<input type="text" id="fa-tag" class="fa-pop-tag" placeholder="e.g. announcer, whisper&hellip;" autocomplete="off">
</div>
<button class="btn-primary fa-pop-apply" id="fa-filter-apply">Apply filters</button>
</div>
</div>
<button class="btn-primary fa-browse-btn" id="fa-fetch">Browse</button>
</div>
<div class="el-status-bar" id="fa-status-bar" hidden><span id="fa-status-text"></span></div>
<div class="el-grid" id="fa-grid">
<div class="el-idle">Search or filter, then click <strong>Browse</strong> to load voices from fish.audio.</div>
</div>
<div class="el-pager" id="fa-pager" hidden>
<button class="btn-secondary" id="fa-prev" disabled>&larr; Prev</button>
<span class="el-pager-info" id="fa-pager-info">Page 1</span>
<button class="btn-secondary" id="fa-next">Next &rarr;</button>
</div>
</div><!-- /fa-browser-card -->

View File

@ -13,20 +13,20 @@
<div class="card"> <div class="card">
<h2><span class="mdi mdi-account-voice"></span> Voice &amp; backend</h2> <h2><span class="mdi mdi-account-voice"></span> Voice &amp; backend</h2>
<div class="tryout-voice-row"> <div class="tryout-voice-row">
<div class="field"> <div class="field" style="min-width:0">
<label>Backend</label> <label>Backend</label>
<select id="tts-backend-select"><option value="">Checking backends…</option></select> <select id="tts-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
</div> </div>
<div class="field"> <div class="field" style="min-width:0">
<label>Voice</label> <label>Voice</label>
<div style="display:flex;gap:8px"> <div class="tryout-voice-pick">
<button class="btn-secondary" id="fetch-tts-voices-btn">Fetch voices</button> <select id="tts-voice-select" aria-label="Voice"><option value="">— fetch or pick a voice —</option></select>
<select id="tts-voice-select"><option value="">— select after fetch —</option></select> <button class="btn-secondary btn-sm" id="fetch-tts-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
</div> </div>
</div> </div>
<div class="field"> <div class="field" style="min-width:0">
<label>Playback</label> <label>Playback</label>
<select id="preview-playback-mode"> <select id="preview-playback-mode" aria-label="Playback mode">
<option value="settings">Settings default</option> <option value="settings">Settings default</option>
<option value="streaming">Streaming only</option> <option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option> <option value="auto">Stream with fallback</option>
@ -106,7 +106,7 @@
<p class="card-subtitle">Apply post-processing to the last generated audio. Non-destructive — re-generate to reset.</p> <p class="card-subtitle">Apply post-processing to the last generated audio. Non-destructive — re-generate to reset.</p>
<div class="effects-preset-row"> <div class="effects-preset-row">
<label class="field-label-sm">Preset</label> <label class="field-label-sm">Preset</label>
<select id="effects-preset"> <select id="effects-preset" aria-label="Effects preset">
<option value="">— Custom —</option> <option value="">— Custom —</option>
<option value="studio">Studio Reverb</option> <option value="studio">Studio Reverb</option>
<option value="broadcast">Broadcast (compressed)</option> <option value="broadcast">Broadcast (compressed)</option>
@ -191,7 +191,7 @@
<div class="s-field"> <div class="s-field">
<label>Speech recognition</label> <label>Speech recognition</label>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<select id="stt-tts-stt-backend"><option value="configured">Configured Whisper/STT</option></select> <select id="stt-tts-stt-backend" aria-label="Speech-to-text engine"><option value="configured">Configured Whisper/STT</option></select>
<button class="btn-secondary" id="stt-tts-refresh-stt-btn" type="button">Refresh</button> <button class="btn-secondary" id="stt-tts-refresh-stt-btn" type="button">Refresh</button>
</div> </div>
<div class="s-hint" id="stt-tts-stt-help">Uses Settings &rarr; Whisper/STT URL by default.</div> <div class="s-hint" id="stt-tts-stt-help">Uses Settings &rarr; Whisper/STT URL by default.</div>
@ -199,7 +199,7 @@
<div class="s-field"> <div class="s-field">
<label>Speech audio</label> <label>Speech audio</label>
<div class="btn-row" style="gap:8px;align-items:center"> <div class="btn-row" style="gap:8px;align-items:center">
<input type="file" id="stt-tts-file" accept="audio/*,video/*"> <input type="file" id="stt-tts-file" aria-label="Audio file to transcribe" accept="audio/*,video/*">
<button class="btn-secondary" id="stt-tts-rec-start" type="button" title="Record from microphone"><span class="mdi mdi-microphone-variant"></span> Record</button> <button class="btn-secondary" id="stt-tts-rec-start" type="button" title="Record from microphone"><span class="mdi mdi-microphone-variant"></span> Record</button>
<button class="btn-secondary" id="stt-tts-rec-stop" type="button" disabled>Stop</button> <button class="btn-secondary" id="stt-tts-rec-stop" type="button" disabled>Stop</button>
<span class="mic-timer" id="stt-tts-rec-time">0:00</span> <span class="mic-timer" id="stt-tts-rec-time">0:00</span>
@ -255,7 +255,7 @@
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px"> <div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field"> <div class="field">
<label>Backend</label> <label>Backend</label>
<select id="stt-tts-backend-select"><option value="">Checking backends...</option></select> <select id="stt-tts-backend-select" aria-label="TTS backend"><option value="">Checking backends...</option></select>
</div> </div>
<div class="backend-help" id="stt-tts-backend-help" aria-live="polite"> <div class="backend-help" id="stt-tts-backend-help" aria-live="polite">
<strong>Checking available TTS backends...</strong> <strong>Checking available TTS backends...</strong>
@ -264,12 +264,12 @@
<label>Backend voice</label> <label>Backend voice</label>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn-secondary" id="stt-tts-fetch-voices-btn">Fetch voices</button> <button class="btn-secondary" id="stt-tts-fetch-voices-btn">Fetch voices</button>
<select id="stt-tts-voice-select"><option value="">-- select after fetch --</option></select> <select id="stt-tts-voice-select" aria-label="Voice"><option value="">-- select after fetch --</option></select>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label>Playback</label> <label>Playback</label>
<select id="stt-tts-playback-mode"> <select id="stt-tts-playback-mode" aria-label="Playback mode">
<option value="settings">Settings default</option> <option value="settings">Settings default</option>
<option value="streaming">Streaming only</option> <option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option> <option value="auto">Stream with fallback</option>

View File

@ -50,12 +50,12 @@
<!-- Card 2: filters + sort + voice list + toolbar --> <!-- Card 2: filters + sort + voice list + toolbar -->
<div class="vl-card vl-card-list"> <div class="vl-card vl-card-list">
<div class="vl-filters"> <div class="vl-filters">
<input type="search" id="library-filter-text" placeholder="Search voices&#x2026;" autocomplete="off" class="vl-search">
<select id="library-filter-lang" title="Language"><option value="">All</option></select> <select id="library-filter-lang" title="Language"><option value="">All</option></select>
<select id="library-filter-type" title="Type"><option value="">All types</option></select> <select id="library-filter-type" title="Type"><option value="">All types</option></select>
<label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label> <label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label>
</div> </div>
<div class="vl-sort-bar"> <div class="vl-sort-bar">
<input type="search" id="library-filter-text" placeholder="&#x1F50D; Search voices&#x2026;" autocomplete="off" class="vl-search vl-search-main">
<span class="vl-sort-label">Sort</span> <span class="vl-sort-label">Sort</span>
<select id="voice-sort-field" title="Sort field"> <select id="voice-sort-field" title="Sort field">
<option value="id">Name</option> <option value="id">Name</option>
@ -68,6 +68,8 @@
<option value="enabled">Active</option> <option value="enabled">Active</option>
</select> </select>
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button> <button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button>
<button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button>
</div> </div>
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite"> <div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
@ -77,7 +79,23 @@
<button class="benchmark-confirm-start" id="benchmark-confirm-start" type="button">Start benchmark</button> <button class="benchmark-confirm-start" id="benchmark-confirm-start" type="button">Start benchmark</button>
</div> </div>
<div id="voice-list"></div> <div id="voice-groups-bar" class="voice-groups-bar" hidden></div>
<!-- Bulk-edit toolbar — appears when voices are selected -->
<div class="vl-bulk-bar" id="vl-bulk-bar" hidden>
<span class="vl-bulk-count" id="vl-bulk-count">0 selected</span>
<button class="vl-bulk-btn" id="vl-bulk-select-all" title="Select all visible"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> All</button>
<button class="vl-bulk-btn" id="vl-bulk-deselect" title="Clear selection"><span class="mdi mdi-checkbox-multiple-blank-outline"></span> None</button>
<div class="vl-bulk-sep"></div>
<button class="vl-bulk-btn" id="vl-bulk-tag" title="Add / replace tag on selected voices"><span class="mdi mdi-tag-outline"></span> Set tag</button>
<button class="vl-bulk-btn" id="vl-bulk-hide" title="Disable (hide) selected voices"><span class="mdi mdi-eye-off-outline"></span> Hide</button>
<button class="vl-bulk-btn" id="vl-bulk-unhide" title="Enable (unhide) selected voices"><span class="mdi mdi-eye-outline"></span> Unhide</button>
<button class="vl-bulk-btn" id="vl-bulk-rating" title="Set star rating on selected"><span class="mdi mdi-star-outline"></span> Rate</button>
<div class="vl-bulk-sep"></div>
<button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button>
</div>
<div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div>
<div class="vl-toolbar"> <div class="vl-toolbar">
<button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button> <button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button>
<button class="btn-secondary vl-tb-btn" id="sync-voice-folders-btn" title="Sync active/hidden folders">Sync</button> <button class="btn-secondary vl-tb-btn" id="sync-voice-folders-btn" title="Sync active/hidden folders">Sync</button>

File diff suppressed because it is too large Load Diff

38
static/sw.js Normal file
View File

@ -0,0 +1,38 @@
/* TTS Voice Creator — network-first service worker.
Always tries the network first (so live edits/new versions show immediately) and
only falls back to cache when offline. Never touches the API or cross-origin
requests, so it can't serve stale data or break audio/streaming. */
const CACHE = 'tts-vc-v1';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // ignore CDN / cross-origin
if (url.pathname.startsWith('/api/')) return; // never cache the API
e.respondWith((async () => {
try {
const net = await fetch(req);
// Cache only successful static shell assets for offline fallback
if (net && net.ok && (url.pathname === '/' || url.pathname.startsWith('/static/'))) {
const c = await caches.open(CACHE);
c.put(req, net.clone());
}
return net;
} catch (err) {
const cached = await caches.match(req);
if (cached) return cached;
throw err;
}
})());
});

34
tests/bundle.spec.js Normal file
View File

@ -0,0 +1,34 @@
// @ts-check
// Verifies the opt-in production bundle (npm run minify → static/dist/main.min.js):
// the app must load and behave identically through the single-bundle path.
const { test, expect } = require('@playwright/test');
// Block the service worker so request visibility is deterministic (the SW can serve
// cached responses without a network request, hiding them from page.on('request')).
test.use({ serviceWorkers: 'block' });
test('app works through the production bundle (?bundle=1)', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
let bundleRequested = false;
page.on('request', (r) => { if (r.url().includes('/static/dist/main.min.js')) bundleRequested = true; });
await page.addInitScript(() => { window.APP_USE_BUNDLE = true; });
await page.goto('/?bundle=1', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#sidebar')).toBeVisible();
// Wait for the JS pipeline to finish (navTo is defined by nav.js, loaded AFTER the
// feature bundle) — only then is it valid to check what was loaded.
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
// The bundle (not the individual feature files) must have served the modules
expect(bundleRequested).toBeTruthy();
// Global API from a bundled module must still be reachable (names are preserved)
await page.evaluate(() => window.navTo('s-clone'));
await expect(page.getByText(/Clone a Voice/i).first()).toBeVisible({ timeout: 10_000 });
const real = errors.filter(e => /ReferenceError|TypeError|SyntaxError|is not defined|is not a function/.test(e));
expect(real, real.join('\n')).toHaveLength(0);
});

21
tests/i18n.spec.js Normal file
View File

@ -0,0 +1,21 @@
// @ts-check
// Verifies the language picker + German translation of the UI chrome.
const { test, expect } = require('@playwright/test');
test('defaults to English and shows the language picker', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#app-lang-picker')).toBeVisible();
await expect(page.locator('.nav-item', { hasText: 'Clone a Voice' }).first()).toBeVisible();
});
test('German translates the navigation and section titles', async ({ page }) => {
await page.addInitScript(() => { try { localStorage.setItem('app-lang', 'de'); } catch (e) {} });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.applyI18n === 'function', null, { timeout: 15_000 });
await page.waitForTimeout(1500);
await expect(page.locator('#app-lang-picker')).toHaveValue('de');
await expect(page.locator('.nav-item', { hasText: 'Stimme klonen' }).first()).toBeVisible();
await expect(page.getByText('Meine Stimmen').first()).toBeVisible();
// English source text should no longer be present in the nav
await expect(page.locator('.nav-item', { hasText: 'Get Voices Online' })).toHaveCount(0);
});

52
tests/rehearser.spec.js Normal file
View File

@ -0,0 +1,52 @@
// @ts-check
// Functional safety net for the Script Rehearser — guards the parser/cast pipeline
// while rehearser.js is split into smaller modules (#8).
const { test, expect } = require('@playwright/test');
const SCRIPT = `FADE IN
EXT. HARBOR - NIGHT
A ship rocks in the dark.
JACK
We need to find the compass.
GIBBS
Aye, Captain. But the tide is against us.
JACK
Then we sail against the tide.`;
async function navTo(page, section) {
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate((s) => window.navTo(s), section);
}
test('parses a screenplay and builds the cast', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await navTo(page, 's-rehearser');
// Go to the Cast tab area: paste a script and parse
await page.waitForSelector('#reh-script-text', { timeout: 10_000 });
await page.fill('#reh-script-text', SCRIPT);
await page.click('#reh-parse-btn');
// Cast cards should appear: Narrator + JACK + GIBBS (title page / FADE IN filtered out)
const cards = page.locator('#reh-cast-list .reh-cast-card');
await expect(cards).toHaveCount(3, { timeout: 10_000 });
await expect(page.locator('#reh-cast-list')).toContainText('JACK');
await expect(page.locator('#reh-cast-list')).toContainText('GIBBS');
// Scene heading / FADE IN must NOT have become characters
await expect(page.locator('#reh-cast-list')).not.toContainText('FADE IN');
await expect(page.locator('#reh-cast-list')).not.toContainText('HARBOR');
// A "Hear a line" sample button appears once a character has a voice assigned
await expect(page.locator('.reh-cc-sample-btn')).toHaveCount(0);
await page.evaluate(() => {
rehState.cast['JACK'].voice = 'EN_Test_Voice';
renderCastList();
});
await expect(page.locator('.reh-cc-sample-btn').first()).toBeVisible();
await expect(page.locator('.reh-cc-sample-btn').first()).toContainText('Hear a line');
});

61
tests/smoke.spec.js Normal file
View File

@ -0,0 +1,61 @@
// @ts-check
const { test, expect } = require('@playwright/test');
// Console/page errors that are environmental (offline backends, missing engines) and
// must NOT fail the smoke test. Real JS exceptions (ReferenceError, etc.) still fail.
const IGNORE = [/Failed to load resource/i, /favicon/i, /net::ERR/i, /AbortError/i];
function watchForErrors(page) {
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => { if (m.type() === 'error' && !IGNORE.some(r => r.test(m.text()))) errors.push(m.text()); });
return errors;
}
test('app shell loads without JS errors', async ({ page }) => {
const errors = watchForErrors(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#sidebar')).toBeVisible();
await expect(page.locator('.nav-item').first()).toBeVisible();
// Let dynamic sections + module scripts settle
await page.waitForTimeout(1500);
const real = errors.filter(e => /ReferenceError|TypeError|SyntaxError|is not defined|is not a function/.test(e));
expect(real, real.join('\n')).toHaveLength(0);
});
// Navigate via the app's global router (sidebar is hidden behind a menu on mobile,
// so clicking sidebar pixels is unreliable across viewports).
async function navTo(page, section) {
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate((s) => window.navTo(s), section);
}
test('core sections render', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const sections = [
['s-clone', /Clone a Voice/i],
['s-studio', /Get Voices Online/i],
['s-rehearser', /Script Rehearser/i],
];
for (const [nav, heading] of sections) {
await navTo(page, nav);
await expect(page.getByText(heading).first()).toBeVisible({ timeout: 10_000 });
}
});
test('clone tabs switch and show one panel at a time', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await navTo(page, 's-clone');
await expect(page.locator('.clone-src-tab[data-src="url"]')).toBeVisible({ timeout: 10_000 });
await page.locator('.clone-src-tab[data-src="url"]').click();
await expect(page.locator('#clone-src-url')).toBeVisible();
await expect(page.locator('#clone-src-mic')).toBeHidden();
});
test('PWA manifest and service worker are served', async ({ page, request }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const mani = await request.get('/manifest.webmanifest');
expect(mani.ok()).toBeTruthy();
const sw = await request.get('/sw.js');
expect(sw.ok()).toBeTruthy();
});