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:
parent
b64baf2d0b
commit
40e42590cc
15
.gitignore
vendored
15
.gitignore
vendored
@ -38,3 +38,18 @@ config/voice_design_presets.json
|
||||
config/tts_routes.json
|
||||
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
|
||||
|
||||
75
CHANGELOG.md
75
CHANGELOG.md
@ -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
|
||||
|
||||
### Added
|
||||
|
||||
@ -23,6 +23,7 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
# ── Application ───────────────────────────────────────────────────────────────
|
||||
WORKDIR /app
|
||||
COPY server.py .
|
||||
COPY CHANGELOG.md .
|
||||
COPY core/ core/
|
||||
COPY routes/ routes/
|
||||
COPY static/ static/
|
||||
|
||||
@ -15,7 +15,7 @@ from core.constants import (
|
||||
_NVIDIA_ROUTER_DEFAULT, _NVIDIA_TTS_DEFAULT, _NVIDIA_ASR_DEFAULT,
|
||||
_NVIDIA_CLONE_DEFAULT, _NVIDIA_ZEROSHOT_DEFAULT, _NVIDIA_FLOW_DEFAULT,
|
||||
_FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT,
|
||||
_KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT,
|
||||
_KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT,
|
||||
_TTS_STREAM_DEFAULT,
|
||||
)
|
||||
|
||||
@ -35,7 +35,7 @@ _SETTINGS_KEYS = {
|
||||
"refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation",
|
||||
"captures_default_voice",
|
||||
"client_voice_bindings",
|
||||
"llm_url",
|
||||
"llm_url", "llm_model",
|
||||
# Browser-persistent UI state
|
||||
"engine_local_urls", "engine_container_names", "custom_engine_cards",
|
||||
"refine_llm_url", "conv_llm_url",
|
||||
@ -120,9 +120,13 @@ def _clean_preview_backend(value: str) -> str:
|
||||
"xtts_v2": "xtts",
|
||||
"xtts2": "xtts",
|
||||
"coqui_xtts": "xtts",
|
||||
"fish": "fishspeech",
|
||||
"fish_speech": "fishspeech",
|
||||
"fishaudio": "fishspeech",
|
||||
"openaudio": "fishspeech",
|
||||
}
|
||||
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:
|
||||
@ -188,6 +192,8 @@ def _preview_backend_base_url(settings: dict, backend: str) -> str:
|
||||
return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT
|
||||
if backend == "xtts":
|
||||
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
|
||||
|
||||
|
||||
@ -242,6 +248,7 @@ def _load_settings() -> dict:
|
||||
"kokoro_url": _KOKORO_DEFAULT,
|
||||
"vibevoice_url": _VIBEVOICE_DEFAULT,
|
||||
"xtts_url": _XTTS_DEFAULT,
|
||||
"fishspeech_url": _FISHSPEECH_DEFAULT,
|
||||
"whisper_api_key": "",
|
||||
"tts_api_key": "",
|
||||
"voice_design_api_key": "",
|
||||
@ -260,6 +267,7 @@ def _load_settings() -> dict:
|
||||
"captures_default_voice": "",
|
||||
"client_voice_bindings": {},
|
||||
"llm_url": "http://localhost:11434/v1",
|
||||
"llm_model": "",
|
||||
"engine_local_urls": {},
|
||||
"engine_container_names": {},
|
||||
"custom_engine_cards": [],
|
||||
|
||||
@ -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")
|
||||
_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")
|
||||
_FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://host.docker.internal:38080")
|
||||
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
|
||||
_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")
|
||||
|
||||
@ -26,13 +26,28 @@ from core.audio import _to_wav_24k, _duration as _dur
|
||||
from core.voice import _find_voice_audio, _read_reference_text
|
||||
|
||||
|
||||
# ── NVIDIA language codes ─────────────────────────────────────────────────────
|
||||
# ── Language helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
_NVIDIA_LANGUAGE_CODES = {
|
||||
"EN": "en-US", "DE": "de-DE", "ES": "es-ES", "FR": "fr-FR",
|
||||
"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:
|
||||
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}
|
||||
if 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
|
||||
|
||||
|
||||
@ -320,6 +343,66 @@ def _nvidia_clone_request_audio(
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
def _vibevoice_request_audio(text: str, settings: dict) -> tuple[bytes, str]:
|
||||
@ -381,6 +464,8 @@ def _preview_request_audio(
|
||||
)
|
||||
if backend == "vibevoice":
|
||||
return _vibevoice_request_audio(text, settings)
|
||||
if backend == "fishspeech":
|
||||
return _fishspeech_request_audio(text, voice, settings, instruct)
|
||||
if backend == "xtts":
|
||||
return _tts_request_audio(
|
||||
text, voice, settings, instruct,
|
||||
|
||||
43
docs/ARCHITECTURE.md
Normal file
43
docs/ARCHITECTURE.md
Normal 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
17
package.json
Normal 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
29
playwright.config.js
Normal 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'] } },
|
||||
],
|
||||
});
|
||||
@ -9,8 +9,12 @@
|
||||
# Then paste this file into Portainer → Stacks → Add stack → Web editor
|
||||
# (or update the existing stack).
|
||||
#
|
||||
# After any Python code change (core/ or routes/) you must redeploy this stack
|
||||
# or rebuild the image — a plain container restart is not enough.
|
||||
# This reconstructs the current stack from the running container plus the
|
||||
# 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:
|
||||
@ -40,6 +44,8 @@ services:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- VOICES_DIR=/voices
|
||||
- OUTPUT_DIR=/voices/active_voices
|
||||
- TTS_STREAM_URL=http://host.docker.internal:8023
|
||||
- TTS_CONTAINER_NAME=faster-qwen3-tts-voiceclone
|
||||
- TTS_CONTAINER_NAMES=faster-qwen3-tts-voiceclone,faster-qwen3-tts-streaming
|
||||
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
||||
|
||||
|
||||
@ -25,7 +25,22 @@ async def index():
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
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")
|
||||
@ -54,10 +69,11 @@ async def get_version():
|
||||
|
||||
@router.get("/api/changelog", response_class=PlainTextResponse)
|
||||
async def get_changelog():
|
||||
cl = Path(__file__).parent.parent / "CHANGELOG.md"
|
||||
if not cl.is_file():
|
||||
raise HTTPException(404, "CHANGELOG.md not found")
|
||||
return cl.read_text(encoding="utf-8")
|
||||
root = Path(__file__).parent.parent
|
||||
for cl in (root / "CHANGELOG.md", root / "static" / "CHANGELOG.md"):
|
||||
if cl.is_file():
|
||||
return cl.read_text(encoding="utf-8")
|
||||
raise HTTPException(404, "CHANGELOG.md not found")
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=PlainTextResponse)
|
||||
|
||||
@ -107,9 +107,10 @@ def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) ->
|
||||
async def refine_text(request: Request):
|
||||
"""Clean up raw STT transcription using a local OpenAI-compatible LLM."""
|
||||
data = await request.json()
|
||||
settings = _load_settings()
|
||||
text: str = (data.get("text") or "").strip()
|
||||
llm_url: str = (data.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
model: str = (data.get("model") or "").strip()
|
||||
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("refine_model") or settings.get("llm_model") or "").strip()
|
||||
toggles: dict = data.get("toggles") or {}
|
||||
|
||||
if not text:
|
||||
@ -164,10 +165,11 @@ async def refine_text(request: Request):
|
||||
async def rewrite_with_persona(request: Request):
|
||||
"""Rewrite user text in a voice persona's character using a local LLM."""
|
||||
data = await request.json()
|
||||
settings = _load_settings()
|
||||
text: str = (data.get("text") or "").strip()
|
||||
persona: str = (data.get("persona") or "").strip()
|
||||
llm_url: str = (data.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
model: str = (data.get("model") or "").strip()
|
||||
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()
|
||||
mode: str = (data.get("mode") or "rewrite").strip()
|
||||
|
||||
if not persona:
|
||||
@ -219,6 +221,220 @@ async def rewrite_with_persona(request: Request):
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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", "")
|
||||
if persona:
|
||||
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
llm_model = settings.get("llm_model") or ""
|
||||
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:
|
||||
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", "")
|
||||
if persona:
|
||||
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(
|
||||
_preview_request_audio, text, voice, settings, "", "voice_clone"
|
||||
)
|
||||
@ -688,6 +906,25 @@ async def conversation_llm_models(url: str = ""):
|
||||
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")
|
||||
async def conversation_turn(
|
||||
audio: Optional[UploadFile] = None,
|
||||
@ -773,8 +1010,9 @@ async def conversation_turn(
|
||||
messages.extend(hist[-20:])
|
||||
messages.append({"role": "user", "content": transcript})
|
||||
llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512}
|
||||
if llm_model:
|
||||
llm_payload["model"] = llm_model
|
||||
eff_llm_model = llm_model or settings.get("llm_model") or ""
|
||||
if eff_llm_model:
|
||||
llm_payload["model"] = eff_llm_model
|
||||
|
||||
_loop = asyncio.get_event_loop()
|
||||
_token_q: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
@ -812,6 +1050,10 @@ async def conversation_turn(
|
||||
|
||||
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()
|
||||
llm_ttft_ms: int | None = None
|
||||
ttft_done = False
|
||||
@ -843,9 +1085,7 @@ async def conversation_turn(
|
||||
if tts_first_start is None:
|
||||
tts_first_start = time.monotonic()
|
||||
tts_texts.append(chunk_text)
|
||||
tts_tasks.append(asyncio.create_task(
|
||||
asyncio.to_thread(_preview_request_audio, chunk_text, tts_voice, settings, "", tts_be)
|
||||
))
|
||||
tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem))
|
||||
except Exception as exc:
|
||||
yield sse({"type": "error", "stage": "llm", "message": str(exc)})
|
||||
return
|
||||
@ -855,9 +1095,7 @@ async def conversation_turn(
|
||||
if tts_first_start is None:
|
||||
tts_first_start = time.monotonic()
|
||||
tts_texts.append(sent_buf.strip())
|
||||
tts_tasks.append(asyncio.create_task(
|
||||
asyncio.to_thread(_preview_request_audio, sent_buf.strip(), tts_voice, settings, "", tts_be)
|
||||
))
|
||||
tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem))
|
||||
|
||||
llm_total_ms = int((time.monotonic() - t_llm) * 1000)
|
||||
yield sse({"type": "llm_done", "text": llm_text,
|
||||
|
||||
@ -111,7 +111,7 @@ async def probe_url(url: str, type: str = ""):
|
||||
elif type == "stt":
|
||||
checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)]
|
||||
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:
|
||||
checks = [("", None)]
|
||||
|
||||
|
||||
@ -359,7 +359,8 @@ async def update_voice_meta(request: Request):
|
||||
meta = _load_meta(wav)
|
||||
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:
|
||||
meta[field] = data[field]
|
||||
if "transcript" in data:
|
||||
@ -648,6 +649,27 @@ async def rename_voice(request: Request):
|
||||
|
||||
# ── 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}")
|
||||
async def delete_voice(voice_id: str):
|
||||
if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id):
|
||||
|
||||
@ -16,7 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
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.validation import _validate_http_url
|
||||
from core.audio import _to_wav_24k, _duration
|
||||
@ -59,6 +59,8 @@ _PROXY_AUDIO_DOMAINS: set[str] = {
|
||||
"lanceblairvo.com",
|
||||
"raw.githubusercontent.com",
|
||||
"sample-files.com",
|
||||
"platform.r2.fish.audio",
|
||||
"fish.audio",
|
||||
}
|
||||
_GDRIVE_MIME_EXT = {
|
||||
"audio/mpeg": "mp3", "audio/mp3": "mp3",
|
||||
@ -624,3 +626,330 @@ async def elevenlabs_shared_voices(request: Request):
|
||||
return r.json()
|
||||
except requests.exceptions.RequestException as 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
|
||||
|
||||
@ -41,6 +41,7 @@ from core.tts_helpers import (
|
||||
_route_sound_path, _voice_design_request_audio, _voice_design_voice_request_audio,
|
||||
_nvidia_clone_request_audio, _parse_voice_design_dialogue,
|
||||
_infer_voice_design_gender, _voice_design_dialogue_request_audio,
|
||||
_fishspeech_request_audio,
|
||||
)
|
||||
from core.audio import _duration, _export_normalized_wav
|
||||
from core.docker_client import _docker_post
|
||||
@ -74,6 +75,7 @@ def _backend_display_name(backend: str, url: str) -> str:
|
||||
"kokoro": "Kokoro FastAPI (82M)",
|
||||
"vibevoice": "VibeVoice TTS",
|
||||
"xtts": "XTTS v2",
|
||||
"fishspeech": "Fish-Speech (Clone + Emotion)",
|
||||
}
|
||||
port = _backend_port_label(url)
|
||||
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,
|
||||
"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), {})
|
||||
|
||||
@ -178,7 +188,7 @@ def _backend_health(url: str) -> 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 bool(voices) or health
|
||||
|
||||
@ -314,7 +324,7 @@ async def tts_voices(backend: str = "voice_clone"):
|
||||
async def tts_backends():
|
||||
settings = _load_settings()
|
||||
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("/")
|
||||
voices = _fetch_backend_voices(settings, backend)
|
||||
health = _backend_health(url)
|
||||
@ -455,9 +465,10 @@ async def tts_preview(request: Request):
|
||||
persona = _load_meta(wav).get("persona", "")
|
||||
if persona:
|
||||
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
llm_model = settings.get("llm_model") or ""
|
||||
try:
|
||||
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:
|
||||
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("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:
|
||||
audio, media_type = await asyncio.to_thread(
|
||||
_tts_request_audio, text, voice, settings, style_instruction,
|
||||
|
||||
42
scripts/minify.mjs
Normal file
42
scripts/minify.mjs
Normal 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)`);
|
||||
13
server.py
13
server.py
@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from core.constants import STATIC_DIR, _BufferHandler
|
||||
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")
|
||||
|
||||
# ── 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 ──────────────────────────────────────────────
|
||||
|
||||
_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"
|
||||
elif path.startswith("/static/sections/"):
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
9
static/icon.svg
Normal file
9
static/icon.svg
Normal 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 |
@ -2,8 +2,24 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
<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) ──────────────── -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
@ -91,7 +107,7 @@
|
||||
<div id="app-shell">
|
||||
|
||||
<!-- Sidebar (renders immediately — no JS needed) -->
|
||||
<aside id="sidebar">
|
||||
<aside id="sidebar" tabindex="0" aria-label="Navigation">
|
||||
<div class="sidebar-brand">
|
||||
<h1><span class="mdi mdi-microphone-variant"></span> Voice Creator</h1>
|
||||
<p>Clone · Design · Deploy</p>
|
||||
@ -103,21 +119,32 @@
|
||||
<span class="nav-icon"><span class="mdi mdi-account-voice"></span></span>
|
||||
<span class="nav-label">My Voices</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 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" data-voice-cat="cloned" onclick="navVoicesCat('cloned')">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="favorites" onclick="navVoicesCat('favorites')">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="tools" onclick="navVoicesCat('tools')">Library tools</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')"><span class="mdi mdi-microphone-variant"></span> Cloned <span class="ntc" id="ntc-cloned"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')"><span class="mdi mdi-auto-fix"></span> Designed <span class="ntc" id="ntc-designed"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')"><span class="mdi mdi-star-outline"></span> Favorites <span class="ntc" id="ntc-favorites"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')"><span class="mdi mdi-eye-off-outline"></span> Hidden <span class="ntc" id="ntc-hidden"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')"><span class="mdi mdi-wrench-outline"></span> Library tools</div>
|
||||
</div>
|
||||
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')"> <span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice</div>
|
||||
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
|
||||
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div>
|
||||
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div>
|
||||
<div class="nav-item" 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-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')">
|
||||
<span class="nav-icon"><span class="mdi mdi-brain"></span></span>
|
||||
<span class="nav-label">Engines</span>
|
||||
<span class="nav-chevron" id="nav-engines-chevron"><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 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" data-engines-cat="stt" onclick="navEnginesCat('stt')">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 is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')"><span class="mdi mdi-brain"></span> Language Models</div>
|
||||
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
|
||||
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-text-to-speech"></span> Text to Speech</div>
|
||||
</div>
|
||||
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')"> <span class="nav-icon"><span class="mdi mdi-swap-horizontal"></span></span> App Routing</div>
|
||||
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')"> <span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps</div>
|
||||
<div class="nav-item nav-tree-head" data-nav-section="s-settings" id="nav-settings-head" onclick="navTo('s-settings')">
|
||||
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span>
|
||||
<span class="nav-label">Settings</span>
|
||||
<span class="nav-chevron" id="nav-settings-chevron"><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 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="connections" onclick="navSettingsCat('connections')">Connections</div>
|
||||
<div class="nav-tree-item" data-settings-cat="playback" onclick="navSettingsCat('playback')">Playback</div>
|
||||
<div class="nav-tree-item" data-settings-cat="captures" onclick="navSettingsCat('captures')">Captures</div>
|
||||
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')">Payloads</div>
|
||||
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')">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="backup" onclick="navSettingsCat('backup')">Backup</div>
|
||||
<div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')">Logs</div>
|
||||
<div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')">About</div>
|
||||
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')"><span class="mdi mdi-tune-vertical"></span> General</div>
|
||||
<div class="nav-tree-item" data-settings-cat="connections" onclick="navSettingsCat('connections')"><span class="mdi mdi-lan-connect"></span> Connections</div>
|
||||
<div class="nav-tree-item" data-settings-cat="playback" onclick="navSettingsCat('playback')"><span class="mdi mdi-speaker-outline"></span> Playback</div>
|
||||
<div class="nav-tree-item" data-settings-cat="captures" onclick="navSettingsCat('captures')"><span class="mdi mdi-record-circle-outline"></span> Captures</div>
|
||||
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')"><span class="mdi mdi-code-json"></span> Payloads</div>
|
||||
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')"><span class="mdi mdi-folder-outline"></span> Storage</div>
|
||||
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')"><span class="mdi mdi-key-outline"></span> API Keys</div>
|
||||
<div class="nav-tree-item" data-settings-cat="backup" onclick="navSettingsCat('backup')"><span class="mdi mdi-backup-restore"></span> Backup</div>
|
||||
<div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')"><span class="mdi mdi-text-box-outline"></span> Logs</div>
|
||||
<div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')"><span class="mdi mdi-information-outline"></span> About</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@ -159,6 +159,13 @@
|
||||
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', () => {
|
||||
const url = document.querySelector('[data-llm-local-key="vibevoice"]')?.value.trim()
|
||||
|| 'http://192.168.178.8:8027';
|
||||
@ -185,15 +192,99 @@
|
||||
'llm-use-vllm-llm': { key: 'vllm', fallback: 'http://localhost:8000/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-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 }]) => {
|
||||
$(id)?.addEventListener('click', () => {
|
||||
const url = document.querySelector(`[data-llm-local-key="${key}"]`)?.value.trim() || fallback;
|
||||
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 ─────────────────────────────────────────
|
||||
(async function initSttTestPanel() {
|
||||
const panel = $('stt-test-panel');
|
||||
|
||||
@ -176,8 +176,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
if (chatWindow) chatWindow.prepend(warn);
|
||||
}
|
||||
|
||||
// Restore conv LLM URL from server settings
|
||||
if (llmUrlInp && _appSettings && _appSettings.conv_llm_url) llmUrlInp.value = _appSettings.conv_llm_url;
|
||||
// Restore conv LLM URL from server settings — fall back to the global Active LLM endpoint
|
||||
if (llmUrlInp && _appSettings) llmUrlInp.value = _appSettings.conv_llm_url || _appSettings.llm_url || '';
|
||||
if (llmUrlInp) llmUrlInp.addEventListener('input', () => {
|
||||
_patchSettings({ 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
|
||||
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
|
||||
: '<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) {
|
||||
llmModelSel.innerHTML = '<option value="">Fetch failed</option>';
|
||||
} finally {
|
||||
|
||||
266
static/js/fishaudio-browser.js
Normal file
266
static/js/fishaudio-browser.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
// 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);
|
||||
})();
|
||||
@ -98,6 +98,12 @@ function historyPush(voice, text, backend, blob, url) {
|
||||
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() {
|
||||
const list = $('history-list');
|
||||
if (!list) return;
|
||||
@ -109,11 +115,17 @@ function renderHistory() {
|
||||
const t = new Date(item.ts);
|
||||
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 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)}">
|
||||
<div class="history-item-row1">
|
||||
${avatar}
|
||||
<span class="history-voice">${escHtml(item.voice)}</span>
|
||||
<span class="history-backend">${escHtml(item.backend)}</span>
|
||||
<span class="history-time">${ts}</span>
|
||||
<button class="hist-del-btn" title="Delete"><span class="mdi mdi-close"></span></button>
|
||||
</div>
|
||||
<div class="history-item-text">${preview}</div>
|
||||
<div class="history-item-actions">
|
||||
@ -124,6 +136,14 @@ function renderHistory() {
|
||||
</div>`;
|
||||
}).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 => {
|
||||
btn.addEventListener('click', () => {
|
||||
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
|
||||
const saved = (_appSettings && _appSettings.refine_llm_url) || localStorage.getItem('refine-llm-url');
|
||||
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', () => {
|
||||
localStorage.setItem('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'},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
llm_url: $('refine-llm-url')?.value?.trim() || 'http://localhost:11434/v1',
|
||||
model: $('refine-model')?.value?.trim() || '',
|
||||
llm_url: $('refine-llm-url')?.value?.trim() || _appSettings?.llm_url || 'http://localhost:11434/v1',
|
||||
model: $('refine-model')?.value?.trim() || _appSettings?.refine_model || _appSettings?.llm_model || '',
|
||||
toggles: {
|
||||
fillers: $('refine-fillers')?.checked ?? true,
|
||||
repetitions: $('refine-repetitions')?.checked ?? true,
|
||||
|
||||
126
static/js/i18n.js
Normal file
126
static/js/i18n.js
Normal 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);
|
||||
};
|
||||
172
static/js/rehearser-parse.js
Normal file
172
static/js/rehearser-parse.js
Normal 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
@ -193,13 +193,13 @@ function renderRoutingList() {
|
||||
$('routing-list').innerHTML = _ttsRoutes.map((r, i) => `
|
||||
<div class="routing-grid routing-row" data-index="${i}">
|
||||
<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>
|
||||
</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-input" value="${escHtml(r.input_voice || 'default')}" placeholder="default">
|
||||
<select class="route-lang">${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>
|
||||
<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" aria-label="Input voice" value="${escHtml(r.input_voice || 'default')}" placeholder="default">
|
||||
<select class="route-lang" aria-label="Route language">${routeSelectOptions(String(r.language || '*').toUpperCase())}</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">
|
||||
<div class="route-sound-cell">
|
||||
<input class="route-before-sound" value="${escHtml(r.before_sound || '')}" placeholder="sounds/start.wav" list="routing-sound-options">
|
||||
|
||||
@ -429,7 +429,7 @@ $('voice-id-input').addEventListener('input', () => {
|
||||
$('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : '';
|
||||
$('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();
|
||||
if (!name) { toast('Enter a name first', 'error'); return; }
|
||||
$('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`;
|
||||
|
||||
@ -86,9 +86,9 @@ function updatePreviewVoiceMatchPanel() {
|
||||
personaBtn.disabled = true;
|
||||
personaBtn.textContent = 'Rewriting…';
|
||||
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'},
|
||||
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); }
|
||||
const d = await r.json();
|
||||
$('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)));
|
||||
}
|
||||
const sel = $('tts-voice-select'), prev = sel.value;
|
||||
sel.innerHTML = '<option value="">— select after fetch —</option>';
|
||||
voices.forEach(v => {
|
||||
const id = backendVoiceId(v);
|
||||
const opt = document.createElement('option'); opt.value = opt.textContent = id; sel.appendChild(opt);
|
||||
});
|
||||
if(prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev;
|
||||
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>';
|
||||
ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); });
|
||||
if (prev && ids.includes(prev)) sel.value = prev;
|
||||
}
|
||||
updatePreviewVoiceMatchPanel();
|
||||
const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices';
|
||||
toast('Fetched '+voices.length+suffix,'success');
|
||||
|
||||
@ -21,6 +21,22 @@ function escHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@ -62,6 +78,7 @@ async function clientAutoTrimBounds(fid) {
|
||||
const audioData = await resp.arrayBuffer();
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
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 sr = buffer.sampleRate;
|
||||
const dur = buffer.duration;
|
||||
|
||||
@ -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ę?',
|
||||
};
|
||||
|
||||
// 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 () {
|
||||
const sel = $('clone-sample-lang');
|
||||
const txt = $('clone-sample-text');
|
||||
if (!sel || !txt) return;
|
||||
txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN;
|
||||
cloneSampleForLang(sel.value);
|
||||
if (!sel._cloneSampleBound) {
|
||||
sel.addEventListener('change', () => {
|
||||
txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN;
|
||||
});
|
||||
sel.addEventListener('change', () => cloneSampleForLang(sel.value));
|
||||
sel._cloneSampleBound = true;
|
||||
}
|
||||
};
|
||||
@ -359,6 +372,9 @@ $('trim-btn').addEventListener('click', async () => {
|
||||
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';
|
||||
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'); }
|
||||
});
|
||||
|
||||
@ -909,14 +925,116 @@ $('clone-refresh-stt-btn')?.addEventListener('click', async () => {
|
||||
$('transcribe-btn').addEventListener('click', async () => {
|
||||
const id = trimmedFileId||designedFileId||currentFileId;
|
||||
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 {
|
||||
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})});
|
||||
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');
|
||||
} catch(e) { $('transcribe-status').textContent='Failed: '+e.message; toast('Transcription failed: '+e.message,'error'); }
|
||||
finally { $('transcribe-btn').disabled=false; }
|
||||
window._cloneScheduleAutoSave?.(); // streamlined flow: save once transcript is in
|
||||
} 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');
|
||||
})();
|
||||
|
||||
@ -47,17 +47,22 @@ function selectVoice(wrap) {
|
||||
const rating = v.rating || 0;
|
||||
|
||||
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 picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null;
|
||||
const inspFlagCc = flagCc || langCode;
|
||||
const inspFlagIcon = inspFlagCc
|
||||
? `<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
|
||||
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
|
||||
: inspFlagIcon || initial;
|
||||
const avatarBgStyle = (picSrcInsp || inspFlagIcon) ? '' : `style="background:${color}"`;
|
||||
: inspIconHtml || inspFlagIcon || initial;
|
||||
const avatarBgStyle = (picSrcInsp || inspIconHtml || inspFlagIcon) ? '' : `style="background:${color}"`;
|
||||
|
||||
const flagIconHtml = inspFlagIcon || '';
|
||||
|
||||
@ -90,7 +95,8 @@ function selectVoice(wrap) {
|
||||
</div>
|
||||
<div class="insp-hd-row2">
|
||||
<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>
|
||||
</div>
|
||||
<div class="insp-actions-active"></div>
|
||||
@ -185,6 +191,8 @@ function selectVoice(wrap) {
|
||||
};
|
||||
dispNameEl.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('keydown', e => {
|
||||
if (e.key === 'Enter') nameEditEl.blur();
|
||||
@ -202,7 +210,10 @@ function selectVoice(wrap) {
|
||||
await saveMeta(voiceId, { flag: cc });
|
||||
};
|
||||
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, {
|
||||
placeholder: 'Country or accent…',
|
||||
renderItem: (cc, name) =>
|
||||
|
||||
@ -123,7 +123,18 @@ function syncSortHeaders() {
|
||||
// since the button is injected into the DOM after script execution).
|
||||
document.addEventListener('click', e => {
|
||||
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 => {
|
||||
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 (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;
|
||||
}
|
||||
return true;
|
||||
@ -674,10 +685,10 @@ async function runVoiceBenchmark(voiceId = '', opts = {}) {
|
||||
}
|
||||
|
||||
async function runVoiceBenchmarkBatch() {
|
||||
const voices = _voices.filter(v => v.enabled !== false);
|
||||
const voices = benchmarkTargetVoices();
|
||||
const text = benchmarkSampleText();
|
||||
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 aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true};
|
||||
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);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const voices = activeBenchmarkVoices();
|
||||
const voices = benchmarkTargetVoices();
|
||||
const onlySelected = (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0);
|
||||
const text = benchmarkSampleText();
|
||||
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;
|
||||
$('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.' +
|
||||
(staleCount ? ` ${staleCount} edited voice${staleCount === 1 ? '' : 's'} should be restarted first, otherwise cached old voices may be benchmarked.` : '');
|
||||
$('benchmark-confirm').hidden = false;
|
||||
@ -1342,25 +1362,37 @@ function renderVoiceList() {
|
||||
const list = $('voice-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
renderVoiceGroupsBar();
|
||||
populateLibraryFilters();
|
||||
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
|
||||
const cat = window._voiceSidebarCat || 'all';
|
||||
const enabledOk = v => showDisabled || v.enabled !== false;
|
||||
let filtered = _voices.filter(v => {
|
||||
if (cat === 'cloned') return v.has_ref;
|
||||
if (cat === 'designed') return !v.has_ref;
|
||||
if (cat === 'favorites') return (v.rating || 0) >= 4;
|
||||
if (cat === 'hidden') return v.enabled === false;
|
||||
if (cat === 'tools') return false; // no rows for tools view
|
||||
// 'all' — respect show-disabled toggle
|
||||
return showDisabled || v.enabled !== false;
|
||||
// 'designed' = prompt-built voices (explicit origin marker, OR no reference WAV).
|
||||
// 'cloned' = real WAV-identity voices that were NOT prompt-designed.
|
||||
if (cat === 'cloned') return enabledOk(v) && v.has_ref && v.origin !== 'designed';
|
||||
if (cat === 'designed') return enabledOk(v) && (v.origin === 'designed' || !v.has_ref);
|
||||
if (cat === 'favorites') return enabledOk(v) && (v.rating || 0) >= 4;
|
||||
if (cat === 'hidden') return v.enabled === false; // always show hidden voices in hidden tab
|
||||
// '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
|
||||
if (cat !== 'all' && cat !== 'tools') {
|
||||
// already filtered above — no extra enabled filter needed
|
||||
} else if (cat === 'all') {
|
||||
// already applied enabled filter in the lambda above
|
||||
// Apply active group filter (from the groups bar)
|
||||
if (window._voiceGroupFilter) {
|
||||
filtered = filtered.filter(v => (v.group || '').trim() === window._voiceGroupFilter);
|
||||
}
|
||||
// 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;
|
||||
filtered = filtered.filter(libraryFilterMatch);
|
||||
@ -1511,7 +1543,8 @@ function renderVoiceList() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
|
||||
if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
|
||||
else filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
|
||||
if (_pendingSelectId) {
|
||||
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
|
||||
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
|
||||
@ -1519,6 +1552,54 @@ function renderVoiceList() {
|
||||
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) {
|
||||
const resp = await fetch(voiceFileUrl(v), {cache:'no-store'});
|
||||
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();
|
||||
|
||||
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] || [];
|
||||
// Use language-specific variants if there are multiple; fall back to world picker otherwise
|
||||
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>`;
|
||||
|
||||
// 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 flagIconHtml = currentFlag
|
||||
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
|
||||
: null;
|
||||
|
||||
// Avatar: photo > 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 avatarStyle = picSrc || flagIconHtml ? '' : `style="background:${color}"`;
|
||||
// Avatar: photo > gender/type icon > flag icon > initial letter (with color)
|
||||
const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
|
||||
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
|
||||
: iconHtml ? 'vl-avatar vl-avatar-icon'
|
||||
: flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
|
||||
const avatarStyle = (picSrc || iconHtml || flagIconHtml) ? '' : `style="background:${color}"`;
|
||||
const avatarContent = picSrc
|
||||
? `<img src="${picSrc}" alt="" class="vl-avatar-img">`
|
||||
: flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`;
|
||||
? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
|
||||
: iconHtml || flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`;
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="vl-compact">
|
||||
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
|
||||
<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">
|
||||
<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>` : ''}
|
||||
@ -1665,14 +1754,14 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
</div>
|
||||
<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-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-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
|
||||
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vr-main-row">
|
||||
<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/*">
|
||||
</div>
|
||||
|
||||
@ -2661,6 +2750,62 @@ function makeVoiceRow(v) {
|
||||
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">×</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) {
|
||||
const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
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'));
|
||||
}, { 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
224
static/js/voice-picker.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@ -108,7 +108,7 @@ function renderGetVoices() {
|
||||
return;
|
||||
}
|
||||
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 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);
|
||||
@ -249,6 +249,21 @@ async function getSourceVoiceInLibrary(meta) {
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
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;
|
||||
navTo('s-voices');
|
||||
_libraryLoadPromise = null;
|
||||
|
||||
@ -84,6 +84,7 @@
|
||||
|
||||
// A — foundation (sequential: each must finish before the next batch starts)
|
||||
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');
|
||||
|
||||
// Wait for background sections BEFORE feature modules run.
|
||||
@ -92,10 +93,14 @@
|
||||
// settings.js completes, so this await is typically instant.
|
||||
await _bgSections;
|
||||
|
||||
// C — feature modules (9 files fetched in parallel, executed in order)
|
||||
await _loadBatch([
|
||||
// C — feature modules (executed in order; share a global scope).
|
||||
// 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-sources.js',
|
||||
'/static/js/fishaudio-browser.js',
|
||||
'/static/js/integrations.js',
|
||||
'/static/js/routing.js',
|
||||
'/static/js/voice-clone.js',
|
||||
@ -103,8 +108,16 @@
|
||||
'/static/js/tts-preview.js',
|
||||
'/static/js/benchmark.js',
|
||||
'/static/js/stt.js',
|
||||
'/static/js/rehearser-parse.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)
|
||||
await _load('/static/js/init.js');
|
||||
@ -122,6 +135,10 @@
|
||||
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.
|
||||
// Engines, AI backends, generation, and conversation sections load while
|
||||
// the user browses Voices / Clone / Design, not before.
|
||||
|
||||
14
static/manifest.webmanifest
Normal file
14
static/manifest.webmanifest
Normal 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" }
|
||||
]
|
||||
}
|
||||
135
static/nav.js
135
static/nav.js
@ -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) {
|
||||
localStorage.setItem('ttsvc_section', sectionId);
|
||||
SECTIONS.forEach(function (id) {
|
||||
@ -50,23 +78,14 @@
|
||||
item.classList.toggle('active', item.dataset.navSection === sectionId);
|
||||
});
|
||||
|
||||
// Voices nav tree
|
||||
var voicesTree = document.getElementById('nav-voices-tree');
|
||||
var voicesChevron = document.getElementById('nav-voices-chevron');
|
||||
if (voicesTree) voicesTree.classList.toggle('open', sectionId === 's-voices');
|
||||
if (voicesChevron) voicesChevron.classList.toggle('open', sectionId === 's-voices');
|
||||
|
||||
// Settings nav tree
|
||||
var settingsTree = document.getElementById('nav-settings-tree');
|
||||
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');
|
||||
// Navigating to a section auto-opens its tree (but never closes others)
|
||||
TREES.forEach(function (t) {
|
||||
if (t.section === sectionId && !_treeOpen[t.tree]) {
|
||||
_treeOpen[t.tree] = true;
|
||||
localStorage.setItem('ttsvc_trees', JSON.stringify(_treeOpen));
|
||||
}
|
||||
});
|
||||
_applyTreeStates();
|
||||
|
||||
// When entering settings, show the active sub-page (default: connections)
|
||||
if (sectionId === 's-settings') {
|
||||
@ -100,12 +119,30 @@
|
||||
// ── Voice tree sub-navigation ─────────────────────────────────────────────
|
||||
window._voiceSidebarCat = 'all';
|
||||
|
||||
window._voiceTagFilter = '';
|
||||
|
||||
window.navVoicesCat = function (cat) {
|
||||
navTo('s-voices');
|
||||
window._voiceSidebarCat = cat;
|
||||
window._voiceTagFilter = ''; // a fixed category clears any tag subfolder filter
|
||||
document.querySelectorAll('[data-voice-cat]').forEach(function (el) {
|
||||
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();
|
||||
};
|
||||
|
||||
@ -119,6 +156,32 @@
|
||||
set('ntc-favorites',n(function (v) { return (v.rating || 0) >= 4; }));
|
||||
set('ntc-hidden', n(function (v) { return v.enabled === false; }));
|
||||
set('nav-voices-count', voices.length);
|
||||
|
||||
// ── 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 ──────────────────────────────────────────
|
||||
@ -151,8 +214,46 @@
|
||||
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)
|
||||
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._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
|
||||
var _startSection = (_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices';
|
||||
|
||||
@ -2,75 +2,95 @@
|
||||
<span class="section-icon"><span class="mdi mdi-microphone-variant"></span></span>
|
||||
<div class="section-title">
|
||||
<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>
|
||||
|
||||
<!-- Step 1: Load audio -->
|
||||
<!-- Audio source — integrated tabs (record / upload / link) -->
|
||||
<div class="tab-content" id="tab-source">
|
||||
<div class="card">
|
||||
<h2>Step 1 — Load audio</h2>
|
||||
<p class="card-subtitle">Drop a file, paste a URL, or record directly from your microphone.</p>
|
||||
<div id="drop-zone">
|
||||
<strong>Drop an audio / video file here</strong>
|
||||
<span>WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · 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 class="clone-tabbox">
|
||||
<div class="clone-src-picker" id="clone-src-picker" role="tablist">
|
||||
<button class="clone-src-tab active" data-src="mic" role="tab"><span class="mdi mdi-microphone"></span> Microphone</button>
|
||||
<button class="clone-src-tab" data-src="file" role="tab"><span class="mdi mdi-file-music-outline"></span> Upload file</button>
|
||||
<button class="clone-src-tab" data-src="url" role="tab"><span class="mdi mdi-link-variant"></span> URL / YouTube</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<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">
|
||||
<label for="clone-sample-lang">Read aloud</label>
|
||||
<select id="clone-sample-lang">
|
||||
<option value="EN">English</option>
|
||||
<option value="DE">Deutsch</option>
|
||||
<option value="IT">Italiano</option>
|
||||
<option value="ES">Español</option>
|
||||
<option value="FR">Français</option>
|
||||
<option value="PT">Português</option>
|
||||
<option value="NL">Nederlands</option>
|
||||
<option value="PL">Polski</option>
|
||||
</select>
|
||||
<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 class="sample-read-box" style="margin-top:12px">
|
||||
<div class="sample-head">
|
||||
<label for="clone-sample-lang">Read this aloud</label>
|
||||
<select id="clone-sample-lang">
|
||||
<option value="EN">English</option>
|
||||
<option value="DE">Deutsch</option>
|
||||
<option value="IT">Italiano</option>
|
||||
<option value="ES">Español</option>
|
||||
<option value="FR">Français</option>
|
||||
<option value="PT">Português</option>
|
||||
<option value="NL">Nederlands</option>
|
||||
<option value="PL">Polski</option>
|
||||
</select>
|
||||
</div>
|
||||
<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> Edit the sentence freely before recording.</p>
|
||||
</div>
|
||||
<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-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-secondary" id="rec-stop-btn" disabled><span class="mdi mdi-stop-circle-outline"></span> Stop</button>
|
||||
<div id="rec-indicator"><div id="rec-dot"></div><span id="rec-time">0:00</span></div>
|
||||
</div>
|
||||
<div class="mic-monitor-box" id="clone-monitor-box" style="margin-top:10px">
|
||||
<div class="mic-monitor-head">
|
||||
<span>Input level</span>
|
||||
<span class="meter-readout" id="clone-db-readout">-∞ dB</span>
|
||||
</div>
|
||||
<div class="mic-meter" id="clone-mic-meter" aria-hidden="true"></div>
|
||||
<canvas id="clone-live-wave" class="mic-live-wave" width="300" height="48" aria-hidden="true"></canvas>
|
||||
<div class="mic-gain-row">
|
||||
<label for="clone-mic-gain">Mic gain</label>
|
||||
<input id="clone-mic-gain" type="range" min="0" max="2" step="0.05" value="1">
|
||||
<span class="mic-gain-value" id="clone-mic-gain-value">1.00x</span>
|
||||
</div>
|
||||
<div class="note" style="margin-top:4px">Best peaks: −18 to −9 dB. Never red.</div>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<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-red" id="rec-start-btn"><span class="mdi mdi-record-circle-outline"></span> Record</button>
|
||||
<button class="btn-secondary" id="rec-stop-btn" disabled><span class="mdi mdi-stop-circle-outline"></span> Stop</button>
|
||||
<div id="rec-indicator"><div id="rec-dot"></div><span id="rec-time">0:00</span></div>
|
||||
</div>
|
||||
<div class="mic-monitor-box" id="clone-monitor-box" style="margin-top:10px">
|
||||
<div class="mic-monitor-head">
|
||||
<span>Input level</span>
|
||||
<span class="meter-readout" id="clone-db-readout">-∞ dB</span>
|
||||
|
||||
<!-- 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 · MP3 · OGG · FLAC · M4A · MP4 · MKV · 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="mic-meter" id="clone-mic-meter" aria-hidden="true"></div>
|
||||
<canvas id="clone-live-wave" class="mic-live-wave" width="300" height="48" aria-hidden="true"></canvas>
|
||||
<div class="mic-gain-row">
|
||||
<label for="clone-mic-gain">Mic gain</label>
|
||||
<input id="clone-mic-gain" type="range" min="0" max="2" step="0.05" value="1">
|
||||
<span class="mic-gain-value" id="clone-mic-gain-value">1.00x</span>
|
||||
|
||||
<!-- 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 class="note" style="margin-top:4px">Best peaks: −18 to −9 dB. Never red.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /clone-srcpanel -->
|
||||
</div><!-- /clone-tabbox -->
|
||||
|
||||
<!-- Trim (appears once audio is loaded) -->
|
||||
<div class="card" id="waveform-card" style="display:none">
|
||||
<h2>Step 2 — 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>
|
||||
<div id="waveform-wrap"><div id="waveform"></div></div>
|
||||
<div class="time-row">
|
||||
@ -87,49 +107,49 @@
|
||||
</div>
|
||||
</div><!-- /tab-source -->
|
||||
|
||||
<!-- Step 3: Name and save -->
|
||||
<!-- Name, transcribe, preview & save -->
|
||||
<div class="tab-content" id="tab-save">
|
||||
<div class="card">
|
||||
<h2>Step 3 — Name the voice</h2>
|
||||
<p class="card-subtitle">Assign a unique ID, language, and gender — e.g. <code>EN_F_Anna</code>.</p>
|
||||
<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>
|
||||
<details class="helper">
|
||||
<summary>Build from parts (LANG · GENDER · Name helper)</summary>
|
||||
<div class="helper-body">
|
||||
<div class="field">
|
||||
<label>Language</label>
|
||||
<select id="lang-select">
|
||||
<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>PT</option><option>RU</option><option>AR</option><option>PL</option>
|
||||
<option>NL</option><option>SV</option><option>TR</option><option>HI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Gender</label>
|
||||
<select id="gender-select">
|
||||
<option value="F">F — Female</option>
|
||||
<option value="M">M — Male</option>
|
||||
<option value="N">N — Neutral</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:1;min-width:120px">
|
||||
<label>Name (no spaces)</label>
|
||||
<input type="text" id="name-input" placeholder="Anna">
|
||||
</div>
|
||||
<button class="btn-secondary" id="helper-apply-btn" style="align-self:flex-end">Apply →</button>
|
||||
<h2><span class="mdi mdi-account-voice"></span> Name your voice</h2>
|
||||
<p class="card-subtitle">Choose language & 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="clone-id-row">
|
||||
<div class="field">
|
||||
<label for="lang-select">Language</label>
|
||||
<select id="lang-select" aria-label="Voice language">
|
||||
<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>PT</option><option>RU</option><option>AR</option><option>PL</option>
|
||||
<option>NL</option><option>SV</option><option>TR</option><option>HI</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
<div class="field">
|
||||
<label for="gender-select">Gender</label>
|
||||
<select id="gender-select" aria-label="Voice gender">
|
||||
<option value="F">F — Female</option>
|
||||
<option value="M">M — Male</option>
|
||||
<option value="N">N — Neutral</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:1;min-width:140px">
|
||||
<label>Name (no spaces)</label>
|
||||
<input type="text" id="name-input" placeholder="Anna">
|
||||
</div>
|
||||
<button class="btn-secondary" id="helper-apply-btn" hidden>Apply →</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>
|
||||
<div class="card">
|
||||
<h2>Transcript (reference text)</h2>
|
||||
<p class="card-subtitle">The spoken text that matches this audio — type it or auto-transcribe.</p>
|
||||
<h2><span class="mdi mdi-message-text-outline"></span> Reference transcript</h2>
|
||||
<p class="card-subtitle">The exact words spoken in the audio — type them or auto-transcribe.</p>
|
||||
<div class="btn-row" style="gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
||||
<label style="font-size:13px;color:var(--subtext);white-space:nowrap">Recognition engine:</label>
|
||||
<select id="clone-stt-backend" 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>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
@ -139,16 +159,20 @@
|
||||
<textarea id="transcript-area" placeholder="Type or auto-transcribe the spoken text…"></textarea>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Audio preview</h2>
|
||||
<p class="card-subtitle">Play back the trimmed audio before saving to the library.</p>
|
||||
<h2><span class="mdi mdi-play-circle-outline"></span> Preview</h2>
|
||||
<p class="card-subtitle">Listen to the trimmed audio before saving.</p>
|
||||
<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 class="card">
|
||||
<h2>Step 4 — 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>
|
||||
<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>
|
||||
<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 id="save-result" class="note" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
@ -11,22 +11,22 @@
|
||||
<div class="conv-config-row">
|
||||
<div class="conv-config-group">
|
||||
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
|
||||
<select id="conv-stt-select"><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 class="conv-config-group">
|
||||
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
|
||||
<div style="display:flex;gap:6px">
|
||||
<input id="conv-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
|
||||
<button class="btn-secondary" id="conv-llm-fetch-btn" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
|
||||
<select id="conv-llm-model-select"><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 class="conv-config-group">
|
||||
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
|
||||
<div style="display:flex;gap:6px">
|
||||
<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>
|
||||
<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 class="conv-config-group conv-config-group--actions">
|
||||
@ -35,7 +35,7 @@
|
||||
</div>
|
||||
<div class="conv-prompt-row">
|
||||
<label class="conv-cfg-label"><span class="mdi mdi-text-box-outline"></span> System prompt</label>
|
||||
<textarea id="conv-system-prompt" 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>
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||
<div class="field">
|
||||
<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="F">Female</option>
|
||||
<option value="M">Male</option>
|
||||
@ -35,7 +35,7 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<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="English">English</option><option value="Chinese">Chinese</option>
|
||||
<option value="Japanese">Japanese</option><option value="Korean">Korean</option>
|
||||
@ -132,7 +132,7 @@
|
||||
<div class="preset-row">
|
||||
<div class="field">
|
||||
<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>
|
||||
<button class="btn-secondary" id="design-preset-load">Load</button>
|
||||
<button class="btn-secondary" id="design-preset-delete">Delete</button>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<div class="llm-sec-header">
|
||||
<div>
|
||||
<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’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 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>
|
||||
@ -20,6 +20,34 @@
|
||||
</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 & 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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-icon"><span class="mdi mdi-swap-horizontal"></span></span>
|
||||
<span class="llm-local-name">llama-swap</span>
|
||||
<span class="llm-local-compat">OpenAI-compat</span>
|
||||
</div>
|
||||
<div class="llm-local-metrics">
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> Hot model swap</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> On-demand load</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Many models, 1 port</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> GPU / CPU</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Proxy that loads/unloads llama.cpp (and other) models on demand and exposes them all on one OpenAI endpoint. Pick any served model below.</p>
|
||||
<div class="llm-local-url">
|
||||
<span class="llm-local-url-label">URL</span>
|
||||
<input class="llm-local-url-inp" type="text" placeholder="http://host.docker.internal:28080/v1" data-llm-local-key="llama-swap" data-llm-local-default="http://host.docker.internal:28080/v1" spellcheck="false">
|
||||
<button class="llm-local-ping" data-ping-key="llama-swap" title="Test connection">Connect</button>
|
||||
</div>
|
||||
<div class="llm-local-actions">
|
||||
<button class="llm-use-btn" id="llm-use-llama-swap-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Docker one-liner</span>
|
||||
<button class="llm-copy-btn" data-copy="docker run -p 28080:8080 -v ./config.yaml:/app/config.yaml ghcr.io/mostlygeek/llama-swap:cuda">Copy</button>
|
||||
</div>
|
||||
<pre>docker run -p 28080:8080 \
|
||||
-v ./config.yaml:/app/config.yaml \
|
||||
ghcr.io/mostlygeek/llama-swap:cuda</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/mostlygeek/llama-swap" target="_blank" rel="noopener">github.com/mostlygeek/llama-swap <span class="mdi mdi-open-in-new link-icon"></span></a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-icon"><span class="mdi mdi-call-split"></span></span>
|
||||
<span class="llm-local-name">LiteLLM</span>
|
||||
<span class="llm-local-compat">OpenAI-compat</span>
|
||||
</div>
|
||||
<div class="llm-local-metrics">
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-lightning-bolt"></span> 100+ providers</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> Unified API</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Routing & fallback</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> Proxy / gateway</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Gateway that exposes local and cloud models behind one OpenAI-compatible endpoint, with routing, fallbacks, and budgets. Pick any configured model below.</p>
|
||||
<div class="llm-local-url">
|
||||
<span class="llm-local-url-label">URL</span>
|
||||
<input class="llm-local-url-inp" type="text" placeholder="http://host.docker.internal:14000/v1" data-llm-local-key="litellm" data-llm-local-default="http://host.docker.internal:14000/v1" spellcheck="false">
|
||||
<button class="llm-local-ping" data-ping-key="litellm" title="Test connection">Connect</button>
|
||||
</div>
|
||||
<div class="llm-local-actions">
|
||||
<button class="llm-use-btn" id="llm-use-litellm-llm" type="button"><span class="mdi mdi-content-copy"></span> Use as LLM</button>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Docker one-liner</span>
|
||||
<button class="llm-copy-btn" data-copy="docker run -p 14000:4000 -v ./config.yaml:/app/config.yaml ghcr.io/berriai/litellm:main-latest --config /app/config.yaml">Copy</button>
|
||||
</div>
|
||||
<pre>docker run -p 14000:4000 \
|
||||
-v ./config.yaml:/app/config.yaml \
|
||||
ghcr.io/berriai/litellm:main-latest \
|
||||
--config /app/config.yaml</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://docs.litellm.ai" target="_blank" rel="noopener">docs.litellm.ai <span class="mdi mdi-open-in-new link-icon"></span></a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h4 class="llm-local-cat" style="margin-top:28px;margin-bottom:12px"><span class="mdi mdi-earth"></span> Cloud APIs <span class="llm-free-badge" style="margin-left:8px;vertical-align:middle">Free tiers</span></h4>
|
||||
@ -492,6 +587,38 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
|
||||
<h4 class="llm-local-cat">Other local TTS</h4>
|
||||
<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× GPU</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-clock-outline"></span> 1–4 s</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-star-circle-outline"></span> Premium</span>
|
||||
<span class="llm-metric-chip"><span class="mdi mdi-memory"></span> ~4 GB VRAM</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Clones a saved WAV voice <strong>and</strong> honours inline emotion markers like <code>(angry)</code>, <code>(whispering)</code>, <code>(excited)</code> per line — consistent speaker identity with real tone control. The only backend that is both WAV-anchored and style-aware.</p>
|
||||
<div class="llm-local-url">
|
||||
<span class="llm-local-url-label">URL</span>
|
||||
<input class="llm-local-url-inp" type="text" placeholder="http://localhost:38080" data-llm-local-key="fishspeech" data-llm-local-default="http://host.docker.internal:38080" spellcheck="false">
|
||||
<button class="llm-local-ping" data-ping-key="fishspeech" title="Test connection">Connect</button>
|
||||
</div>
|
||||
<div class="llm-local-actions">
|
||||
<button class="llm-use-btn" id="llm-use-fishspeech-tts" type="button"><span class="mdi mdi-content-copy"></span> Use as TTS</button>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Docker</span>
|
||||
<button class="llm-copy-btn" data-copy="cd /home/sparky/Docker/fishaudio/s1 && docker compose up -d">Copy</button>
|
||||
</div>
|
||||
<pre>cd fishaudio/s1
|
||||
docker compose up -d # S2-Pro API on :38080</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/fishaudio/fish-speech" target="_blank" rel="noopener">github.com/fishaudio/fish-speech <span class="mdi mdi-open-in-new link-icon"></span></a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-icon"><span class="mdi mdi-speaker-outline"></span></span>
|
||||
|
||||
@ -2,155 +2,524 @@
|
||||
<span class="section-icon"><span class="mdi mdi-theater"></span></span>
|
||||
<div class="section-title">
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<!-- ── 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="card">
|
||||
<div class="reh-lib-header">
|
||||
<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">
|
||||
<span class="mdi mdi-import"></span> Import .reh
|
||||
<input type="file" id="reh-lib-import-file" accept=".reh,.json" style="display:none">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reh-library-list" class="reh-lib-list">
|
||||
<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 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.</p></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 & drop a script here</strong>
|
||||
<span class="note">.txt · .md · .fountain · .pdf · .fdx · .osf · .reh</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="mdi mdi-script-text-outline"></span> New script</h2>
|
||||
<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>
|
||||
<div class="reh-new-script-head">
|
||||
<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">
|
||||
<textarea id="reh-script-text" placeholder="ALICE Hello, how are you today? BOB I'm doing great, thank you for asking! ALICE That's wonderful to hear." spellcheck="false"></textarea>
|
||||
<textarea id="reh-script-text" placeholder="EXT. LOCATION - DAY Scene description goes here. ALICE Hello, how are you? BOB I'm doing great, thank you!" spellcheck="false"></textarea>
|
||||
<div class="reh-input-side">
|
||||
<label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
|
||||
<span class="mdi mdi-upload"></span> Upload .txt
|
||||
<input type="file" id="reh-file-input" accept=".txt,.md" style="display:none">
|
||||
<span class="mdi mdi-upload"></span> .txt / .md
|
||||
<input type="file" id="reh-file-input" accept=".txt,.md,.fountain" style="display:none">
|
||||
</label>
|
||||
<div class="field" style="margin-top:10px">
|
||||
<label style="font-size:12px">Script title (optional)</label>
|
||||
<input type="text" id="reh-script-title" placeholder="My Script">
|
||||
</div>
|
||||
<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-pdf-box"></span> .pdf
|
||||
<input type="file" id="reh-pdf-input" accept=".pdf" style="display:none">
|
||||
</label>
|
||||
<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 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 & cast</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Phase 2: Cast assignment ───────────────────────────────── -->
|
||||
<!-- ── Phase 2: Cast ──────────────────────────────────────────── -->
|
||||
<div class="reh-phase" id="reh-phase-2" hidden>
|
||||
<div class="card">
|
||||
<h2><span class="mdi mdi-account-group-outline"></span> Step 2 — 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>
|
||||
<div class="field" style="margin-bottom:14px">
|
||||
<label>TTS backend for synthesis</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<h2><span class="mdi mdi-account-group-outline"></span> Cast your characters</h2>
|
||||
<p class="card-subtitle">Assign a TTS voice or your mic to each character. Add a speaking-style note per character so the voice matches their personality.</p>
|
||||
<div class="reh-cast2-backend">
|
||||
<div class="field" style="flex:1;min-width:160px">
|
||||
<label>TTS backend</label>
|
||||
<select id="reh-backend-select"><option value="">Checking…</option></select>
|
||||
<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>
|
||||
|
||||
<!-- 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 :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 id="reh-cast-list" class="reh-cast-list"></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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Phase 3: Rehearsal — A4 script view ────────────────────── -->
|
||||
<div class="reh-phase" id="reh-phase-3" hidden>
|
||||
<!-- ── Phase 3: Rehearsal ─────────────────────────────────────── -->
|
||||
<div class="reh-phase reh-stage-wrap" id="reh-phase-3" hidden style="position:relative">
|
||||
|
||||
<!-- Transport controls -->
|
||||
<div class="reh-transport-bar" id="reh-transport-bar">
|
||||
<!-- Cast strip -->
|
||||
<!-- Director's console -->
|
||||
<div class="reh-console" id="reh-transport-bar">
|
||||
<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 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 & 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-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>
|
||||
<!-- 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>
|
||||
<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 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>
|
||||
<button class="btn-secondary btn-sm" id="reh-tb-export" title="Export to .reh file"><span class="mdi mdi-export"></span></button>
|
||||
|
||||
<div class="reh-console-actions">
|
||||
<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 & 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 & 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 & speech recognition"><span class="mdi mdi-school-outline"></span> Train</button>
|
||||
<button class="btn-secondary btn-sm" id="reh-exit-btn">Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A4 script page -->
|
||||
<div class="reh-page-wrap">
|
||||
<div class="reh-a4-page" id="reh-a4-page">
|
||||
<div class="reh-page-title" id="reh-page-title"></div>
|
||||
<div class="reh-script-lines" id="reh-script-lines"></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 & 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>
|
||||
|
||||
<!-- Recording overlay (visible when my turn) -->
|
||||
<div class="reh-rec-overlay" id="reh-rec-overlay" hidden>
|
||||
<div class="reh-rec-overlay-inner">
|
||||
<div class="reh-rec-cue" id="reh-rec-cue"></div>
|
||||
<div class="mic-monitor-box" style="margin-bottom:10px">
|
||||
<div class="mic-monitor-head">
|
||||
<span>Input level</span>
|
||||
<span class="meter-readout" id="reh-db-readout">-∞ dB</span>
|
||||
</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>
|
||||
</div>
|
||||
<div class="btn-row" style="gap:8px">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<audio id="reh-rec-preview" controls style="display:none;margin-top:8px;width:100%"></audio>
|
||||
<div class="btn-row" style="margin-top:6px" id="reh-rec-confirm-row" hidden>
|
||||
<button class="btn-green" id="reh-rec-keep"><span class="mdi mdi-check"></span> Keep & continue</button>
|
||||
<button class="btn-secondary" id="reh-rec-redo"><span class="mdi mdi-refresh"></span> Re-record</button>
|
||||
<!-- 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 -->
|
||||
<div class="reh-page-wrap">
|
||||
<div class="reh-a4-page" id="reh-a4-page">
|
||||
<div class="reh-page-title" id="reh-page-title"></div>
|
||||
<div class="reh-script-lines" id="reh-script-lines"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS playback status (slim bar below transport) -->
|
||||
<!-- TTS now-playing bar -->
|
||||
<div class="reh-tts-status-bar" id="reh-tts-status-bar" hidden>
|
||||
<span id="reh-tts-status-txt"></span>
|
||||
<audio id="reh-tts-audio" controls style="flex:1;min-width:0;max-width:320px"></audio>
|
||||
<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-inner">
|
||||
<div class="reh-rec-cue" id="reh-rec-cue"></div>
|
||||
<div class="reh-rec-controls">
|
||||
<div class="reh-rec-meter-col">
|
||||
<div class="mic-monitor-head">
|
||||
<span>Input level</span>
|
||||
<span class="meter-readout" id="reh-db-readout">-∞ dB</span>
|
||||
</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="40" aria-hidden="true"></canvas>
|
||||
</div>
|
||||
<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-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>
|
||||
<button class="btn-secondary" id="reh-skip-line">Skip</button>
|
||||
</div>
|
||||
<audio id="reh-rec-preview" controls style="display:none;width:100%;margin-top:8px"></audio>
|
||||
<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 & continue</button>
|
||||
<button class="btn-secondary" id="reh-rec-redo"><span class="mdi mdi-refresh"></span> Re-record</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Train Mode panel ─────────────────────────────────────── -->
|
||||
<div class="reh-train-panel" id="reh-train-panel" hidden>
|
||||
<div class="reh-train-header">
|
||||
<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><!-- /phase-3 -->
|
||||
|
||||
<!-- ── Phase 4: Session summary ────────────────────────────────── -->
|
||||
<!-- ── Phase 4: Summary ───────────────────────────────────────── -->
|
||||
<div class="reh-phase" id="reh-phase-4" hidden>
|
||||
<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>
|
||||
<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-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-new-session-btn"><span class="mdi mdi-refresh"></span> New script</button>
|
||||
</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 -->
|
||||
|
||||
@ -150,7 +150,7 @@
|
||||
<label>Groq API key <span class="s-label-note">(Groq Whisper & LLM)</span></label>
|
||||
<div class="s-key-row">
|
||||
<input type="password" id="s-groq-api-key" placeholder="gsk_…" autocomplete="off">
|
||||
<button type="button" class="s-eye-btn" data-target="s-groq-api-key"><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>
|
||||
<span class="s-hint">Used for Groq Whisper STT and LLM. Free: 2 000 req/day.</span>
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,13 @@
|
||||
</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="card">
|
||||
<h2>Get voices</h2>
|
||||
@ -20,10 +27,10 @@
|
||||
</div>
|
||||
<div class="getvoices-toolbar">
|
||||
<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-language-filter"><option value="all">Language: all</option></select>
|
||||
<select id="getvoices-gender-filter"><option value="all">Sex: all</option></select>
|
||||
<select id="getvoices-filetype-filter"><option value="all">Filetype: 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" aria-label="Filter by language"><option value="all">Language: 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" 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">
|
||||
<input type="checkbox" id="getvoices-direct-only"> Direct audio only
|
||||
</label>
|
||||
@ -138,3 +145,101 @@
|
||||
</div>
|
||||
|
||||
</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…" 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 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…" 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>← Prev</button>
|
||||
<span class="el-pager-info" id="fa-pager-info">Page 1</span>
|
||||
<button class="btn-secondary" id="fa-next">Next →</button>
|
||||
</div>
|
||||
</div><!-- /fa-browser-card -->
|
||||
|
||||
@ -13,20 +13,20 @@
|
||||
<div class="card">
|
||||
<h2><span class="mdi mdi-account-voice"></span> Voice & backend</h2>
|
||||
<div class="tryout-voice-row">
|
||||
<div class="field">
|
||||
<div class="field" style="min-width:0">
|
||||
<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 class="field">
|
||||
<div class="field" style="min-width:0">
|
||||
<label>Voice</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-secondary" id="fetch-tts-voices-btn">Fetch voices</button>
|
||||
<select id="tts-voice-select"><option value="">— select after fetch —</option></select>
|
||||
<div class="tryout-voice-pick">
|
||||
<select id="tts-voice-select" aria-label="Voice"><option value="">— fetch or pick a voice —</option></select>
|
||||
<button class="btn-secondary btn-sm" id="fetch-tts-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field" style="min-width:0">
|
||||
<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="streaming">Streaming only</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>
|
||||
<div class="effects-preset-row">
|
||||
<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="studio">Studio Reverb</option>
|
||||
<option value="broadcast">Broadcast (compressed)</option>
|
||||
@ -191,7 +191,7 @@
|
||||
<div class="s-field">
|
||||
<label>Speech recognition</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="s-hint" id="stt-tts-stt-help">Uses Settings → Whisper/STT URL by default.</div>
|
||||
@ -199,7 +199,7 @@
|
||||
<div class="s-field">
|
||||
<label>Speech audio</label>
|
||||
<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-stop" type="button" disabled>Stop</button>
|
||||
<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="field">
|
||||
<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 class="backend-help" id="stt-tts-backend-help" aria-live="polite">
|
||||
<strong>Checking available TTS backends...</strong>
|
||||
@ -264,12 +264,12 @@
|
||||
<label>Backend voice</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-secondary" id="stt-tts-fetch-voices-btn">Fetch voices</button>
|
||||
<select id="stt-tts-voice-select"><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 class="field">
|
||||
<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="streaming">Streaming only</option>
|
||||
<option value="auto">Stream with fallback</option>
|
||||
|
||||
@ -50,12 +50,12 @@
|
||||
<!-- Card 2: filters + sort + voice list + toolbar -->
|
||||
<div class="vl-card vl-card-list">
|
||||
<div class="vl-filters">
|
||||
<input type="search" id="library-filter-text" placeholder="Search voices…" autocomplete="off" class="vl-search">
|
||||
<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>
|
||||
<label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label>
|
||||
</div>
|
||||
<div class="vl-sort-bar">
|
||||
<input type="search" id="library-filter-text" placeholder="🔍 Search voices…" autocomplete="off" class="vl-search vl-search-main">
|
||||
<span class="vl-sort-label">Sort</span>
|
||||
<select id="voice-sort-field" title="Sort field">
|
||||
<option value="id">Name</option>
|
||||
@ -68,6 +68,8 @@
|
||||
<option value="enabled">Active</option>
|
||||
</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-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 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>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
1190
static/style.css
1190
static/style.css
File diff suppressed because it is too large
Load Diff
38
static/sw.js
Normal file
38
static/sw.js
Normal 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
34
tests/bundle.spec.js
Normal 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
21
tests/i18n.spec.js
Normal 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
52
tests/rehearser.spec.js
Normal 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
61
tests/smoke.spec.js
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user