diff --git a/CHANGELOG.md b/CHANGELOG.md
index 339c0dc..325ba18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,15 +7,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
+- **My Voices list header:** a sort header (select-all/none checkbox with a partial-selection state, then sortable Name / Lang / Gender / Speed / Rating with the active column highlighted) sits directly above the voice list, and the filter row starts with a filter icon. The full table header still takes over in table view.
- **QA: Python tests in a throwaway container** (`npm run qa -- --only pyunit --container`): runs the unit tests on production's Python 3.11 in a no-network, read-only container built from the app image on a writable throwaway copy of the read-only mounted repo (`docker/Dockerfile.test` adds only `httpx2` and `nodejs`); opt-in, SKIPPED when docker or the image is missing.
- **Sonar triage `--drain`:** works through open agent issues one at a time, fires the multica webhook, waits for the agent's answer on GitHub and resends once if nothing arrives; stops on a stalled issue.
- **Sonar triage can fire a multica webhook autopilot** after each GitHub issue (URL from `~/.config/multica-webhook` or `MULTICA_WEBHOOK_URL`, passed to curl on stdin only); the multica CLI stays as fallback.
### Changed
+- **My Voices bottom bar simplified:** Refresh, Sync and Import bundle stay as icon buttons (labels as tooltips / for screen readers) so the whole bar fits one row; every selection-based action now lives in one **Actions (N)** menu grouped Edit / Audio / Share / Danger (disabled until voices are checked; closes on pick, outside click or Esc).
+- **My Voices preview text / synth source** ("Synth uses" Preview text / Reference transcript + sample sentence) moved from its own always-visible card into a drawer behind an icon in the list's top bar (state remembered; the icon tooltip shows the active mode). In the stacked/mobile layout the voice list now has a bounded height and the page scrolls, so opening both drawers no longer squeezes the rows or clips the bottom toolbar.
+- **My Voices filter area redesigned:** one search row with a Filters button (badge = number of active filters) plus group-by-tag / table-view buttons. Language, gender, type, tag, rating, group, sort field/order, "Show disabled", "Clear filters" and the Groups accordion moved into a labelled, collapsible panel (remembered per browser); the sort header above the list stays on one line.
+- **My Voices list:** the Groups chip list is a collapsed accordion (summary shows the group count and the active group, with "Show all"); the bulk actions (Set tag, Set source, Hide, Unhide, Rate, Delete) moved from the selection bar into the bottom toolbar; every selection-based button (Export selected, Copy selected, Precompute, Calc dB, Benchmark, Batch seeds, Verify, Set tag, Set source, Hide, Unhide, Rate, Delete) is disabled, with a tooltip, until voices are checked; only Refresh, Sync and Import bundle are always enabled; the redundant All / None buttons are gone (use the header checkbox).
- QA: the Sonar triage script groups medium/large findings per rule instead of per (rule, file): one GitHub issue per rule with a file/line table (max 40 rows, 'and N more'; split into 'part n/m' beyond 40 locations); dedupe marker still carries all Sonar keys.
- Conversation Playground: the round microphone button is red (pressed, "Stop microphone") while the mic is hot (live or recording) and blue ("Start microphone") when off; it returns to blue on stop, auto-stop and mic errors. The pulse ring only animates when reduced motion is not requested.
- Conversation Playground: the metrics (Token Usage and STT / LLM first token / TTS first audio TTFB charts) now sit in the right-hand Latency column below Turn history instead of a second "Metrics" tab; the Conversation | Metrics tablist is removed. Charts gain axis titles ("Time (ms)", "Time"), a legend and three y-ticks.
### Fixed
+- **Status-bar TTS picker did not drive My Voices "Play":** choosing an engine in the status bar updated the preview/reader/rehearser selectors but not the My Voices (library) one, so Play kept using the previously selected engine (e.g. a stopped VoiceClone) and failed with a 500. It now also sets the library and Speech-to-Text-test selectors, and the toast names the engine.
+- **Unhidden voices fell back to the default voice:** unhiding moved the files but never registered the voice in the TTS engine's `voices.json` (only saving a transcript or importing did), so the engine logged "Voice … not configured; falling back to default voice" and spoke with another voice. Unhiding now publishes a missing entry from the voice's reference transcript; existing entries (and their cached speaker embeddings) are left alone.
+- **"No TTS backend" although engines show Connected:** the TTS health probe appended `/health` to the card URL (`…:4000/v1/health` = 404), so every custom TTS engine behind a LiteLLM gateway was judged unreachable. Card URLs ending in `/v1` are now probed from the server root, and for engines behind a LiteLLM gateway the probe asks the gateway (`/model/info`, cached 30 s) for the selected model's upstream and checks that engine itself, so a stopped VoiceClone container is shown as unavailable even though the gateway is up (the gateway's unauthenticated `/health/liveliness` is only the fallback); and "Use as TTS / STT" refreshes the status bar immediately.
+- **Copy buttons did nothing** (My Voices "Copy selected", Connect "Copy active voices", engine/voice-inspector/voice-source copy): `copyText` was no longer exported from utils.js after the module scoping, so the click threw `ReferenceError`. It is exported again, now reports whether the copy succeeded, and "Copy selected" shows a copy icon, a "N voice names copied to clipboard" message, and a dialog with the pre-selected list when the async clipboard API is unavailable or refused (plain-HTTP LAN origin, embedded webviews), since the legacy fallback cannot be verified.
- Sonar: removed 43 small Sonar findings in 20 files: unused variables and dead helper functions (frontend), unused locals and a misread comment (backend); behaviour unchanged. 7 further small findings were deliberately left (used via globals, or entangled with other dead code).
- Conversation Playground: Turn history rows (#n, time, error rows) were in the DOM but squeezed to zero height by the flex column once charts filled the Latency column; the list no longer shrinks.
- Conversation Playground: the metrics charts render at the column width (re-rendered on resize) so axis, tick and legend text is 11px instead of ~8px; a single point is centred with a larger dot.
diff --git a/core/config.py b/core/config.py
index 67a5b29..1ea9d9b 100644
--- a/core/config.py
+++ b/core/config.py
@@ -44,12 +44,12 @@ _SETTINGS_KEYS = {
# Magazine layout is a vision task. Kept separate from the general text
# LLM so page images never accidentally go to a text-only model.
"magazine_layout_url", "magazine_layout_model",
- "vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled",
+ "vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled", "vision_ocr_api_key", "vision_ocr_fallback_api_key",
"inline_tags_enabled", "conversation_thinking",
"vision_ocr_fallback_url", "vision_ocr_fallback_model",
# OPDS catalogues to search for magazine issues: [{"name": ..., "url": ...}], no credentials.
"magazine_opds_catalogs",
- "engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards",
+ "engine_local_urls", "engine_container_names", "engine_api_keys", "engine_models", "stt_models", "tts_models", "custom_engine_cards", "engine_disabled", "engine_active",
# Character portrait generation (cloud APIs + local ComfyUI)
"image_gen_provider", "image_gen_model",
"comfyui_url", "comfyui_workflow", "comfyui_prompt_node_id",
@@ -140,6 +140,8 @@ def _clean_tts_param_dict(raw, fallback: dict | None = None) -> dict:
def _clean_preview_backend(value: str) -> str:
+ if str(value or "").startswith("custom:"):
+ return str(value)
key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_")
aliases = {
"clone": "voice_clone",
@@ -222,6 +224,8 @@ def _strip_tts_extra_params(payload: dict) -> dict:
def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwargs) -> requests.Response:
+ from core.provider_health import provider_request_params
+ payload = provider_request_params(endpoint, payload)
resp = request_with_retries("POST", endpoint, json=payload, headers=headers, **kwargs)
fallback = _strip_tts_extra_params(payload)
if resp.status_code in {400, 404, 415, 422} and fallback != payload:
@@ -235,7 +239,44 @@ def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwarg
return resp
+def _engine_card_api_key(settings: dict, name: str) -> str:
+ """Resolve stable IDs and credentials saved under their own legacy name."""
+ keys = settings.get("engine_api_keys") or {}
+ if name in keys:
+ return str(keys[name] or "").strip()
+ card = next((card for card in settings.get("custom_engine_cards", [])
+ if str(card.get("id") or card.get("name")) == name), None)
+ return str(keys.get(card.get("name")) or "").strip() if card else ""
+
+
+def _tts_api_key(settings: dict, url: str, name: str = "") -> str:
+ """Keep card credentials first and global credentials on their endpoint."""
+ from core.provider_health import provider_key_for_url
+
+ card_key = _engine_card_api_key(settings, name) if name else ""
+ if card_key:
+ return card_key
+ normalized = str(url or "").strip().rstrip("/").removesuffix("/v1")
+ fallback = ""
+ for prefix in ("tts", "llm"):
+ configured = str(settings.get(f"{prefix}_url") or "").strip().rstrip("/").removesuffix("/v1")
+ if normalized and normalized == configured:
+ fallback = str(settings.get(f"{prefix}_api_key") or "").strip()
+ if fallback:
+ break
+ return fallback if name else provider_key_for_url(settings, url, fallback) or fallback
+
+
def _preview_backend_base_url(settings: dict, backend: str) -> str:
+ if backend.startswith("custom:"):
+ identity = backend.removeprefix("custom:")
+ card = next((card for card in settings.get("custom_engine_cards", [])
+ if str(card.get("id") or card.get("name")) == identity and card.get("role") == "tts"), None)
+ if not card:
+ return ""
+ urls = settings.get("engine_local_urls") or {}
+ legacy_name = str(card.get("name") or "")
+ return urls.get("dc-" + identity) or urls.get(identity) or urls.get("dc-" + legacy_name) or urls.get(legacy_name) or card.get("url") or ""
backend = _clean_preview_backend(backend)
if backend == "streaming":
return settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT
@@ -273,10 +314,17 @@ def _normalize_settings(s: dict) -> dict:
s["tts_stream_mode"] = "auto"
if not isinstance(s.get("engine_local_urls"), dict):
s["engine_local_urls"] = {}
+ if not isinstance(s.get("engine_disabled"), dict):
+ s["engine_disabled"] = {}
+ if not isinstance(s.get("engine_active"), dict):
+ s["engine_active"] = {}
if not isinstance(s.get("engine_container_names"), dict):
s["engine_container_names"] = {}
if not isinstance(s.get("engine_api_keys"), dict):
s["engine_api_keys"] = {}
+ for key in ("engine_models", "stt_models", "tts_models"):
+ if not isinstance(s.get(key), dict):
+ s[key] = {}
if not isinstance(s.get("custom_engine_cards"), list):
s["custom_engine_cards"] = []
if not isinstance(s.get("magazine_opds_catalogs"), list):
@@ -353,8 +401,15 @@ def _load_settings() -> dict:
"vision_ocr_fallback_url": _VISION_OCR_FALLBACK_DEFAULT,
"vision_ocr_fallback_model": _VISION_OCR_FALLBACK_MODEL_DEFAULT,
"engine_local_urls": {},
+ "engine_disabled": {},
+ "engine_active": {},
"engine_container_names": {},
"engine_api_keys": {},
+ "engine_models": {},
+ "stt_models": {},
+ "tts_models": {},
+ "vision_ocr_api_key": "",
+ "vision_ocr_fallback_api_key": "",
"custom_engine_cards": [],
"magazine_opds_catalogs": [],
"image_gen_provider": "",
diff --git a/core/magazine.py b/core/magazine.py
index ea8cc07..3167690 100644
--- a/core/magazine.py
+++ b/core/magazine.py
@@ -1269,6 +1269,24 @@ def glued_repetition_share(text: str) -> float:
return covered / len(text)
+from core.provider_health import provider_request_params
+
+
+def _vision_auth_headers(base_url: str) -> dict[str, str]:
+ """Use only the credential belonging to this configured vision endpoint."""
+ from core.config import _load_settings
+
+ settings = _load_settings()
+ for prefix in ("vision_ocr", "vision_ocr_fallback"):
+ configured = str(settings.get(f"{prefix}_url") or "").strip().rstrip("/")
+ if configured and not urlsplit(configured).path:
+ configured += "/v1"
+ if configured == base_url:
+ key = str(settings.get(f"{prefix}_api_key") or "").strip()
+ return {"Authorization": f"Bearer {key}"} if key else {}
+ return {}
+
+
def vision_ocr_page(png_path: Path, base_url: str, model: str,
timeout: float = 150.0) -> list[dict[str, Any]] | None:
"""OCR one rendered page with a connected vision-capable OCR engine.
@@ -1336,7 +1354,7 @@ def vision_ocr_page(png_path: Path, base_url: str, model: str,
def _fetch() -> Any:
resp = request_with_retries(
"POST", f"{base_url.rstrip('/')}/chat/completions",
- json=payload, timeout=timeout, attempts=2,
+ json=provider_request_params(base_url, payload, role="ocr"), timeout=timeout, attempts=2, headers=_vision_auth_headers(base_url),
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
@@ -1648,7 +1666,7 @@ def describe_visual_region(image_path: Path, region_type: str, base_url: str,
def _fetch() -> Any:
resp = request_with_retries(
"POST", f"{base_url.rstrip('/')}/chat/completions",
- json=payload, timeout=timeout, attempts=2,
+ json=provider_request_params(base_url, payload, role="ocr"), timeout=timeout, attempts=2, headers=_vision_auth_headers(base_url),
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
diff --git a/core/provider_health.py b/core/provider_health.py
index 4bb916e..9918c26 100644
--- a/core/provider_health.py
+++ b/core/provider_health.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any, Iterable
+from urllib.parse import urlsplit
import requests
@@ -45,3 +46,140 @@ def probe_provider(
except Exception as exc:
last_error = str(exc)
return {'ok': False, 'error': last_error}
+
+
+def provider_key_for_url(settings: dict, url: str, fallback: str = "") -> str:
+ """Resolve saved card credentials for the endpoint actually being called."""
+ normalized = str(url).rstrip("/").removesuffix("/v1")
+ cards = dict(settings.get("engine_local_urls") or {})
+ for card in settings.get("custom_engine_cards") or []:
+ cards[str(card.get("id") or card.get("name") or "")] = card.get("url", "")
+ keys = settings.get("engine_api_keys") or {}
+ for name, card_url in cards.items():
+ if str(card_url).rstrip("/").removesuffix("/v1") == normalized:
+ return str(keys.get(name.removeprefix("dc-")) or keys.get(name) or "")
+ return fallback
+
+
+def _litellm_model_aliases(base: str, key: str) -> list[str]:
+ """Names of a LiteLLM gateway's router aliases (Model Group Alias), which its /models list omits.
+
+ Best effort: the settings endpoint needs an admin-capable key, and other gateways do not have it.
+ """
+ root = base[:-3] if base.endswith("/v1") else base
+ try:
+ r = requests.get(f"{root}/router/settings", timeout=5, headers={"Authorization": f"Bearer {key}"})
+ if r.status_code != 200:
+ return []
+ data = r.json()
+ except Exception:
+ return []
+ if not isinstance(data, dict):
+ return []
+ for holder in (data.get("current_values"), data.get("router_settings"), data):
+ aliases = holder.get("model_group_alias") if isinstance(holder, dict) else None
+ if isinstance(aliases, dict):
+ return [str(name) for name in aliases if name]
+ return []
+
+
+_PROVIDER_REQUIRED_FIELDS = {
+ 'model', 'messages', 'input', 'text', 'voice', 'stream', 'response_format',
+ 'file', 'files', 'audio', 'audio_prompt', 'audio_prompt_transcript', 'references',
+ 'url', 'base_url', 'endpoint', 'headers', 'method',
+}
+_PROVIDER_SECRET_FIELDS = {
+ 'apikey', 'authorization', 'proxyauthorization', 'password', 'passwd', 'secret',
+ 'token', 'accesstoken', 'refreshtoken', 'privatekey', 'credentials', 'credential',
+ 'xapikey', 'accesskey', 'secretkey',
+}
+
+
+def validate_provider_params(params: Any) -> None:
+ """Keep credentials and required request fields out of optional metadata."""
+ if not isinstance(params, dict):
+ raise ValueError('Additional parameters must be a JSON object')
+ if any(key in _PROVIDER_REQUIRED_FIELDS for key in params):
+ raise ValueError('Additional parameters cannot replace required request fields')
+
+ def check(value):
+ if isinstance(value, dict):
+ for key, item in value.items():
+ normalized = ''.join(character for character in str(key).lower() if character.isalnum())
+ if normalized in _PROVIDER_SECRET_FIELDS or normalized == 'headers' or normalized.endswith(('apikey', 'authorization', 'password', 'secret', 'token', 'secretkey', 'privatekey')):
+ raise ValueError('Credentials must use the API key field')
+ check(item)
+ elif isinstance(value, list):
+ for item in value:
+ check(item)
+ check(params)
+
+
+def engine_disconnected(settings: dict, url: str, role: str = "", identity: str = "") -> bool:
+ """Use explicit selection first; infer legacy endpoints only when unambiguous."""
+ base = str(url).rstrip('/').removesuffix('/v1')
+ urls = dict(settings.get('engine_local_urls') or {})
+ urls.update({str(name).removeprefix('dc-'): value for name, value in list(urls.items()) if str(name).startswith('dc-')})
+ urls = {name: value for name, value in urls.items() if not str(name).startswith('dc-')}
+ from routes.docker import _LOCAL_CONTAINER_DEFS
+ roles = {card['name']: card['role'] for card in _LOCAL_CONTAINER_DEFS}
+ parsed = urlsplit(base)
+ if parsed.hostname in {'localhost', '127.0.0.1', '::1', 'host.docker.internal'}:
+ for card in _LOCAL_CONTAINER_DEFS:
+ if card['port'] == parsed.port:
+ urls.setdefault(card['name'], base)
+ for card in settings.get('custom_engine_cards') or []:
+ name = str(card.get('id') or card.get('name') or '')
+ roles[name] = str(card.get('role') or '')
+ urls.setdefault(name, card.get('url'))
+ if not identity:
+ for setting, selected in (settings.get('engine_active') or {}).items():
+ if str(settings.get(setting) or '').rstrip('/').removesuffix('/v1') == base:
+ if setting == {'llm': 'llm_url', 'ocr': 'vision_ocr_url'}.get(role):
+ selected_url = urls.get(selected)
+ if not selected_url or str(selected_url).rstrip('/').removesuffix('/v1') == base:
+ identity = selected
+ break
+ if not identity:
+ matches = [name for name, value in urls.items() if (not role or not roles.get(name) or role in roles[name].split('+')) and str(value).rstrip('/').removesuffix('/v1') == base]
+ if len(matches) == 1:
+ identity = matches[0]
+ return (settings.get('engine_disabled') or {}).get(identity) is True
+
+
+def provider_request_params(url: str, payload: dict, settings: dict | None = None, role: str = "", identity: str = "") -> dict:
+ """Merge custom endpoint options while preserving required request fields."""
+ if settings is None:
+ from core.config import _load_settings
+ settings = _load_settings()
+ base = str(url).rstrip('/')
+ routes = {'/audio/transcriptions': 'stt', '/audio/speech': 'tts', '/chat/completions': 'llm', '/transcribe': 'stt', '/tts': 'tts'}
+ for suffix, route_role in routes.items():
+ if base.endswith(suffix):
+ base = base[:-len(suffix)]
+ role = role or route_role
+ break
+ base = base.removesuffix('/v1')
+ if engine_disconnected(settings, base, role, identity):
+ raise ValueError('Engine is disconnected; connect it before use')
+ candidates = []
+ urls = settings.get('engine_local_urls') or {}
+ for card in settings.get('custom_engine_cards') or []:
+ card_role = str(card.get('role') or '')
+ if role and card_role and role not in card_role.split('+'):
+ continue
+ card_identity = str(card.get('id') or card.get('name') or '')
+ if identity and identity != card_identity:
+ continue
+ legacy_name = str(card.get('name') or '')
+ configured = urls.get('dc-' + card_identity) or urls.get(card_identity) or urls.get('dc-' + legacy_name) or urls.get(legacy_name) or card.get('url') or ''
+ if str(configured).rstrip('/').removesuffix('/v1') == base:
+ candidates.append(card)
+ if len(candidates) != 1:
+ return payload
+ params = candidates[0].get('params') or {}
+ try:
+ validate_provider_params(params)
+ except ValueError:
+ return payload
+ return {**payload, **params, **{key: value for key, value in payload.items() if key in _PROVIDER_REQUIRED_FIELDS}}
diff --git a/core/routing.py b/core/routing.py
index 2d04f28..37d7e91 100644
--- a/core/routing.py
+++ b/core/routing.py
@@ -39,6 +39,8 @@ def _clean_route_sound(value: str) -> str:
def _clean_route_backend(value: str) -> str:
+ if str(value or "").startswith("custom:"):
+ return str(value)
key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_")
aliases = {
"clone": "voice_clone",
diff --git a/core/tts_helpers.py b/core/tts_helpers.py
index 1e5211c..5dcb2b1 100644
--- a/core/tts_helpers.py
+++ b/core/tts_helpers.py
@@ -7,11 +7,14 @@ import time
from pathlib import Path
import requests
+
+from core.provider_health import provider_key_for_url, provider_request_params
from pydub import AudioSegment
from core.config import (
_clean_preview_backend,
_preview_backend_base_url,
+ _tts_api_key,
_tts_extra_params,
_apply_tts_extra_params,
_post_tts_with_fallback,
@@ -119,6 +122,26 @@ def _audio_duration_from_bytes(audio: bytes, media_type: str) -> float | None:
# ── TTS request config & audio ────────────────────────────────────────────────
+def _tts_selected_model(settings: dict, keys: str | list[str], default: str) -> str:
+ if isinstance(keys, str):
+ keys = [keys]
+ tts_models = settings.get("tts_models") or {}
+ for key in keys:
+ if model := tts_models.get(key):
+ return str(model)
+ engine_models = settings.get("engine_models") or {}
+ for key in keys:
+ if model := engine_models.get(key):
+ return str(model)
+ if key in engine_models:
+ continue
+ card = next((card for card in settings.get("custom_engine_cards", [])
+ if str(card.get("id") or card.get("name")) == key), None)
+ if card and (model := engine_models.get(card.get("name"))):
+ return str(model)
+ return default
+
+
def _tts_request_config(
text: str,
voice: str,
@@ -133,17 +156,35 @@ def _tts_request_config(
from core.constants import _TTS_DEFAULT
from core.validation import _validate_http_url
- tts_url = _validate_http_url(url_override or settings.get("tts_url", _TTS_DEFAULT), allow_private=True).rstrip("/")
backend = backend_override or settings.get("tts_backend", "openai")
- tts_key = (api_key_override if api_key_override is not None else settings.get("tts_api_key", "")).strip()
+ custom_id = backend.removeprefix("custom:") if backend.startswith("custom:") else ""
+ source_url = _preview_backend_base_url(settings, backend) if backend.startswith("custom:") else url_override or settings.get("tts_url", _TTS_DEFAULT)
+ tts_url = _validate_http_url(source_url, allow_private=True).rstrip("/")
+ tts_key = str(api_key_override).strip() if api_key_override is not None else _tts_api_key(settings, tts_url, custom_id)
tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {}
if backend == "localai":
endpoint, payload = f"{tts_url}/tts", {"input": text, "model": voice, "response_format": response_format}
elif backend == "pocket":
- endpoint, payload = f"{tts_url}/v1/audio/speech", {"input": text, "voice": voice, "response_format": response_format}
+ endpoint, payload = f"{tts_url.removesuffix('/v1')}/v1/audio/speech", {"input": text, "voice": voice, "response_format": response_format}
else:
- endpoint, payload = f"{tts_url}/v1/audio/speech", {"model": "tts-1", "input": text, "voice": voice, "response_format": response_format}
+ backend_to_key = {
+ "streaming": "tts_stream_url",
+ "customvoice": "customvoice_url",
+ "nvidia_magpie": "nvidia_tts_url",
+ "kokoro": "kokoro_url",
+ "vibevoice": "vibevoice_url",
+ "xtts": "xtts_url",
+ }
+ if backend.startswith("custom:"):
+ c_id = backend.removeprefix("custom:")
+ model = _tts_selected_model(settings, c_id, "tts-1") # engine_models uses the card ID!
+ endpoint, payload = f"{tts_url.removesuffix('/v1')}/v1/audio/speech", {"model": model, "input": text, "voice": voice, "response_format": response_format}
+ else:
+ bkey = backend_to_key.get(backend, "tts_url")
+ # Fall back to engine_models too if tts_models missed it (we can check both)
+ model = settings.get("tts_models", {}).get(bkey) or _tts_selected_model(settings, [bkey, "tts_url"], "tts-1")
+ endpoint, payload = f"{tts_url.removesuffix('/v1')}/v1/audio/speech", {"model": model, "input": text, "voice": voice, "response_format": response_format}
if instruct.strip():
payload["instruct"] = instruct.strip()
# Include language derived from voice ID prefix so the TTS server knows what
@@ -157,7 +198,7 @@ def _tts_request_config(
_apply_tts_extra_params(payload, settings, _eff_backend)
if _eff_backend in ("voice_clone", "streaming", "customvoice"):
_apply_voice_pinned_seed(payload, voice, settings)
- return endpoint, payload, tts_hdrs
+ return endpoint, provider_request_params(tts_url, payload, settings, "tts", custom_id), tts_hdrs
def _tts_request_audio(
@@ -191,12 +232,16 @@ def _tts_stream_request_config(text: str, voice: str, settings: dict, instruct:
stream_url = settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT
tts_url = _validate_http_url(stream_url, allow_private=True).rstrip("/")
tts_key = settings.get("tts_api_key", "").strip()
+ fallback_url = str(settings.get("tts_url") or "").rstrip("/").removesuffix("/v1")
+ if tts_url.rstrip("/").removesuffix("/v1") != fallback_url:
+ tts_key = ""
+ tts_key = provider_key_for_url(settings, tts_url, tts_key)
tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {}
- payload = {"model": "tts-1", "input": text, "voice": voice, "response_format": "wav"}
+ payload = {"model": _tts_selected_model(settings, ["tts_stream_url", "tts_url"], "tts-1"), "input": text, "voice": voice, "response_format": "wav"}
if instruct.strip():
payload["instruct"] = instruct.strip()
_apply_tts_extra_params(payload, settings, "streaming")
- return f"{tts_url}/v1/audio/speech", payload, tts_hdrs
+ return f"{tts_url.removesuffix('/v1')}/v1/audio/speech", provider_request_params(tts_url, payload, settings, "tts"), tts_hdrs
def _open_tts_stream_response(text: str, voice: str, settings: dict, instruct: str = "") -> requests.Response:
@@ -265,9 +310,10 @@ def _voice_design_request_audio(
from core.validation import _validate_http_url
vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/")
vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip()
+ vd_key = provider_key_for_url(settings, vd_url, vd_key)
vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {}
payload = {
- "model": _VOICE_DESIGN_MODEL,
+ "model": _tts_selected_model(settings, "voice_design_url", _VOICE_DESIGN_MODEL),
"input": text,
"instruct": _apply_voice_design_gender(instruct, gender),
"language": language,
@@ -293,9 +339,10 @@ def _voice_design_voice_request_audio(
from core.validation import _validate_http_url
vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/")
vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip()
+ vd_key = provider_key_for_url(settings, vd_url, vd_key)
vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {}
payload = {
- "model": _VOICE_DESIGN_MODEL,
+ "model": _tts_selected_model(settings, "voice_design_url", _VOICE_DESIGN_MODEL),
"input": text,
"voice": voice,
"response_format": "wav",
@@ -341,7 +388,7 @@ def _nvidia_clone_request_audio(
mode = "flow" if str(mode).lower().endswith("flow") else "zeroshot"
backend = "nvidia_flow" if mode == "flow" else "nvidia_zeroshot"
base_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
- key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip()
+ key = provider_key_for_url(settings, base_url)
headers = {"Authorization": f"Bearer {key}"} if key else {}
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio_path = _find_voice_audio(voice, scan_dir)
@@ -493,7 +540,9 @@ def _fishspeech_request_audio(
# room to vary). Every other backend already routes through this same helper; Fish
# was the one exception.
_apply_tts_extra_params(payload, settings, "fishspeech")
- resp = _post_tts_with_fallback(f"{base_url}/v1/tts", payload, {}, timeout=180)
+ key = provider_key_for_url(settings, base_url)
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
+ resp = _post_tts_with_fallback(f"{base_url}/v1/tts", payload, headers, timeout=180)
resp.raise_for_status()
audio = resp.content
if not audio or len(audio) < 256:
@@ -506,7 +555,9 @@ def _fishspeech_request_audio(
def _vibevoice_request_audio(text: str, settings: dict) -> tuple[bytes, str]:
url = _preview_backend_base_url(settings, "vibevoice").rstrip("/")
- resp = requests.post(f"{url}/tts", json={"text": text}, timeout=60)
+ key = provider_key_for_url(settings, url)
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
+ resp = requests.post(f"{url}/tts", json=provider_request_params(url, {"text": text}, settings, "tts"), headers=headers, timeout=60)
resp.raise_for_status()
audio = resp.content
if not audio:
@@ -525,6 +576,8 @@ def _preview_request_audio(
backend: str = "voice_clone",
) -> tuple[bytes, str]:
backend = _clean_preview_backend(backend)
+ if backend.startswith("custom:"):
+ return _tts_request_audio(text, voice, settings, instruct, backend_override=backend, extra_backend=backend)
if backend == "streaming":
resp = _open_tts_stream_response(text, voice, settings, instruct)
audio, media_type = _read_tts_stream_response(resp)
@@ -535,7 +588,7 @@ def _preview_request_audio(
return _tts_request_audio(
text, voice, settings, instruct,
_preview_backend_base_url(settings, "customvoice"),
- settings.get("tts_api_key", ""),
+ None,
"openai",
"customvoice",
)
@@ -554,7 +607,7 @@ def _preview_request_audio(
return _tts_request_audio(
text, voice, settings, instruct,
_preview_backend_base_url(settings, "nvidia_magpie"),
- settings.get("tts_api_key", ""),
+ None,
"nvidia_magpie",
"nvidia_magpie",
)
@@ -566,7 +619,7 @@ def _preview_request_audio(
return _tts_request_audio(
text, voice, settings, instruct,
url_override=_preview_backend_base_url(settings, "kokoro"),
- api_key_override=settings.get("tts_api_key", ""),
+ api_key_override=None,
backend_override="openai",
extra_backend="kokoro",
)
@@ -578,7 +631,7 @@ def _preview_request_audio(
return _tts_request_audio(
text, voice, settings, instruct,
url_override=_preview_backend_base_url(settings, "xtts"),
- api_key_override=settings.get("tts_api_key", ""),
+ api_key_override=None,
backend_override="openai",
extra_backend="xtts",
)
diff --git a/routes/conversation.py b/routes/conversation.py
index 6aa98a4..9c3d878 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -398,9 +398,13 @@ def _usage_from_chunk(obj: dict) -> dict | None:
return out or None
+from core.provider_health import provider_request_params
+
+
def _post_llm_stream(url: str, payload: dict, headers: dict):
"""POST a streaming chat request; on 400/422 retry once without the optional keys
(thinking switch, stream_options) that older backends reject."""
+ payload = provider_request_params(url, payload)
resp = requests.post(url, json=payload, headers=headers, stream=True, timeout=120)
optional = [k for k in ("chat_template_kwargs", "think", "stream_options") if k in payload]
if resp.status_code in (400, 422) and optional:
@@ -429,6 +433,7 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str
if model:
payload["model"] = model
payload.update(_thinking_off_params(settings, llm_url))
+ payload = provider_request_params(llm_url, payload, settings, "llm")
url = f"{llm_url.rstrip('/')}/chat/completions"
hdrs = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
resp = requests.post(url, json=payload, headers=hdrs, timeout=60)
@@ -488,6 +493,7 @@ def _llm_chat_completion_urls(llm_url: str) -> list[str]:
def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeout: float):
+ payload = provider_request_params(llm_url, payload, role="llm")
last_resp = None
last_exc = None
for url in _llm_chat_completion_urls(llm_url):
@@ -745,7 +751,7 @@ async def refine_text(request: Request):
resp = await asyncio.to_thread(
requests.post,
f"{llm_url}/chat/completions",
- json=payload,
+ json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60,
)
@@ -807,7 +813,7 @@ async def rewrite_with_persona(request: Request):
resp = await asyncio.to_thread(
requests.post,
f"{llm_url}/chat/completions",
- json=payload,
+ json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
@@ -897,7 +903,7 @@ async def analyze_characters(request: Request):
payload["model"] = model
resp = requests.post(
f"{llm_url}/chat/completions",
- json=payload,
+ json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
@@ -1045,7 +1051,7 @@ async def match_characters_voices(request: Request):
try:
resp = await asyncio.to_thread(
requests.post,
- f"{llm_url}/chat/completions", json=payload,
+ f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
@@ -1571,7 +1577,7 @@ async def character_sheets_stream(request: Request):
for u in urls:
try:
upstream = requests.post(
- u, json=payload, headers=headers,
+ u, json=provider_request_params(u, payload), headers=headers,
timeout=(15, prep["timeout_seconds"]), stream=True,
)
if upstream.status_code in (404, 405) and u != urls[-1]:
@@ -1696,7 +1702,7 @@ async def character_deep_analysis(request: Request):
try:
resp = await asyncio.to_thread(
requests.post,
- f"{llm_url}/chat/completions", json=payload,
+ f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
@@ -3607,7 +3613,7 @@ async def character_generate_prompts(request: Request):
payload["model"] = model
try:
resp = requests.post(
- f"{llm_url}/chat/completions", json=payload,
+ f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
@@ -3806,7 +3812,7 @@ async def audiobook_consistency_check(request: Request):
def _call() -> dict:
resp = requests.post(
- f"{llm_url}/chat/completions", json=payload,
+ f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
@@ -5384,7 +5390,7 @@ async def attribute_dialogue_stream(request: Request):
for u in urls:
try:
upstream = requests.post(
- u, json=payload, headers=headers,
+ u, json=provider_request_params(u, payload), headers=headers,
timeout=(15, prep["timeout_seconds"]), stream=True,
)
if upstream.status_code in (404, 405) and u != urls[-1]:
@@ -5598,6 +5604,7 @@ async def audio_effects(request: Request):
_EXPORT_SKIP_KEYS = {
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
"elevenlabs_api_key", "llm_api_key", "engine_api_keys",
+ "vision_ocr_api_key", "vision_ocr_fallback_api_key",
}
_IMPORT_ALLOWED_SUFFIXES = set(_AUDIO_EXTS + [".reference.txt", ".meta.json", ".jpg", ".jpeg", ".png", ".webp"])
@@ -6058,26 +6065,7 @@ async def mcp_sse(request: Request):
# ── Conversation playground ───────────────────────────────────────────────────
-def _litellm_model_aliases(base: str, key: str) -> list[str]:
- """Names of a LiteLLM gateway's router aliases (Model Group Alias), which its /models list omits.
-
- Best effort: the settings endpoint needs an admin-capable key, and other gateways do not have it.
- """
- root = base[:-3] if base.endswith("/v1") else base
- try:
- r = requests.get(f"{root}/router/settings", timeout=5, headers={"Authorization": f"Bearer {key}"})
- if r.status_code != 200:
- return []
- data = r.json()
- except Exception:
- return []
- if not isinstance(data, dict):
- return []
- for holder in (data.get("current_values"), data.get("router_settings"), data):
- aliases = holder.get("model_group_alias") if isinstance(holder, dict) else None
- if isinstance(aliases, dict):
- return [str(name) for name in aliases if name]
- return []
+from core.provider_health import _litellm_model_aliases
@router.get("/api/conversation/llm-models")
@@ -6089,6 +6077,9 @@ async def conversation_llm_models(
from core.validation import _validate_http_url
settings = _load_settings()
base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
+ from core.provider_health import engine_disconnected
+ if engine_disconnected(settings, base, "llm"):
+ return {"models": [], "url": base, "error": "Disconnected", "connected": False}
key_to_use = provider_api_key or settings.get("llm_api_key") or "sk-dummy-key"
try:
base = _validate_http_url(base, allow_private=True)
diff --git a/routes/docker.py b/routes/docker.py
index e68c324..d8253e0 100644
--- a/routes/docker.py
+++ b/routes/docker.py
@@ -4,15 +4,15 @@ from __future__ import annotations
import asyncio
import os
from pathlib import Path
-from urllib.parse import quote
+from urllib.parse import quote, urlsplit
import requests
from fastapi import APIRouter, Header, HTTPException, Request
from core.docker_client import _docker_available, _docker_get_json, _docker_post
-from core.config import _load_settings, _save_settings, _normalize_settings
+from core.config import _load_settings, _save_settings, _normalize_settings, _tts_api_key, _engine_card_api_key
from core.validation import _normalize_service_url
-from core.provider_health import probe_provider
+from core.provider_health import probe_provider, _litellm_model_aliases
router = APIRouter()
@@ -123,6 +123,71 @@ async def restart_local_container(name: str):
return {"ok": True, "name": name, **_container_status(name)}
+def _engine_api_key(settings: dict, name: str, role: str, supplied: str = "", url: str = "") -> str:
+ card_key = _engine_card_api_key(settings, name)
+ if role == "tts":
+ return supplied or _tts_api_key(settings, url, name) or "sk-dummy-key"
+ if name:
+ return supplied or card_key or "sk-dummy-key"
+ fallback = {
+ "anthropic": "llm_api_key", "llm": "llm_api_key", "openai": "llm_api_key",
+ "ollama": "llm_api_key", "stt": "whisper_api_key", "tts": "tts_api_key",
+ "ocr": "vision_ocr_api_key",
+ }.get(role, "")
+ key = supplied or card_key or settings.get(fallback, "")
+ return key or "sk-dummy-key"
+
+
+def _engine_headers(key: str, role: str) -> dict:
+ if role == "anthropic":
+ return {"x-api-key": key, "anthropic-version": "2023-06-01"}
+ return {"Authorization": f"Bearer {key}"}
+
+
+@router.post("/api/local-containers/{name}/connection")
+async def engine_connection(name: str, request: Request):
+ body = await request.json()
+ if not isinstance(body, dict) or not isinstance(body.get("connected"), bool):
+ raise HTTPException(400, "connected must be a boolean")
+ settings = _load_settings()
+ known = {card["name"] for card in _LOCAL_CONTAINER_DEFS}
+ known.update(str(card.get("id") or card.get("name")) for card in settings.get("custom_engine_cards", []))
+ if name not in known:
+ raise HTTPException(404, "Unknown engine")
+ active = dict(settings.get("engine_active") or {})
+ urls = dict(settings.get("engine_local_urls") or {})
+ urls.update({str(identity).removeprefix("dc-"): url for identity, url in list(urls.items()) if str(identity).startswith("dc-")})
+ urls = {identity: url for identity, url in urls.items() if not str(identity).startswith("dc-")}
+ roles = {card["name"]: card["role"] for card in _LOCAL_CONTAINER_DEFS}
+ for card in settings.get("custom_engine_cards", []):
+ identity = str(card.get("id") or card.get("name"))
+ urls.setdefault(identity, card.get("url"))
+ roles[identity] = card.get("role", "")
+ def endpoint(url):
+ return str(url or "").rstrip("/").removesuffix("/v1")
+ for role, setting in (("llm", "llm_url"), ("ocr", "vision_ocr_url")):
+ selected_url = endpoint(settings.get(setting))
+ if setting not in active and selected_url:
+ candidates = dict(urls)
+ parsed = urlsplit(selected_url)
+ if parsed.hostname in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}:
+ for card in _LOCAL_CONTAINER_DEFS:
+ if card["port"] == parsed.port:
+ candidates.setdefault(card["name"], selected_url)
+ matches = [identity for identity, url in candidates.items() if role in roles.get(identity, "").split("+") and endpoint(url) == selected_url]
+ if matches == [name]:
+ active[setting] = name
+ settings["engine_active"] = active
+ disabled = dict(settings.get("engine_disabled") or {})
+ if body["connected"]:
+ disabled.pop(name, None)
+ else:
+ disabled[name] = True
+ settings["engine_disabled"] = disabled
+ _save_settings(_normalize_settings(settings))
+ return {"ok": True, "connected": body["connected"]}
+
+
@router.get("/api/probe-url")
async def probe_url(url: str, type: str = "", name: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")):
"""Server-side API probe — checks service-specific endpoints and validates JSON responses."""
@@ -132,30 +197,8 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
base = base[: -len(_suffix)]
break
settings = _load_settings()
- # Key precedence:
- # 1. A key supplied explicitly by the browser for this probe (e.g. the
- # user just typed/edited it and it isn't saved yet).
- # 2. The key saved for this specific engine card (settings.engine_api_keys[name]).
- # Card values are masked to "__configured__" before ever reaching the
- # browser, so on a normal page load the browser has no real secret to
- # forward — the backend must resolve it itself from `name`.
- # 3. The old global llm_api_key, which may belong to a different
- # gateway after that card's own key was rotated.
- card_key = (settings.get("engine_api_keys") or {}).get(name) if name else None
- key_to_use = provider_api_key or card_key or {
- "anthropic": settings.get("llm_api_key"),
- "llm": settings.get("llm_api_key"),
- "openai": settings.get("llm_api_key"),
- "stt": settings.get("groq_api_key") or settings.get("whisper_api_key"),
- "tts": settings.get("tts_api_key"),
- }.get(type) or "sk-dummy-key"
- # Anthropic doesn't speak the OpenAI-style Bearer auth every other card
- # here uses — it needs x-api-key + an anthropic-version header, so the
- # generic Bearer probe below would 401 even with a perfectly valid key.
- if type == "anthropic":
- hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "x-api-key": key_to_use, "anthropic-version": "2023-06-01"}
- else:
- hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"}
+ key_to_use = _engine_api_key(settings, name, type, provider_api_key, base)
+ hdrs = {"User-Agent": "TTS-Voice-Creator/probe", **_engine_headers(key_to_use, type)}
if type == "anthropic":
checks = [("/v1/models", "data")]
@@ -164,7 +207,7 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
elif type == "stt":
checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)]
elif type == "tts":
- checks = [("/health", None), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)]
+ checks = [("/health", None), ("/v1/models", "data"), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)]
elif type == "ocr":
checks = [("/health", None), ("/v1/models", "data"), ("/v1/chat/completions", None), ("/parse", None), ("/ocr", None)]
elif type == "comfyui":
@@ -172,7 +215,7 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
else:
checks = [("", None)]
- result = probe_provider(base, checks, headers=hdrs, timeout=5)
+ result = await asyncio.to_thread(probe_provider, base, checks, headers=hdrs, timeout=5)
return {key: value for key, value in result.items() if key != 'payload'}
@@ -193,12 +236,15 @@ async def use_as_llm(name: str, request: Request, provider_api_key: str = Header
raise HTTPException(400, "URL is required")
s = _load_settings()
- card_key = (s.get("engine_api_keys") or {}).get(name)
+ card_key = _engine_card_api_key(s, name)
key_to_use = provider_api_key or card_key or ""
s["llm_url"] = url
+ s["engine_active"] = {**(s.get("engine_active") or {}), "llm_url": name}
+ s["engine_disabled"] = {key: value for key, value in (s.get("engine_disabled") or {}).items() if key != name}
+ s["llm_api_key"] = key_to_use
+ s["llm_model"] = str(body.get("model") or "").strip()
if key_to_use:
- s["llm_api_key"] = key_to_use
if provider_api_key:
# Keep this card's own saved key in sync with what was just typed.
engine_keys = dict(s.get("engine_api_keys") or {})
@@ -209,15 +255,68 @@ async def use_as_llm(name: str, request: Request, provider_api_key: str = Header
return {"ok": True, "llm_url": url, "key_set": bool(key_to_use)}
+@router.post("/api/local-containers/{name}/use-as-engine")
+async def use_as_engine(name: str, request: Request, provider_api_key: str = Header("", alias="X-Provider-API-Key")):
+ body = await request.json()
+ if not isinstance(body, dict):
+ raise HTTPException(400, "JSON object is required")
+ role = str(body.get("role") or "")
+ setting_key = str(body.get("setting_key") or "")
+ targets = {
+ "stt": {"whisper_url", "faster_whisper_url", "whisper_cpp_url", "nvidia_asr_url", "nvidia_router_url"},
+ "tts": {"tts_url", "tts_stream_url", "voice_design_url", "customvoice_url", "kokoro_url", "vibevoice_url", "xtts_url", "fishspeech_url", "nvidia_tts_url", "nvidia_clone_url", "nvidia_zeroshot_url", "nvidia_flow_url"},
+ "ocr": {"vision_ocr_url"},
+ }
+ if setting_key not in targets.get(role, set()):
+ raise HTTPException(400, "Unsupported engine setting")
+ url = _normalize_service_url(str(body.get("url") or "")).strip()
+ if not url:
+ raise HTTPException(400, "URL is required")
+ settings = _load_settings()
+ key = provider_api_key or _engine_card_api_key(settings, name)
+ if role == "tts" and not key:
+ key = _tts_api_key(settings, url, name)
+ settings[setting_key] = url
+ settings["engine_active"] = {**(settings.get("engine_active") or {}), setting_key: name}
+ settings["engine_disabled"] = {key: value for key, value in (settings.get("engine_disabled") or {}).items() if key != name}
+ settings["engine_local_urls"] = {**(settings.get("engine_local_urls") or {}), name: url}
+ credential_setting = "voice_design_api_key" if setting_key == "voice_design_url" else {
+ "stt": "whisper_api_key", "tts": "tts_api_key", "ocr": "vision_ocr_api_key",
+ }[role]
+ if setting_key in {"whisper_url", "tts_url", "voice_design_url", "vision_ocr_url"}:
+ settings[credential_setting] = key
+ settings["engine_api_keys"] = {**(settings.get("engine_api_keys") or {}), name: key}
+ model = str(body.get("model") or "").strip()
+ settings["engine_models"] = {**(settings.get("engine_models") or {}), name: model}
+ if role == "ocr":
+ from core.constants import _VISION_OCR_MODEL_DEFAULT
+ settings["vision_ocr_model"] = model or _VISION_OCR_MODEL_DEFAULT
+ elif role == "tts":
+ settings["tts_models"] = {**(settings.get("tts_models") or {}), setting_key: model}
+ else:
+ backend = {"whisper_url": "configured", "faster_whisper_url": "faster_whisper", "whisper_cpp_url": "whisper_cpp", "nvidia_asr_url": "nvidia_parakeet", "nvidia_router_url": "nvidia_router"}[setting_key]
+ settings["stt_models"] = {**(settings.get("stt_models") or {}), backend: model}
+ if body.get("activate") is True:
+ if role == "stt":
+ custom = any(str(card.get("id") or card.get("name")) == name for card in settings.get("custom_engine_cards", []))
+ settings["stt_preferred_backend"] = "custom:" + name if custom else backend
+ elif role == "tts":
+ settings["tts_backend"] = "openai"
+ elif role == "ocr":
+ settings["vision_ocr_enabled"] = True
+ _save_settings(_normalize_settings(settings))
+ return {"ok": True, "key_set": bool(key)}
+
+
@router.get("/api/engine-models")
-async def engine_models(url: str, type: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")):
+async def engine_models(url: str, type: str = "", name: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")):
"""Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser."""
base = _normalize_service_url(url).rstrip("/")
if not base:
raise HTTPException(400, "URL is required")
- candidates: list[str]
+ candidates: list[str] = []
if base.endswith("/v1"):
- candidates = [base + "/models", base[:-3].rstrip("/") + "/v1/models"]
+ candidates = [base + "/models", base[:-3].rstrip("/") + "/models"]
else:
candidates = [base.rstrip("/") + "/v1/models", base.rstrip("/") + "/models"]
if type == "ollama":
@@ -226,10 +325,10 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header
seen: set[str] = set()
models: list[str] = []
settings = _load_settings()
- key_to_use = settings.get("llm_api_key") or provider_api_key or "sk-dummy-key"
+ key_to_use = _engine_api_key(settings, name, type, provider_api_key, base)
for endpoint in candidates:
try:
- r = await asyncio.to_thread(requests.get, endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"})
+ r = await asyncio.to_thread(requests.get, endpoint, timeout=6, headers={**_engine_headers(key_to_use, type), "User-Agent": "TTS-Voice-Creator/models"})
if r.status_code >= 400:
last_err = f"HTTP {r.status_code} from {endpoint}"
continue
@@ -253,6 +352,9 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header
seen.add(mid)
models.append(mid)
if models:
+ if type in {"llm", "openai", "ollama"}:
+ aliases = await asyncio.to_thread(_litellm_model_aliases, base, key_to_use)
+ models = [alias for alias in aliases if alias not in models] + models
return {"ok": True, "url": base, "endpoint": endpoint, "models": models}
except requests.exceptions.Timeout:
last_err = "Timeout fetching models"
diff --git a/routes/library.py b/routes/library.py
index 844fb79..0a3eaf7 100644
--- a/routes/library.py
+++ b/routes/library.py
@@ -460,6 +460,13 @@ async def update_voice_meta(request: Request):
wav = _move_voice_package(wav, _active_voices_dir(settings) if enabled else _hidden_voices_dir(settings))
meta = _load_meta(wav)
meta["enabled"] = enabled
+ if enabled:
+ # Unhiding must make the voice usable: the engine only knows voices listed in its voices.json.
+ try:
+ from routes.tts import publish_voice_clone_if_missing
+ await asyncio.to_thread(publish_voice_clone_if_missing, wav, str(meta.get("lang") or ""), settings)
+ except Exception:
+ pass # best-effort; the voice is already enabled in our own library
for field in ("note", "rating", "flag", "gender", "age", "lang", "language", "loudness", "persona", "origin", "group",
"name", "tag", "source", "avatar", "voice_design_prompt"):
diff --git a/routes/settings.py b/routes/settings.py
index c498f3c..4b3b363 100644
--- a/routes/settings.py
+++ b/routes/settings.py
@@ -14,6 +14,7 @@ router = APIRouter()
_SECRET_SETTING_KEYS = {
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
"elevenlabs_api_key", "llm_api_key", "engine_api_keys", "external_api_key",
+ "vision_ocr_api_key", "vision_ocr_fallback_api_key",
}
_SECRET_MASK = "__configured__"
@@ -55,7 +56,21 @@ async def regenerate_api_key(response: Response):
@router.post("/api/settings")
async def post_settings(request: Request):
data = await request.json()
+ if not isinstance(data, dict):
+ raise HTTPException(400, "JSON object is required")
+ if "custom_engine_cards" in data:
+ from core.provider_health import validate_provider_params
+ cards = data["custom_engine_cards"]
+ if not isinstance(cards, list) or any(not isinstance(card, dict) for card in cards):
+ raise HTTPException(400, "Custom engine cards must be a list of objects")
+ for card in cards:
+ try:
+ validate_provider_params(card.get("params", {}))
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from None
s = _load_settings()
+ previous_cards = {str(card.get("id") or card.get("name") or ""): card for card in s.get("custom_engine_cards", []) if isinstance(card, dict)}
+ previous_maps = {key: dict(s.get(key) or {}) for key in ("engine_api_keys", "engine_models", "engine_local_urls")}
clear = {str(k) for k in (data.get("clear_secrets") or [])} if isinstance(data.get("clear_secrets"), list) else set()
for key, value in data.items():
if key not in _SETTINGS_KEYS:
@@ -76,6 +91,27 @@ async def post_settings(request: Request):
if value in (None, "") and key not in clear:
continue
s[key] = value
+ if isinstance(data.get("custom_engine_cards"), list):
+ for card in data["custom_engine_cards"]:
+ if not isinstance(card, dict):
+ continue
+ identity = str(card.get("id") or card.get("name") or "")
+ previous = previous_cards.get(identity)
+ if not previous:
+ continue
+ old_name = str(previous.get("name") or "")
+ for key in previous_maps:
+ source = "dc-" + old_name if key == "engine_local_urls" else old_name
+ target = "dc-" + identity if key == "engine_local_urls" else identity
+ values = dict(s.get(key) or {})
+ if key == "engine_api_keys" and (identity in clear or old_name in clear):
+ continue
+ if target not in values:
+ if target in previous_maps[key]:
+ values[target] = previous_maps[key][target]
+ elif source in previous_maps[key]:
+ values[target] = values.get(source, previous_maps[key][source])
+ s[key] = values
s = _normalize_settings(s)
_save_settings(s)
return {"ok": True}
diff --git a/routes/stt.py b/routes/stt.py
index db19f20..4c78482 100644
--- a/routes/stt.py
+++ b/routes/stt.py
@@ -21,7 +21,7 @@ from core.constants import (
)
from core.registry import _registry_get, TEMP_DIR
from core.validation import _validate_http_url, _copy_limited, _validated_upload_suffix
-from core.provider_health import probe_provider
+from core.provider_health import probe_provider, provider_key_for_url, provider_request_params, engine_disconnected
from core.audio import _to_wav_16k
from core.database import background_job_create, background_job_get, background_job_update
from core.voice import _AUDIO_EXTS, _UPLOAD_EXTS
@@ -152,7 +152,9 @@ def _stt_backend_url(settings: dict, backend: str) -> str:
custom_id = backend[len("custom:"):]
for card in settings.get("custom_engine_cards", []):
if str(card.get("id", card.get("name", ""))) == custom_id:
- return card.get("url", "")
+ urls = settings.get("engine_local_urls") or {}
+ name = str(card.get("name") or "")
+ return urls.get("dc-" + custom_id) or urls.get(custom_id) or urls.get("dc-" + name) or urls.get(name) or card.get("url", "")
return ""
if backend == "nvidia_parakeet":
return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT
@@ -183,8 +185,18 @@ def _stt_backend_configured(settings: dict, backend: str) -> bool:
return any(str(settings.get(key) or '').strip() for key in keys)
-def _stt_backend_model(backend: str) -> str:
+def _stt_backend_model(backend: str, settings: dict | None = None) -> str:
backend = _clean_stt_backend(backend)
+ settings = settings if settings is not None else _load_settings()
+ selected = (settings.get("stt_models") or {}).get(backend)
+ if backend.startswith("custom:"):
+ identity = backend[7:]
+ card = next((card for card in settings.get("custom_engine_cards", [])
+ if str(card.get("id") or card.get("name") or "") == identity), {})
+ models = settings.get("engine_models") or {}
+ selected = models.get(identity, models.get(card.get("name")) or selected)
+ if selected:
+ return str(selected)
if backend in {"nvidia_parakeet", "nvidia_router"}:
return "whisper-1"
if backend == "whisper_cpp":
@@ -228,7 +240,24 @@ def _stt_backend_label(backend: str, url: str) -> str:
def _stt_backend_api_key(settings: dict, backend: str) -> str:
if backend == "groq_whisper":
return settings.get("groq_api_key", "").strip()
- return settings.get("whisper_api_key", "").strip()
+ if backend.startswith("custom:"):
+ from core.config import _engine_card_api_key
+ return _engine_card_api_key(settings, backend[7:])
+ setting = {"configured": "whisper_url", "faster_whisper": "faster_whisper_url", "whisper_cpp": "whisper_cpp_url", "nvidia_parakeet": "nvidia_asr_url", "nvidia_router": "nvidia_router_url"}.get(backend)
+ selected = (settings.get("engine_active") or {}).get(setting)
+ if selected:
+ from core.config import _engine_card_api_key
+ card_key = _engine_card_api_key(settings, selected)
+ if card_key:
+ return card_key
+ if any(selected == str(card.get("id") or card.get("name") or "") for card in settings.get("custom_engine_cards", [])):
+ return ""
+ url = _stt_backend_url(settings, backend).rstrip("/").removesuffix("/v1")
+ configured_url = str(settings.get("whisper_url") or "").rstrip("/").removesuffix("/v1")
+ return str(settings.get("whisper_api_key") or "").strip() if url == configured_url else ""
+ url = _stt_backend_url(settings, backend)
+ fallback = settings.get("whisper_api_key", "") if url.rstrip("/") == str(settings.get("whisper_url") or "").rstrip("/") else ""
+ return provider_key_for_url(settings, url, fallback).strip()
def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes:
@@ -244,7 +273,7 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
return header + data
-def _stt_backend_health(url: str) -> dict:
+def _stt_backend_health(url: str, api_key: str = "") -> dict:
base = _validate_http_url(url, allow_private=True).rstrip("/")
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
# health at the service root, while its transcription route is under
@@ -252,10 +281,11 @@ def _stt_backend_health(url: str) -> dict:
# `/v1/v1/models`, incorrectly marking a healthy WhisperX service down.
api_base = base[:-3].rstrip("/") if base.endswith("/v1") else base
models: list[str] = []
- health = probe_provider(api_base, [("/health", None)], timeout=2)
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
+ health = probe_provider(api_base, [("/health", None)], headers=headers, timeout=2)
models_result = probe_provider(
api_base, [("/v1/models", "data")],
- headers={"Authorization": "Bearer sk-dummy-key"}, timeout=3,
+ headers=headers, timeout=3,
)
ok = bool(health['ok'] or models_result['ok'])
payload = models_result.get('payload')
@@ -297,9 +327,11 @@ def _transcription_text_from_response(resp: requests.Response) -> str:
def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") -> tuple[str, str]:
backend = _clean_stt_backend(backend)
stt_url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/")
+ if _stt_disconnected(settings, backend, stt_url):
+ raise HTTPException(409, "STT engine is disconnected; connect it before use")
stt_key = _stt_backend_api_key(settings, backend)
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
- model = _stt_backend_model(backend)
+ model = _stt_backend_model(backend, settings)
# Accept both `http://host:port` and OpenAI-style `http://host:port/v1`.
# WhisperX's native fallback remains rooted at `/transcribe`.
if stt_url.endswith("/v1"):
@@ -315,7 +347,7 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
resp = requests.post(
endpoint,
files={"file": ("audio.wav", f, "audio/wav")},
- data={"model": model, "response_format": "text"},
+ data={key: json.dumps(value) if isinstance(value, (dict, list)) else value for key, value in provider_request_params(stt_url, {"model": model, "response_format": "text"}, settings, "stt", backend[7:] if backend.startswith("custom:") else "").items()},
headers=hdrs,
timeout=_STT_REQUEST_TIMEOUT,
)
@@ -328,7 +360,7 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
resp = requests.post(
f"{stt_url}{path}",
files={"file": ("audio.wav", f, "audio/wav")},
- data={"model": "whisper-1", "response_format": "text"},
+ data={key: json.dumps(value) if isinstance(value, (dict, list)) else value for key, value in provider_request_params(stt_url, {"model": "whisper-1", "response_format": "text"}, settings, "stt", backend[7:] if backend.startswith("custom:") else "").items()},
headers=hdrs,
timeout=60,
)
@@ -487,6 +519,43 @@ async def _default_stt_benchmark_engines() -> list[dict]:
# ── STT routes ────────────────────────────────────────────────────────────────
+def _stt_backend_engine_id(settings: dict, backend: str, url: str = "") -> str:
+ identities = {"faster_whisper": "faster-whisper", "whisper_cpp": "whisper-cpp", "nvidia_parakeet": "parakeet-stt-multi-9011", "nvidia_router": "nvidia-parakeet-asr"}
+ setting = {"configured": "whisper_url", "faster_whisper": "faster_whisper_url", "whisper_cpp": "whisper_cpp_url", "nvidia_parakeet": "nvidia_asr_url", "nvidia_router": "nvidia_router_url"}.get(backend)
+ if backend.startswith("custom:"):
+ return backend[7:]
+ selected = (settings.get("engine_active") or {}).get(setting)
+ if selected:
+ return selected
+ from routes.docker import _LOCAL_CONTAINER_DEFS
+ for card in _LOCAL_CONTAINER_DEFS:
+ if card["role"] == "stt" and str(card["port"]) == _backend_port_label(url):
+ return card["name"]
+ return identities.get(backend, "")
+
+
+def _stt_disconnected(settings: dict, backend: str, url: str) -> bool:
+ from routes.docker import _LOCAL_CONTAINER_DEFS
+ native_identity = _stt_backend_engine_id({}, backend)
+ identity = _stt_backend_engine_id(settings, backend, url)
+ selected_native = any(card["name"] == identity and card["role"] == "stt" for card in _LOCAL_CONTAINER_DEFS)
+ if not selected_native and (settings.get("engine_disabled") or {}).get(native_identity) is True:
+ return True
+ return engine_disconnected(settings, url, "stt", identity)
+
+
+def _stt_backend_title(settings: dict, backend: str, url: str) -> str:
+ from routes.docker import _LOCAL_CONTAINER_DEFS
+ setting = {"configured": "whisper_url", "faster_whisper": "faster_whisper_url", "whisper_cpp": "whisper_cpp_url", "nvidia_parakeet": "nvidia_asr_url", "nvidia_router": "nvidia_router_url"}.get(backend)
+ selected = (settings.get("engine_active") or {}).get(setting)
+ for card in settings.get("custom_engine_cards", []):
+ if selected and selected == str(card.get("id") or card.get("name") or ""):
+ return card.get("label") or card.get("name") or "Custom STT"
+ for card in _LOCAL_CONTAINER_DEFS:
+ if card["name"] == selected or (not selected and card["role"] == "stt" and str(card["port"]) == _backend_port_label(url)):
+ return card["label"]
+ return "Configured STT" if backend == "configured" else _stt_backend_label(backend, "")
+
@router.get("/api/stt-backends")
async def stt_backends():
settings = _load_settings()
@@ -501,36 +570,47 @@ async def stt_backends():
continue
seen_urls.add(key)
api_key = _stt_backend_api_key(settings, backend)
- if backend == "groq_whisper":
+ disconnected = _stt_disconnected(settings, backend, url)
+ if disconnected:
+ ok, models, health_detail = False, [], {"ok": False, "error": "Disconnected"}
+ elif backend == "groq_whisper":
ok = bool(api_key)
+ health_detail = {"ok": ok}
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
else:
- health_detail = _stt_backend_health(url)
+ health_detail = _stt_backend_health(url, api_key)
ok, models = health_detail.get("ok", False), health_detail.get("models", [])
items.append({
"id": backend,
+ "engine_id": _stt_backend_engine_id(settings, backend, url),
"label": _stt_backend_label(backend, url),
+ "title": _stt_backend_title(settings, backend, url),
"url": url,
"port": _backend_port_label(url),
"available": ok,
+ "connected": not disconnected,
"configured": _stt_backend_configured(settings, backend),
"health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
- "model": _stt_backend_model(backend),
+ "model": _stt_backend_model(backend, settings),
"models": models,
"metrics": _STT_BACKEND_METRICS.get(backend, {}),
})
for card in settings.get("custom_engine_cards", []):
if card.get("role") not in ("stt", "stt+tts"):
continue
- raw_url = card.get("url", "").strip()
- url = _validate_http_url(raw_url, allow_private=True).rstrip("/")
- if not url:
- continue
card_id = "custom:" + str(card.get("id", card.get("name", "")))
+ raw_url = _stt_backend_url(settings, card_id).strip()
+ if not raw_url:
+ continue
+ try:
+ url = _validate_http_url(raw_url, allow_private=True).rstrip("/")
+ except HTTPException:
+ continue
if (card_id, url) in seen_urls:
continue
seen_urls.add((card_id, url))
- health_detail = _stt_backend_health(url)
+ disconnected = _stt_disconnected(settings, card_id, url)
+ health_detail = {"ok": False, "error": "Disconnected"} if disconnected else _stt_backend_health(url, _stt_backend_api_key(settings, card_id))
ok, models = health_detail.get("ok", False), health_detail.get("models", [])
port = _backend_port_label(url)
label = card.get("label") or card.get("name") or "Custom STT"
@@ -538,13 +618,16 @@ async def stt_backends():
label = f"{port} {label}"
items.append({
"id": card_id,
+ "engine_id": card_id[7:],
"label": label,
+ "title": card.get("label") or card.get("name") or "Custom STT",
"url": url,
"port": port,
"available": ok,
+ "connected": not disconnected,
"configured": _stt_backend_configured(settings, card_id),
"health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
- "model": "whisper-1",
+ "model": _stt_backend_model(card_id, settings),
"models": models,
"metrics": {},
})
diff --git a/routes/tts.py b/routes/tts.py
index 743b5ba..539393e 100644
--- a/routes/tts.py
+++ b/routes/tts.py
@@ -21,7 +21,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from core.tasks import spawn
-from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
+from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url, _tts_api_key
from core.constants import (
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
_routing_log_add, CONFIG_DIR, _VOICE_PEAK_DBFS,
@@ -42,7 +42,7 @@ from core.voice import (
from core.voice_index import indexed_voices, refresh_voice_index_background
from core.validation import _validate_http_url
from core import ssml_lite
-from core.provider_health import probe_provider
+from core.provider_health import probe_provider, engine_disconnected
from core.registry import _registry_put, TEMP_DIR
from core.tts_helpers import (
_tts_request_audio, _open_tts_stream_response, _read_tts_stream_response,
@@ -214,7 +214,7 @@ def _backend_port_label(url: str) -> str:
return ""
-def _backend_display_name(backend: str, url: str) -> str:
+def _backend_display_name(backend: str, url: str, settings: dict = None) -> str:
names = {
"voice_clone": "Voice Clone/Base (WAV File)",
"voice_design": "Voice Design",
@@ -228,11 +228,42 @@ def _backend_display_name(backend: str, url: str) -> str:
"xtts": "XTTS v2",
"fishspeech": "Fish Audio S2 Pro (Clone)",
}
- port = _backend_port_label(url)
- return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend)
+
+ prefix = ""
+ if settings:
+ from core.tts_helpers import _tts_selected_model
+ bkeys = {
+ "voice_clone": "tts_url",
+ "voice_design": "voice_design_url",
+ "streaming": "tts_stream_url",
+ "customvoice": "customvoice_url",
+ "nvidia_magpie": "nvidia_tts_url",
+ "kokoro": "kokoro_url",
+ "vibevoice": "vibevoice_url",
+ "xtts": "xtts_url",
+ "fishspeech": "fishspeech_url"
+ }
+ if bkey := bkeys.get(backend):
+ model = _tts_selected_model(settings, [bkey, "tts_url"], "")
+ if model and model not in ("tts-1", "tts-1-hd"):
+ prefix = model
+
+ if not prefix:
+ prefix = _backend_port_label(url)
+
+ return f"{prefix} {names.get(backend, backend)}" if prefix else names.get(backend, backend)
def _backend_capabilities(backend: str) -> dict:
+ if backend.startswith("custom:"):
+ return {
+ "purpose": "Custom TTS Engine",
+ "identity": "Custom configured engine behavior.",
+ "style": "Behavior depends on the underlying engine.",
+ "best_for": "Using custom externally configured APIs.",
+ "uses_wav": False, "style_aware": False, "true_streaming": False,
+ "speed": "varies", "latency": "varies", "quality": "varies", "ram": "varies",
+ }
caps = {
"voice_clone": {
"purpose": "Clone a speaker from a short WAV/reference clip.",
@@ -336,8 +367,75 @@ def _backend_capabilities(backend: str) -> dict:
return caps.get(_clean_preview_backend(backend), {})
-def _backend_health_detail(url: str) -> dict:
- return probe_provider(url, [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)], timeout=2)
+_TTS_BACKEND_CARDS = {
+ "voice_clone": "tts-faster-qwen3-tts-voiceclone",
+ "voice_design": "tts-faster-qwen3-tts-voicedesign",
+ "customvoice": "tts-faster-qwen3-tts-customvoice",
+ "streaming": "tts-faster-qwen3-tts-streaming",
+ "fishspeech": "fish-speech-api",
+ "nvidia_magpie": "nvidia-magpie-tts",
+ "nvidia_zeroshot": "nvidia-zeroshot-nim",
+ "nvidia_flow": "nvidia-flow-nim",
+ "kokoro": "kokoro-tts",
+ "vibevoice": "vibevoice-msft-tts",
+ "xtts": "xtts-dgx-spark",
+}
+
+
+def _backend_card_title(backend: str) -> str:
+ from routes.docker import _LOCAL_CONTAINER_DEFS
+
+ return next((card["label"] for card in _LOCAL_CONTAINER_DEFS
+ if card["name"] == _TTS_BACKEND_CARDS.get(backend)), backend)
+
+
+_GATEWAY_UPSTREAMS: dict[str, tuple[float, dict[str, str]]] = {}
+_GATEWAY_UPSTREAM_TTL = 30.0
+
+
+def _gateway_upstream(root: str, api_key: str, model: str) -> str:
+ """api_base of ``model`` on a LiteLLM gateway, or "" when unknown / not a gateway.
+
+ The gateway being alive says nothing about the engine behind a given model, so the
+ health probe checks that model's own upstream. Cached briefly: /model/info lists every
+ model and the status bar polls often. ponytail: duplicate model_name deployments, the
+ last one wins.
+ """
+ if not (api_key and model):
+ return ""
+ now = time.time()
+ cached = _GATEWAY_UPSTREAMS.get(root)
+ if not cached or now - cached[0] > _GATEWAY_UPSTREAM_TTL:
+ mapping: dict[str, str] = {}
+ try:
+ r = requests.get(root + "/model/info", headers={"Authorization": f"Bearer {api_key}"}, timeout=3)
+ if r.status_code == 200:
+ for m in (r.json().get("data") or []):
+ base = (m.get("litellm_params") or {}).get("api_base")
+ if m.get("model_name") and base:
+ mapping[str(m["model_name"])] = str(base)
+ except Exception:
+ pass
+ cached = _GATEWAY_UPSTREAMS[root] = (now, mapping)
+ return cached[1].get(model, "")
+
+
+def _backend_health_detail(url: str, api_key: str = "", model: str = "") -> dict:
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
+ # Card URLs usually end in /v1 (OpenAI-compatible base); health endpoints live at the
+ # server root, so probe from the root.
+ root = url.rstrip("/").removesuffix("/v1")
+ checks = [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)]
+ upstream = _gateway_upstream(root, api_key, model)
+ if upstream:
+ detail = probe_provider(upstream.rstrip("/").removesuffix("/v1"), checks, timeout=2)
+ # A refused connection is definitive (engine container is down); anything else
+ # (DNS, timeout) may just mean the upstream is not routable from here, so fall back.
+ if detail.get("ok") or detail.get("error") == "Connection refused":
+ return detail
+ # /health/liveliness is the unauthenticated liveness route of a LiteLLM gateway
+ # (its /health and /v1/models need a key): the gateway itself is up.
+ return probe_provider(root, checks + [("/health/liveliness", None)], headers=headers, timeout=2)
def _backend_health(url: str) -> bool:
@@ -416,6 +514,28 @@ def sync_voice_clone_reference(audio: Path, transcript: str, language: str, sett
time.sleep(0.35)
+def publish_voice_clone_if_missing(audio: Path, language: str, settings: dict) -> bool:
+ """Publish a voice that the engine's voices.json does not know yet (e.g. just unhidden).
+
+ Existing entries are left alone: syncing drops the cached speaker embedding, which
+ would force a needless re-warm. Returns True when a new entry was written.
+ """
+ voices_path = _seed_finder_voices_json_path(settings)
+ if not voices_path.exists():
+ return False
+ try:
+ known = json.loads(voices_path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return False
+ if not isinstance(known, dict) or audio.stem in known:
+ return False
+ _has_ref, transcript = _read_reference_text(audio)
+ if not transcript:
+ return False
+ sync_voice_clone_reference(audio, transcript, language, settings)
+ return True
+
+
def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
cfg = dict(voices.get(voice) or {})
if cfg.get("ref_audio"):
@@ -573,6 +693,10 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list:
return _active_library_voice_options(settings)
tts_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
key = (settings.get("voice_design_api_key") if backend == "voice_design" else settings.get("tts_api_key")) or ""
+ from core.provider_health import provider_key_for_url
+ if backend != "voice_design" and tts_url != str(settings.get("tts_url") or "").rstrip("/"):
+ key = ""
+ key = _tts_api_key(settings, tts_url, backend.removeprefix("custom:")) if backend.startswith("custom:") else provider_key_for_url(settings, tts_url, key)
tts_hdrs = {"Authorization": f"Bearer {key.strip()}"} if key.strip() else {}
for ep in _TTS_VOICE_ENDPOINTS:
try:
@@ -643,16 +767,22 @@ async def tts_backends():
items = []
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_detail = _backend_health_detail(url)
+ disabled = (settings.get("engine_disabled") or {}).get(_TTS_BACKEND_CARDS[backend]) is True
+ setting_key = {"voice_clone": "tts_url", "voice_design": "voice_design_url", "customvoice": "customvoice_url", "streaming": "tts_stream_url", "fishspeech": "fishspeech_url", "nvidia_magpie": "nvidia_tts_url", "nvidia_zeroshot": "nvidia_zeroshot_url", "nvidia_flow": "nvidia_flow_url", "kokoro": "kokoro_url", "vibevoice": "vibevoice_url", "xtts": "xtts_url"}[backend]
+ selected = (settings.get("engine_active") or {}).get(setting_key, "")
+ disabled = disabled or engine_disconnected(settings, url, "tts", selected)
+ voices = [] if disabled else _fetch_backend_voices(settings, backend)
+ health_detail = {"ok": False} if disabled else _backend_health_detail(url)
health = bool(health_detail.get('ok'))
available = _backend_available(backend, voices, health)
items.append({
"id": backend,
- "label": _backend_display_name(backend, url),
+ "label": _backend_display_name(backend, url, settings),
+ "title": _backend_card_title(backend),
"url": url,
"port": _backend_port_label(url),
"available": available,
+ "disabled": disabled,
"configured": _backend_configured(settings, backend, voices),
"health": {
"ok": health,
@@ -663,6 +793,42 @@ async def tts_backends():
"voice_count": len(voices) if isinstance(voices, list) else 0,
**_backend_capabilities(backend),
})
+ for custom in settings.get("custom_engine_cards", []):
+ if custom.get("role") != "tts":
+ continue
+ c_id = custom.get("id") or custom.get("name")
+ backend_id = f"custom:{c_id}"
+ try:
+ url = _validate_http_url(_preview_backend_base_url(settings, backend_id), allow_private=True).rstrip("/")
+ except (ValueError, HTTPException):
+ continue
+ from core.tts_helpers import _tts_selected_model
+ model = _tts_selected_model(settings, str(c_id), "")
+ label = f"{model} (Custom)" if model else f"{custom.get('label') or c_id} (Custom)"
+ disabled = (settings.get("engine_disabled") or {}).get(str(c_id)) is True
+ voices = [] if disabled else _fetch_backend_voices(settings, backend_id)
+ health_detail = {"ok": False} if disabled else _backend_health_detail(url, _tts_api_key(settings, url, str(c_id)), model)
+ health = bool(health_detail.get('ok'))
+ available = _backend_available(backend_id, voices, health)
+ items.append({
+ "id": backend_id,
+ "label": label,
+ "title": custom.get("label") or custom.get("name") or str(c_id),
+ "url": url,
+ "port": _backend_port_label(url),
+ "available": available,
+ "disabled": disabled,
+ "configured": True,
+ "health": {
+ "ok": health,
+ "status": health_detail.get("status"),
+ "endpoint": health_detail.get("endpoint"),
+ "error": health_detail.get("error"),
+ },
+ "voice_count": len(voices) if isinstance(voices, list) else 0,
+ **_backend_capabilities(backend_id),
+ })
+
return {"backends": items}
@@ -1214,7 +1380,7 @@ async def openai_speech_proxy(request: Request):
audio, media_type = await _bounded_tts_call(
_tts_request_audio, text, voice, settings, style_instruction,
_preview_backend_base_url(settings, "nvidia_magpie"),
- settings.get("tts_api_key", ""), "nvidia_magpie", "nvidia_magpie",
+ None, "nvidia_magpie", "nvidia_magpie",
)
elif backend in {"nvidia_zeroshot", "nvidia_flow"}:
audio, media_type = await _bounded_tts_call(
@@ -1223,6 +1389,10 @@ async def openai_speech_proxy(request: Request):
str(data.get("audio_prompt_transcript") or ""),
str(data.get("language") or ""),
)
+ elif backend.startswith("custom:"):
+ audio, media_type = await _bounded_tts_call(
+ _preview_request_audio, text, voice, settings, style_instruction, backend,
+ )
elif backend == "fishspeech":
audio, media_type = await _bounded_tts_call(
_fishspeech_request_audio, text, voice, settings,
diff --git a/scripts/sonar_triage.mjs b/scripts/sonar_triage.mjs
index 6ea9988..17ccb19 100644
--- a/scripts/sonar_triage.mjs
+++ b/scripts/sonar_triage.mjs
@@ -73,11 +73,21 @@ export function triage(issues, cfg) {
const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {});
+export const IMPACT_RANK = { BLOCKER: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4 };
+export function impactSeverity(issue) {
+ const severities = (issue.impacts || []).map((i) => i.severity);
+ return severities.sort((a, b) => (IMPACT_RANK[a] ?? 5) - (IMPACT_RANK[b] ?? 5))[0];
+}
+export function parseImpactSeverities(value) {
+ const severities = (value || '').split(',');
+ if (!severities.length || severities.some((s) => !Object.hasOwn(IMPACT_RANK, s))) throw new Error('--impact-severities requires BLOCKER,HIGH,MEDIUM,LOW or INFO');
+ return new Set(severities);
+}
export const MAX_LOCATIONS = 40;
export const REFACTOR_RULES = new Set(['python:S3776', 'javascript:S3776']); // cognitive complexity: own lane and agent
// One group per rule; a rule with more than `size` locations is split into numbered parts.
-export function groupIssues(items, size = MAX_LOCATIONS) {
+export function groupIssues(items, size = MAX_LOCATIONS, severityFirst = false) {
const byRule = new Map();
for (const i of items) (byRule.get(i.rule) || byRule.set(i.rule, []).get(i.rule)).push(i);
const out = [];
@@ -99,7 +109,8 @@ export function groupIssues(items, size = MAX_LOCATIONS) {
const SEV_RANK = { BLOCKER: 0, CRITICAL: 1, MAJOR: 2, MINOR: 3, INFO: 4 };
const NOISY = new Set(['javascript:S3358', 'python:S1192', 'python:S8415', 'javascript:S5850', 'python:S5850', 'javascript:S2189']); // triage: low value or probable false positive, file last
const rank = (g) => [NOISY.has(g.rule) ? 1 : 0, Math.min(...g.issues.map((i) => TYPE_RANK[i.type] ?? 3)), Math.min(...g.issues.map((i) => SEV_RANK[i.severity] ?? 5))];
- out.sort((a, b) => { const x = rank(a), y = rank(b); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || a.rule.localeCompare(b.rule) || a.part - b.part; });
+ out.sort((a, b) => { const x = rank(a), y = rank(b); const impact = (g) => Math.min(...g.issues.map((i) => IMPACT_RANK[impactSeverity(i)] ?? 5));
+ return (severityFirst ? impact(a) - impact(b) : 0) || x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || a.rule.localeCompare(b.rule) || a.part - b.part; });
return out;
}
@@ -149,10 +160,11 @@ export function postWebhook(url, payload) {
function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) {
const agentLabel = lane === 'refactor' ? 'agent-refactor' : 'agent-fix';
- if (run('gh', ['auth', 'status']).status !== 0) { log('gh unauthenticated: skipping GitHub issues (run `gh auth login`).'); return []; }
+ if (run('gh', ['auth', 'status']).status !== 0) { if (apply) throw new Error('gh unauthenticated'); log('gh unauthenticated: skipping GitHub issues (run `gh auth login`).'); return []; }
const list = run('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--limit', '300', '--json', 'number,body']);
// closed fixed-local issues also suppress (Sonar keys stay open until a re-scan)
const fixed = run('gh', ['issue', 'list', '--state', 'closed', '--label', 'fixed-local', '--limit', '300', '--json', 'number,body']);
+ if (apply && [list, fixed].some((r) => r.status !== 0)) throw new Error('gh issue list failed');
const bodies = [list, fixed].flatMap((l) => (l.status === 0 ? JSON.parse(l.stdout).map((x) => x.body) : []));
const todo = groups.filter((g) => !hasMarker(bodies, g.keys));
log(`GitHub: ${groups.length - todo.length} duplicate(s) skipped, ${Math.min(todo.length, cap)} of ${todo.length} to create (cap ${cap}).`);
@@ -161,7 +173,7 @@ function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) {
for (const g of todo.slice(0, cap)) {
if (!apply) { log(` [dry-run] would create: ${g.title}`); continue; }
const r = run('gh', ['issue', 'create', '--title', g.title, '--label', 'sonar', '--label', agentLabel, '--body-file', '-'], issueBody(g));
- if (r.status !== 0) { log(` gh create failed for ${g.title}`); continue; }
+ if (r.status !== 0) throw new Error(`gh create failed for ${g.title}`);
const url = r.stdout.trim().split('\n').pop();
log(` created ${url}`);
// Mechanism 1: a multica autopilot webhook (URL is a secret: env MULTICA_WEBHOOK_URL or ~/.config/multica-webhook).
@@ -188,15 +200,15 @@ export async function drain(log, { lane = 'fix', timeoutMin = 60, resends = 1, p
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
const hook = deps.webhookUrl ? deps.webhookUrl() : webhookUrl(process.env, undefined, lane);
const post = deps.postWebhook || postWebhook;
- if (!hook) { log('no webhook configured for this lane'); return { done: [], stalled: null }; }
+ if (!hook) throw new Error('no webhook configured for this lane');
const label = lane === 'refactor' ? 'agent-refactor' : 'agent-fix';
const listed = sh('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--label', label, '--limit', '100', '--json', 'number,title,body,url']);
- if (listed.status !== 0) { log('gh issue list failed'); return { done: [], stalled: null }; }
+ if (listed.status !== 0) throw new Error('gh issue list failed');
const todo = JSON.parse(listed.stdout).sort((x, y) => x.number - y.number);
const done = [];
- const view = (n) => JSON.parse(sh('gh', ['issue', 'view', String(n), '--json', 'state,comments,labels']).stdout || '{}');
+ const view = (n) => { const r = sh('gh', ['issue', 'view', String(n), '--json', 'state,comments,labels']); if (r.status !== 0) throw new Error('gh issue view failed'); return JSON.parse(r.stdout); };
for (const it of todo) {
- const base = (view(it.number).comments || []).length;
+ view(it.number); // Fail before delivery if GitHub cannot be read.
let sent = 0, answered = false;
while (!answered && sent <= resends) {
const w = post(hook, { source: 'sonar-triage', title: it.title, github_issue_url: it.url, body: it.body });
@@ -206,7 +218,7 @@ export async function drain(log, { lane = 'fix', timeoutMin = 60, resends = 1, p
while (Date.now() < deadline) {
await sleep(pollSec * 1000);
const v = view(it.number);
- if (v.state === 'CLOSED' || (v.comments || []).length > base || (v.labels || []).some((l) => l.name === 'fixed-local')) { answered = true; break; }
+ if (v.state === 'CLOSED' || (v.labels || []).some((l) => l.name === 'fixed-local')) { answered = true; break; }
}
if (!answered) log(`#${it.number}: no answer within ${timeoutMin} min`);
}
@@ -247,6 +259,7 @@ export async function main(argv, deps = {}) {
else if (f === '--no-webhook') a.noHook = true;
else if (f === '--timeout') a.timeout = +argv[++i];
else if (f === '--resends') a.resends = +argv[++i];
+ else if (f === '--impact-severities') a.impactSeverities = parseImpactSeverities(argv[++i]);
else if (f === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean);
else throw new Error(`unknown flag ${f}`);
}
@@ -254,13 +267,16 @@ export async function main(argv, deps = {}) {
if (!a.apply) { log('--drain needs --apply (it fires webhooks).'); return {}; }
return drain(log, { lane: a.lane || 'fix', timeoutMin: a.timeout || 60, resends: a.resends ?? 1, deps: deps.drainDeps });
}
+ if (a.impactSeverities && (a.exclude?.length || a.lane === 'refactor' || a.fixSmall)) throw new Error('--impact-severities cannot be combined with exclusions, refactor lane or --fix-small');
const cfg = deps.cfg || loadConfig();
if (a.list) { log('Allowlisted (small) rules:'); for (const r of cfg.allow) log(` ${r}`); log('Skip-list:'); for (const s of cfg.skip) log(` ${s.rule || '*'} ${s.file || ''} ${s.message || ''} -- ${s.reason}`); return; }
const tokFile = join(homedir(), '.config', 'sonar-token');
const token = deps.token || process.env.SONAR_TOKEN || (existsSync(tokFile) ? readFileSync(tokFile, 'utf8').trim() : '');
if (!token) throw new Error('no token: put it in ~/.config/sonar-token (or SONAR_TOKEN)');
- const issues = await fetchIssues({ fetchFn: deps.fetchFn, token, newCode: a.newCode, max: a.max });
+ const fetched = await fetchIssues({ fetchFn: deps.fetchFn, token, newCode: a.newCode, max: a.max });
+ const issues = a.impactSeverities ? fetched.filter((i) => a.impactSeverities.has(impactSeverity(i))) : fetched;
const res = triage(issues, cfg);
+ if (a.impactSeverities) log(`Selected impact findings: ${issues.length}; scan freshness has not been verified.`);
log(`Mode: ${a.apply ? 'APPLY' : 'dry-run'} | scope: ${a.newCode ? 'new code' : 'all'} | issues: ${issues.length}`);
for (const c of ['small', 'medium', 'large', 'skipped']) {
log(`\n${c.toUpperCase()} (${res[c].length})`);
@@ -270,13 +286,13 @@ export async function main(argv, deps = {}) {
if (deps.noActions) return res;
const lane = a.lane || 'fix';
// refactor lane: cognitive-complexity findings only, one GitHub issue per function (reviewed and reverted per function), cap 3.
- const all = [...res.medium, ...res.large];
+ const all = a.impactSeverities ? Object.values(res).flat() : [...res.medium, ...res.large];
const groups = (lane === 'refactor'
? groupIssues(all.filter((i) => REFACTOR_RULES.has(i.rule)), 1)
- : groupIssues(all)).filter((g) => !(a.exclude || []).includes(g.rule));
+ : groupIssues(all, MAX_LOCATIONS, !!a.impactSeverities)).filter((g) => !(a.exclude || []).includes(g.rule));
const cap = lane === 'refactor' && !a.capSet ? 3 : a.cap;
log(`\n${lane === 'refactor' ? 'Refactor' : 'Medium+'} groups (${lane === 'refactor' ? 'one per function' : 'per rule, max 40 locations'}): ${groups.length}`);
- ghIssues(groups, a.apply, cap, log, lane, !!a.noHook);
+ (deps.ghIssues || ghIssues)(groups, a.apply, cap, log, lane, !!a.noHook);
if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); }
return res;
}
diff --git a/static/dist/main.min.js b/static/dist/main.min.js
index 1cd52ab..d0e4dc9 100644
--- a/static/dist/main.min.js
+++ b/static/dist/main.min.js
@@ -456,7 +456,7 @@ Mia:Only if you promise not to spill coffee on my notes again... though I guess
- `,list.appendChild(card)}))}function applyQwenSample(sample){$("design-instruct").value=sample.description,$("design-sample-text").value=sample.text,$("design-language").value=sample.language,$("design-gender").value=sample.gender,currentDesignSource=sample,$("design-result").style.display="none",$("design-save-result").style.display="none",$("design-instruct").scrollIntoView({behavior:"smooth",block:"nearest"})}function isDialogueDesign(instruct,text,source=null){if(source&&source.dialogue)return!0;const speakers=new Set;if(String(instruct||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/);match&&speakers.add(match[1].trim())}),speakers.size<2)return!1;const turnSpeakers=new Set;return String(text||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^([^:]{1,40}):\s*(.+)$/);match&&speakers.has(match[1].trim())&&turnSpeakers.add(match[1].trim())}),turnSpeakers.size>=2}function voiceDesignPayload(instruct,sampleText,language,source=null,gender=null){var _a2;return{instruct,sample_text:sampleText,language,gender:gender||(source==null?void 0:source.gender)||((_a2=$("design-gender"))==null?void 0:_a2.value)||"",dialogue:isDialogueDesign(instruct,sampleText,source)}}let _dVoiceIdManual=!1;function designSafeName(name){const base=name||"VoiceDesign";return(typeof _umlautSafe=="function"?_umlautSafe(base):String(base)).replace(/^[A-Z]{2}_[FMN]_/,"").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,42)||"VoiceDesign"}function voiceIdSafePart(value,fallback="style"){return(typeof _umlautSafe=="function"?_umlautSafe(value||fallback):String(value||fallback)).replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,32)||fallback}function suggestedStyleVoiceId2(baseId,style){const suffix=voiceIdSafePart(style||"style");return`${baseId}_${suffix}`.slice(0,96)}function _updateDVoiceId(){if(_dVoiceIdManual)return;const lang=$("d-lang").value,gender=$("d-gender").value,name=$("d-name").value.trim();$("d-voice-id").value=name?`${lang}_${gender}_${name}`:""}["d-lang","d-gender"].forEach(id=>["change","input"].forEach(ev=>$(id).addEventListener(ev,_updateDVoiceId))),$("d-name").addEventListener("input",()=>{_dVoiceIdManual=!1,_updateDVoiceId()}),$("d-voice-id").addEventListener("input",()=>{_dVoiceIdManual=!0}),seedDesignPresets(),refreshDesignPresetSelect(),renderQwenSampleCards(),syncDesignPresetsToServer(),$("design-preset-select").addEventListener("change",()=>{$("design-preset-select").value&&applyDesignPreset($("design-preset-select").value)}),$("design-preset-load").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}applyDesignPreset(name)}),$("design-preset-save").addEventListener("click",()=>{const name=$("design-preset-name").value.trim()||$("design-preset-select").value;if(!name){toast("Enter a preset name","error"),$("design-preset-name").focus();return}const presets=loadDesignPresets2();presets[name]={description:$("design-instruct").value,sample_text:$("design-sample-text").value,language:$("design-language").value,gender:$("design-gender").value,dialogue:isDialogueDesign($("design-instruct").value,$("design-sample-text").value,currentDesignSource)},saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-select").value=name,toast("Preset saved: "+name,"success")}),$("design-preset-delete").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}const presets=loadDesignPresets2();if(!presets[name]){toast("Preset not found","error");return}delete presets[name],saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-name").value="",toast("Preset deleted: "+name,"success")}),["design-instruct","design-sample-text"].forEach(id=>$(id).addEventListener("input",()=>{currentDesignSource=null,id==="design-sample-text"&&($("d-transcript").value=$("design-sample-text").value)}));const DESIGN_CHIPS=[["Gender",!0,["male voice","female voice","neutral voice"]],["Age",!0,["child","teenager","young adult","middle-aged","elderly"]],["Pitch",!0,["high pitch","mid pitch","low pitch"]],["Pace",!0,["fast pace","moderate pace","slow pace"]],["Accent / region",!1,["British accent","American accent","Australian accent","Irish accent","German accent","Southern accent"]],["Tone / timbre",!1,["warm","husky","crisp","smooth","rich","magnetic","authoritative"]],["Emotion",!1,["cheerful","calm","serious","gentle","enthusiastic","sad"]]],_chipRe=p=>new RegExp("(^|[,.;]\\s*)"+p.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"(?=\\s*(?:[,.;]|$))","i"),chipHas=(text,p)=>_chipRe(p).test(text);function chipRemove(text,p){return text.replace(_chipRe(p),"").replace(/^\s*[,;]\s*/,"")}function chipAdd(text,p){const t=text.trimEnd();return t?/[.,;]$/.test(t)?t+" "+p:t+", "+p:p}function syncDesignChips(){const v=$("design-instruct").value;document.querySelectorAll("#design-chips [data-phrase]").forEach(b=>b.setAttribute("aria-pressed",String(chipHas(v,b.dataset.phrase))))}function renderDesignChips(){const tr=window.t||(x=>x),box=$("design-chips");DESIGN_CHIPS.forEach(([label,exclusive,phrases],i)=>{const g=document.createElement("div");g.className="design-chip-group",g.setAttribute("role","group"),g.setAttribute("aria-label",tr(label)),g.innerHTML='',g.firstChild.textContent=tr(label),phrases.forEach(p=>{const b=document.createElement("button");b.type="button",b.className="design-chip",b.dataset.phrase=p,b.dataset.group=i,b.setAttribute("aria-pressed","false"),b.textContent=tr(p),g.appendChild(b)}),box.appendChild(g)}),box.addEventListener("click",e=>{const b=e.target.closest("[data-phrase]");if(!b)return;const ta=$("design-instruct");let v=ta.value;chipHas(v,b.dataset.phrase)?v=chipRemove(v,b.dataset.phrase):(DESIGN_CHIPS[b.dataset.group][1]&&DESIGN_CHIPS[b.dataset.group][2].forEach(o=>{v=chipRemove(v,o)}),v=chipAdd(v,b.dataset.phrase)),ta.value=v,ta.dispatchEvent(new Event("input",{bubbles:!0}))}),box.addEventListener("focusin",syncDesignChips),box.addEventListener("pointerenter",syncDesignChips),$("design-instruct").addEventListener("input",syncDesignChips),syncDesignChips()}renderDesignChips(),document.querySelectorAll(".qwen-sample").forEach(card=>{const sample=QWEN_DESIGN_SAMPLES[card.dataset.qwenSample],state=card.querySelector(".qwen-state"),audio=card.querySelector("audio");card.querySelector(".qwen-use").addEventListener("click",()=>{applyQwenSample(sample),toast("Voice Design sample loaded","success")}),card.querySelector(".qwen-preview").addEventListener("click",async e=>{const btn=e.currentTarget;btn.disabled=!0,state.textContent="Generating preview\u2026";try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(sample.description,sample.text,sample.language,sample))});if(!r.ok){const err=await r.json().catch(()=>({}));throw new Error(err.detail||r.statusText)}const d=await r.json();audio.src="/api/audio/"+d.id,audio.style.display="",audio.play().catch(()=>{}),state.textContent="Preview ready"}catch(err){state.textContent="Preview failed",toast("Sample preview failed: "+err.message,"error")}finally{btn.disabled=!1}})});async function runVoiceDesign(){const baseInstruct=$("design-instruct").value.trim(),sample=$("design-sample-text").value.trim(),instruct=baseInstruct;if(!instruct){toast("Enter a voice description first","error");return}$("design-generate-btn").disabled=!0,$("design-status").textContent="Generating\u2026",$("design-result").style.display="none",$("design-save-result").style.display="none",status("Generating voice design\u2026");try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(instruct,sample,$("design-language").value,currentDesignSource,$("design-gender").value))});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();designedFileId2=d.id,trimmedFileId2=null,editingVoiceId2=null,$("design-audio").src="/api/audio/"+d.id,$("design-result").style.display="flex",$("design-status").textContent="Done ("+d.duration.toFixed(1)+" s)";const langCode=DESIGN_LANG_CODE[$("design-language").value]||"EN";$("d-lang").value=langCode,$("d-gender").value=$("design-gender").value,$("d-name").value=designSafeName((currentDesignSource==null?void 0:currentDesignSource.title)||(currentDesignSource==null?void 0:currentDesignSource.name)||$("design-preset-name").value||"VoiceDesign"),_dVoiceIdManual=!1,_updateDVoiceId(),$("d-transcript").value=sample,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",$("transcript-area").value||($("transcript-area").value=sample),$("design-audio").play().catch(()=>{}),$("design-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Voice generated and export fields filled.","success"),status("Voice design ready")}catch(e){$("design-status").textContent="Failed: "+e.message,toast("Voice design failed: "+e.message,"error"),status("Voice design failed")}finally{$("design-generate-btn").disabled=!1}}$("design-generate-btn").addEventListener("click",runVoiceDesign),$("design-retry-btn").addEventListener("click",runVoiceDesign),$("design-save-btn").addEventListener("click",async()=>{if(!designedFileId2){toast("No voice generated yet","error");return}const voiceId=$("d-voice-id").value.trim();if(!voiceId){toast("Enter a Voice ID first","error"),$("d-name").focus();return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("design-save-btn").disabled=!0;try{const r=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designedFileId2,voice_id:voiceId,transcript:$("d-transcript").value})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const saved=await r.json();await saveMeta(saved.voice_id,{gender:$("d-gender").value,flag:LANG_FLAG_DEFAULT[$("d-lang").value]||void 0,transcript:$("d-transcript").value,note:"Voice Design: "+$("design-instruct").value.slice(0,240),origin:"designed",voice_design_prompt:$("design-instruct").value}).catch(()=>{}),await loadVoiceLibrary().catch(()=>{}),$("design-save-result").style.display="flex",$("design-save-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Exported to Voice Clone Library: "+saved.voice_id,"success"),status("Exported to Voice Clone Library: "+saved.voice_id)}catch(e){toast("Save failed: "+e.message,"error")}finally{$("design-save-btn").disabled=!1}}),$("design-download-btn").addEventListener("click",()=>{if(!designedFileId2)return;const a=document.createElement("a");a.href="/api/audio/"+designedFileId2,a.download=($("d-voice-id").value.trim()||"voice_design")+".wav",a.click()}),(_d=$("clone-refresh-stt-btn"))==null||_d.addEventListener("click",async()=>{var _a2;$("clone-refresh-stt-btn").disabled=!0;try{await refreshSttBackends((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)}finally{$("clone-refresh-stt-btn").disabled=!1}}),$("transcribe-btn").addEventListener("click",async()=>{var _a2,_b2;const id=trimmedFileId2||designedFileId2||currentFileId2;if(!id){toast("No audio to transcribe","error");return}const btn=$("transcribe-btn"),status2=$("transcribe-status"),area=$("transcript-area"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Transcribing\u2026',status2&&(status2.className="clone-tr-status working",status2.innerHTML=' Listening to your recording\u2026'),area&&(area.classList.add("transcribing"),area.placeholder="Transcribing your audio \u2014 please wait\u2026");try{const backend=((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)||"configured",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();area&&(area.value=d.text),status2&&(status2.className="clone-tr-status done",status2.innerHTML=' Transcribed'),toast("Transcription complete","success"),(_b2=window._cloneScheduleAutoSave)==null||_b2.call(window)}catch(e){status2&&(status2.className="clone-tr-status error",status2.innerHTML=` Failed: ${escHtml(e.message||String(e))}`),toast("Transcription failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig,area&&(area.classList.remove("transcribing"),area.placeholder="Type or auto-transcribe the spoken text\u2026")}}),function(){var _a2,_b2,_c2;const g=id=>document.getElementById(id);let _idManual=!1,_prevName="Sam",_autoSaveTimer=null,_lastAutoSaved="";function buildVoiceId(){var _a3,_b3,_c3;if(_idManual){scheduleAutoSave();return}const lang=((_a3=g("lang-select"))==null?void 0:_a3.value)||"EN",gender=((_b3=g("gender-select"))==null?void 0:_b3.value)||"N",name=(((_c3=g("name-input"))==null?void 0:_c3.value)||"").trim().replace(/\s+/g,""),vid=g("voice-id-input");vid&&name&&(vid.value=`${lang}_${gender}_${name}`,vid.dispatchEvent(new Event("input"))),scheduleAutoSave()}const nameField=g("clone-your-name");nameField==null||nameField.addEventListener("input",()=>{const name=nameField.value.trim();if(!name)return;const sample=g("clone-sample-text");if(sample){const prev=sample.dataset.sampleName||_prevName,re=new RegExp("\\b"+prev.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b");re.test(sample.value)&&(sample.value=sample.value.replace(re,name)),sample.dataset.sampleName=name}_prevName=name;const ni=g("name-input");ni&&(ni.value=name.replace(/\s+/g,"")),buildVoiceId()}),["lang-select","gender-select"].forEach(id=>["change","input"].forEach(ev=>{var _a3;return(_a3=g(id))==null?void 0:_a3.addEventListener(ev,buildVoiceId)})),(_a2=g("name-input"))==null||_a2.addEventListener("input",buildVoiceId),(_b2=g("voice-id-input"))==null||_b2.addEventListener("input",e=>{e.isTrusted&&(_idManual=!0),scheduleAutoSave()}),(_c2=g("transcript-area"))==null||_c2.addEventListener("input",scheduleAutoSave),window._cloneAutoTranscribe=function(){var _a3;const ta=g("transcript-area");if(ta&&ta.value.trim()){scheduleAutoSave();return}(_a3=g("transcribe-btn"))==null||_a3.click()};function canAutoSave(){var _a3,_b3;const vid=(((_a3=g("voice-id-input"))==null?void 0:_a3.value)||"").trim(),tr=(((_b3=g("transcript-area"))==null?void 0:_b3.value)||"").trim();return!!((typeof trimmedFileId2!="undefined"&&trimmedFileId2||typeof designedFileId2!="undefined"&&designedFileId2)&&vid&&tr&&(typeof validateVoiceId!="function"||validateVoiceId(vid)))}function scheduleAutoSave(){const toggle=g("clone-autosave-toggle");!toggle||!toggle.checked||(clearTimeout(_autoSaveTimer),_autoSaveTimer=setTimeout(()=>{var _a3;if(!canAutoSave())return;const sig=(g("voice-id-input").value+"|"+g("transcript-area").value).trim();sig!==_lastAutoSaved&&(_lastAutoSaved=sig,(_a3=g("save-btn"))==null||_a3.click())},1600))}window._cloneScheduleAutoSave=scheduleAutoSave}(),function(){const picker=document.getElementById("clone-src-picker");if(!picker)return;const cards=[...document.querySelectorAll(".clone-src-card")],tabs=[...picker.querySelectorAll(".clone-src-tab")],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")}(),Object.assign(root,{CLONE_SAMPLE_TEXTS,CLONE_SAMPLE_NAMES,cloneSampleForLang,ws,wsRegions,currentFileId:currentFileId2,trimmedFileId:trimmedFileId2,designedFileId:designedFileId2,editingVoiceId:editingVoiceId2,editingVoicePath:editingVoicePath2,initWaveSurfer,updateRegion,updateDurationLabel,loadAudioId:loadAudioId2,dropZone,fileInput,uploadFile,loadLibraryVoiceAudio:loadLibraryVoiceAudio2,RAW_MIC_CONSTRAINTS,visibleMicrophoneCount,microphoneErrorMessage:microphoneErrorMessage2,requestMicrophoneStream:requestMicrophoneStream2,_cloneMonState,_cloneRenderMeter,_cloneStartMeter,_cloneStartMonitor,_cloneStopMonitor,mediaRec,recChunks,recTimer,recSecs,DESIGN_LANG_CODE,DESIGN_GENDER_WORD,DESIGN_PRESET_KEY,DESIGN_PRESET_SEEDED_KEY,DEFAULT_DESIGN_PRESETS,QWEN_DESIGN_SAMPLES,currentDesignSource,loadDesignPresets:loadDesignPresets2,saveDesignPresets,syncDesignPresetsToServer,seedDesignPresets,refreshDesignPresetSelect,applyDesignPreset,renderDesignPresetLibrary,renderQwenSampleCards,applyQwenSample,isDialogueDesign,voiceDesignPayload,_dVoiceIdManual,designSafeName,voiceIdSafePart,suggestedStyleVoiceId:suggestedStyleVoiceId2,_updateDVoiceId,runVoiceDesign}),root.TTVC=root.TTVC||{},root.TTVC.voiceClone=root.TTVC.voiceClone||{},Object.assign(root.TTVC.voiceClone,{samples:{forLanguage:cloneSampleForLang},audio:{init:initWaveSurfer,load:loadAudioId2,upload:uploadFile},design:{run:runVoiceDesign,buildPayload:voiceDesignPayload}})})(window),setupVocalIsolation({btn:"yt-iso-btn",toggle:"yt-iso-toggle",msg:"yt-iso-msg",getId:()=>currentFileId,getDur:()=>parseFloat($("trim-end").value)||0,apply:(id,dur)=>loadAudioId(id,dur)}),function(){const det=document.getElementById("archetype-gallery");if(!det)return;const tr=s=>window.t?window.t(s):s,$a=id=>document.getElementById(id),PAGE=24,FAV_KEY="ttsvc-archetype-favs",FACETS=[["gender","Gender",["male","female","neutral"]],["age","Age",["child","teenager","young adult","middle-aged","elderly"]],["pitch","Pitch",["high","mid","low"]],["accent","Accent / region",["none","british","american","australian","irish","german","southern"]],["language","Language",["German","English"]]],st={gender:"",age:"",pitch:"",accent:"",language:"",category:"",q:"",favOnly:!1,offset:0,total:0};let favs=new Set,booted=!1,token=0,objUrl="";try{favs=new Set(JSON.parse(localStorage.getItem(FAV_KEY)||"[]"))}catch{}const saveFavs=()=>{try{localStorage.setItem(FAV_KEY,JSON.stringify([...favs]))}catch{}},archSay=m=>{$a("arch-status").textContent=m},lang=()=>window.__APP_LANG__==="de"?"de":"en";function buildChips(){const box=$a("arch-chips");FACETS.forEach(([key,label,vals])=>{const g=document.createElement("div");g.className="design-chip-group",g.setAttribute("role","group"),g.setAttribute("aria-label",tr(label));const l=document.createElement("span");l.className="design-chip-label",l.textContent=tr(label),g.appendChild(l),vals.forEach(v=>{const b=document.createElement("button");b.type="button",b.className="design-chip",b.dataset.k=key,b.dataset.v=v,b.setAttribute("aria-pressed","false"),b.textContent=tr(v),g.appendChild(b)}),box.appendChild(g)});const f=document.createElement("button");f.type="button",f.className="design-chip",f.id="arch-fav-only",f.setAttribute("aria-pressed","false"),f.textContent=tr("Favourites only"),box.appendChild(f),box.addEventListener("click",e=>{const b=e.target.closest("button");b&&(b.id==="arch-fav-only"?st.favOnly=!st.favOnly:st[b.dataset.k]=st[b.dataset.k]===b.dataset.v?"":b.dataset.v,box.querySelectorAll("[data-k]").forEach(x=>x.setAttribute("aria-pressed",String(st[x.dataset.k]===x.dataset.v))),f.setAttribute("aria-pressed",String(st.favOnly)),load(!0))})}function card(a){const name=lang()==="de"?a.name_de:a.name_en,el=document.createElement("article");el.className="arch-card",el.dataset.id=a.id;const h=document.createElement("h3");h.textContent=name;const f=document.createElement("div");f.className="arch-facets",f.textContent=[a.language,...Object.values(a.facets)].map(tr).join(" \xB7 ");const p=document.createElement("p");p.textContent=a.category;const row=document.createElement("div");row.className="arch-actions";const mk=(cls,text,label)=>{const b=document.createElement("button");return b.type="button",b.className=cls,b.textContent=text,label&&b.setAttribute("aria-label",label+": "+name),row.appendChild(b),b};return mk("btn-primary arch-play",tr("Preview"),tr("Preview")),mk("btn-secondary arch-use",tr("Use voice"),tr("Use voice")),mk("btn-secondary arch-fav",favs.has(a.id)?"\u2605":"\u2606",tr("Favourite")).setAttribute("aria-pressed",String(favs.has(a.id))),el.append(h,f,p,row),el}async function load(reset){const my=++token;reset&&(st.offset=0,$a("arch-grid").textContent="");const q=new URLSearchParams({offset:st.offset,limit:st.favOnly?100:PAGE});["gender","age","pitch","accent","language","category","q"].forEach(k=>st[k]&&q.set(k,st[k])),archSay(tr("Loading\u2026"));try{const r=await fetch("/api/archetypes?"+q);if(!r.ok)throw new Error(r.statusText);const d=await r.json();if(my!==token)return;if(!$a("arch-category").options.length){const sel=$a("arch-category");[["",tr("All categories")],...d.categories.map(c=>[c,c])].forEach(([v,t])=>sel.add(new Option(t,v)))}const items=st.favOnly?d.items.filter(a=>favs.has(a.id)):d.items,grid=$a("arch-grid");items.forEach(a=>grid.appendChild(card(a))),st.offset+=d.items.length,st.total=d.total,$a("arch-more").hidden=st.favOnly||st.offset>=d.total,archSay(grid.children.length?grid.children.length+" / "+(st.favOnly?grid.children.length:d.total):tr("No voices match."))}catch{my===token&&archSay(tr("Could not load the gallery."))}}async function play(a,btn){btn.disabled=!0,archSay(tr("Rendering preview\u2026"));try{const r=await fetch("/api/archetypes/"+encodeURIComponent(a)+"/preview",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}objUrl&&URL.revokeObjectURL(objUrl),objUrl=URL.createObjectURL(await r.blob());const au=$a("arch-audio");au.src=objUrl,await au.play().catch(()=>{}),archSay(tr("Preview ready"))}catch(e){archSay(tr("Preview failed")+": "+e.message)}finally{btn.disabled=!1}}async function use(id,btn){btn.disabled=!0,archSay(tr("Saving voice\u2026"));try{const r=await fetch("/api/archetypes/"+encodeURIComponent(id)+"/use",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();if(archSay(tr("Voice saved")+": "+d.voice_id),typeof toast=="function"&&toast(tr("Voice saved")+": "+d.voice_id,"success"),!btn.parentNode.querySelector(".arch-open")){const o=document.createElement("button");o.type="button",o.className="btn-secondary arch-open",o.textContent=tr("Open in Design"),o.dataset.instruct=d.instruct,o.dataset.language=d.language,btn.parentNode.insertBefore(o,btn.nextSibling),o.focus()}typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){archSay(tr("Save failed")+": "+e.message),typeof toast=="function"&&toast(e.message,"error")}finally{btn.disabled=!1}}function boot(){if(booted)return;booted=!0,buildChips();let t;$a("arch-q").addEventListener("input",e=>{clearTimeout(t),t=setTimeout(()=>{st.q=e.target.value.trim(),load(!0)},250)}),$a("arch-category").addEventListener("change",e=>{st.category=e.target.value,load(!0)}),$a("arch-more").addEventListener("click",()=>load(!1)),$a("arch-grid").addEventListener("click",e=>{const b=e.target.closest("button");if(!b)return;const id=b.closest(".arch-card").dataset.id;if(b.classList.contains("arch-play"))play(id,b);else if(b.classList.contains("arch-use"))use(id,b);else if(b.classList.contains("arch-fav"))favs.has(id)?favs.delete(id):favs.add(id),saveFavs(),b.textContent=favs.has(id)?"\u2605":"\u2606",b.setAttribute("aria-pressed",String(favs.has(id)));else if(b.classList.contains("arch-open")){const ta=$a("design-instruct");ta.value=b.dataset.instruct,ta.dispatchEvent(new Event("input",{bubbles:!0}));const sel=$a("d-lang");if(sel)for(const o of sel.options)o.value===b.dataset.language&&(sel.value=o.value);ta.scrollIntoView({behavior:matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth",block:"center"}),ta.focus()}}),load(!0)}det.addEventListener("toggle",()=>{det.open&&boot()}),det.open&&boot()}(),function(root){var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r;$("save-btn").addEventListener("click",async()=>{const id=trimmedFileId||designedFileId||currentFileId;if(!id){toast("No audio ready","error");return}const voiceId=$("voice-id-input").value.trim();if(!voiceId){toast("Enter a Voice ID","error");return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("save-btn").disabled=!0;try{const payload={id,voice_id:voiceId,path:editingVoicePath,transcript:$("transcript-area").value},sendSave=endpoint=>fetch(endpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});let fallbackSave=!1,r=await sendSave(editingVoiceId?"/api/voice-replace":"/api/save");if(editingVoiceId&&(r.status===404||r.status===405)&&(fallbackSave=!0,status("Update endpoint unavailable; saving as a regular voice\u2026"),r=await sendSave("/api/save")),!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();$("save-result").style.display="",toast((editingVoiceId?"Voice updated: ":"Voice saved: ")+d.voice_id,"success"),fallbackSave&&editingVoiceId&&voiceId!==editingVoiceId&&((await fetch("/api/voice/"+encodeURIComponent(editingVoiceId),{method:"DELETE"})).ok||status("Saved renamed voice; old library entry may need manual deletion.")),editingVoiceId=null,editingVoicePath=null}catch(e){toast("Save failed: "+e.message,"error")}finally{$("save-btn").disabled=!1}});let _voices2=[],_pendingSelectId2=null,_sortField="id",_sortDir=1,_libraryIssueFilter="",_activePlayButton=null,_activePlayVoiceId=null,_activePlayUrl=null,_libraryLoadPromise2=null;const BENCHMARK_SAMPLE_STORAGE_KEY="vcf-benchmark-sample-text",_VL_CACHE_KEY="ttsvc_vc";function _vlCacheRead(){try{const value=JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY)||"null");return Array.isArray(value)?value:Array.isArray(value==null?void 0:value.voices)?value.voices:null}catch{return null}}function _vlCacheWrite(voices){try{sessionStorage.setItem(_VL_CACHE_KEY,JSON.stringify(voices))}catch{}}function _vlCacheClear(){try{sessionStorage.removeItem(_VL_CACHE_KEY)}catch{}}window._vlCacheClear=_vlCacheClear;const _libraryFilters={text:"",lang:"",sex:"",type:"",rating:""};let _libraryFilterOptionsSig="";const DEFAULT_BENCHMARK_SAMPLE_TEXT="Hello, how are you today? Please read this sample clearly for a fair voice benchmark.",BENCHMARK_PRESETS={de:"Die Welt ist voller Geschichten, die darauf warten, erz\xE4hlt zu werden \u2014 von mutigen Helden und stillen Tr\xE4umern.",en:"The old lighthouse stood firm against the crashing waves, its beam sweeping silently across the dark and restless sea.",de2:"Victor jagt zw\xF6lf Boxk\xE4mpfer quer \xFCber den gro\xDFen Sylter Deich. Im Winter ist es kalt und die Tage sind kurz.",en2:"She sells seashells by the seashore. Peter Piper picked a peck of pickled peppers on a perfectly pleasant afternoon.",reset:DEFAULT_BENCHMARK_SAMPLE_TEXT};function benchmarkSampleText(){const el=$("benchmark-sample-text");return el&&el.value.trim()||DEFAULT_BENCHMARK_SAMPLE_TEXT}function initBenchmarkSampleControls(){var _a2,_b2;const sample=$("benchmark-sample-text");if(!sample)return;sample.value=localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY)||DEFAULT_BENCHMARK_SAMPLE_TEXT,sample.addEventListener("input",debounce(()=>{localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value.trim()),status("Benchmark sample sentence saved")},500)),(_a2=$("benchmark-reset-sample-btn"))==null||_a2.addEventListener("click",()=>{sample.value=DEFAULT_BENCHMARK_SAMPLE_TEXT,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),status("Benchmark sample sentence reset")});const presetSel=$("benchmark-preset-select");presetSel&&presetSel.addEventListener("change",()=>{const key=presetSel.value;if(!key||!BENCHMARK_PRESETS[key]){presetSel.value="";return}sample.value=BENCHMARK_PRESETS[key],localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),presetSel.value="",status("Benchmark sample sentence loaded")}),(_b2=$("benchmark-use-preview-btn"))==null||_b2.addEventListener("click",()=>{var _a3;const text=(_a3=$("preview-text-area"))==null?void 0:_a3.value.trim();if(!text){toast("Preview text is empty","error");return}sample.value=text,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,text),status("Benchmark sample sentence copied from TTS preview")})}function _displaySource(v){if(v.origin)return v.origin;if(v.note){const m=v.note.match(/^Rehearser\s*·\s*(.+?)\s*·/);if(m)return m[1].trim()}if(v.tag){const tags=v.tag.split(",").map(t=>t.trim().toLowerCase());if(tags.includes("fish-audio")||tags.includes("fishaudio"))return"fish-audio"}return""}function getSortValue(v,field){var _a2,_b2,_c2,_d2,_e2,_f2;switch(field){case"has_picture":return v.has_picture?1:0;case"flag":return(v.flag||"").toLowerCase();case"gender":return(_a2={F:0,M:1,N:2}[v.gender])!=null?_a2:3;case"id":return v.id.toLowerCase();case"file_type":return voiceFileType(v);case"duration":return v.duration||0;case"dbfs":return(_b2=voiceDbfs(v))!=null?_b2:-999;case"benchmark":case"factor":return-((_c2=voiceFactor(v))!=null?_c2:-999);case"elapsed":return(_d2=voiceBenchmarkElapsed(v))!=null?_d2:999;case"bench_audio":return(_e2=voiceBenchmarkAudioSec(v))!=null?_e2:999;case"wpm":return(_f2=voiceWpm(v))!=null?_f2:-1;case"transcript":return(v.transcript||"").toLowerCase();case"note":return(v.note||"").toLowerCase();case"source":return(_displaySource(v)||"").toLowerCase();case"seed":return v.seed!=null?v.seed:9999999;case"tag":return(v.tag||"").toLowerCase();case"rating":return v.rating||0;case"enabled":return v.enabled===!1?0:1;default:return""}}function setSort(field){_sortDir=_sortField===field?_sortDir*-1:1,_sortField=field,syncSortHeaders(),renderVoiceList2()}function toggleSortDir(){_sortDir*=-1,syncSortHeaders(),renderVoiceList2()}function syncSortHeaders(){document.querySelectorAll(".vl-header [data-sort], .vl-table-header [data-sort]").forEach(el=>{el.classList.remove("sort-asc","sort-desc"),el.dataset.sort===_sortField&&el.classList.add(_sortDir===1?"sort-asc":"sort-desc")});const sel=document.getElementById("voice-sort-field");sel&&sel.value!==_sortField&&(sel.value=_sortField);const dirBtn=document.getElementById("voice-sort-dir");if(dirBtn){const icon=dirBtn.querySelector(".mdi");icon&&(icon.className=_sortDir===1?"mdi mdi-arrow-up":"mdi mdi-arrow-down"),dirBtn.title=_sortDir===1?"Ascending \u2014 click to reverse":"Descending \u2014 click to reverse"}}document.addEventListener("click",e=>{var _a2,_b2,_c2;if(e.target.closest("#voice-sort-dir")&&toggleSortDir(),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==null||btn.classList.toggle("active",window._voiceGroupByTag);const icon=btn==null?void 0:btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline"),renderVoiceList2()}else if(e.target.closest("#voice-table-view-btn")){window._voiceTableView=!window._voiceTableView,window._voiceTableView||(window._voiceTableEditMode=!1);try{localStorage.setItem("vl-table-view",window._voiceTableView?"1":"0")}catch{}const btn=document.getElementById("voice-table-view-btn");btn==null||btn.classList.toggle("active",window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");if(editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode)),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",window._voiceTableView),(_b2=document.querySelector(".voices-workbench"))==null||_b2.classList.toggle("table-edit-mode",!!window._voiceTableEditMode),window._voiceTableView){document.querySelectorAll(".edit-open").forEach(r=>r.classList.remove("edit-open"));const inspector=document.getElementById("voices-inspector");inspector&&(inspector.innerHTML='
Table View Mode
Click a row to exit table view and edit.
')}renderVoiceList2()}else if(e.target.closest("#voice-table-edit-btn")){window._voiceTableEditMode=!window._voiceTableEditMode;const editBtn=document.getElementById("voice-table-edit-btn");editBtn==null||editBtn.classList.toggle("active",window._voiceTableEditMode),(_c2=document.querySelector(".voices-workbench"))==null||_c2.classList.toggle("table-edit-mode",window._voiceTableEditMode),renderVoiceList2()}});try{window._voiceGroupByTag=localStorage.getItem("vl-group-by-tag")==="1"}catch{}try{window._voiceTableView=localStorage.getItem("vl-table-view")==="1"}catch{}document.addEventListener("click",e=>{const th=e.target.closest(".vl-th-sortable");if(th){const field=th.dataset.sort;if(field){const sel=document.getElementById("voice-sort-field");sel&&(sel.value=field),setSort(field)}}}),document.addEventListener("change",e=>{e.target.id==="voice-sort-field"&&setSort(e.target.value)}),document.addEventListener("change",async e=>{if(e.target.classList.contains("vl-inline-edit")){const row=e.target.closest(".vl-row");if(!row)return;const voiceId=row.dataset.id,field=e.target.dataset.field;let value=e.target.type==="checkbox"?e.target.checked:e.target.value;field==="rating"&&(value=parseInt(value)||0);const payload={};payload[field]=value;try{await saveMeta2(voiceId,payload);const v=_voices2.find(vv=>vv.id===voiceId);v&&(v[field]=value),typeof toast=="function"&&toast("Saved "+field,"success")}catch{typeof toast=="function"&&toast("Failed to save "+field,"error")}}});const FLAG_LANGUAGE_CANDIDATES={GB:["EN"],US:["EN"],AU:["EN"],NZ:["EN"],IE:["EN"],ZA:["EN"],NG:["EN"],KE:["EN"],GH:["EN"],JM:["EN"],TT:["EN"],CA:["EN","FR"],IN:["EN","HI"],SG:["EN","ZH"],PH:["EN","FIL"],MT:["EN","MT"],DE:["DE"],AT:["DE"],CH:["DE","FR","IT"],FR:["FR"],BE:["FR","NL"],LU:["FR","DE"],ES:["ES"],MX:["ES"],AR:["ES"],CO:["ES"],CL:["ES"],PE:["ES"],VE:["ES"],UY:["ES"],EC:["ES"],BO:["ES"],CR:["ES"],CU:["ES"],DO:["ES"],PT:["PT"],BR:["PT"],IT:["IT"],NL:["NL"],PL:["PL"],SE:["SV"],DK:["DA"],NO:["NO"],FI:["FI"],IS:["IS"],GR:["EL"],CY:["EL","TR"],CZ:["CS"],SK:["SK"],HU:["HU"],RO:["RO"],BG:["BG"],HR:["HR"],SI:["SL"],RS:["SR"],BA:["BS"],ME:["SR"],MK:["MK"],AL:["SQ"],EE:["ET"],LV:["LV"],LT:["LT"],UA:["UK"],RU:["RU"],BY:["RU"],MD:["RO"],TR:["TR"],CN:["ZH"],TW:["ZH"],HK:["ZH"],MO:["ZH"],JP:["JA"],KR:["KO"],VN:["VI"],TH:["TH"],ID:["ID"],MY:["MS"],PK:["UR"],BD:["BN"],LK:["SI"],NP:["NE"],SA:["AR"],EG:["AR"],AE:["AR"],MA:["AR"],QA:["AR"],KW:["AR"],OM:["AR"],JO:["AR"],LB:["AR"],IQ:["AR"],IR:["FA"],IL:["HE"]},LANGUAGE_LABELS2={EN:"English",DE:"German",FR:"French",ES:"Spanish",PT:"Portuguese",IT:"Italian",NL:"Dutch",PL:"Polish",SV:"Swedish",DA:"Danish",NO:"Norwegian",FI:"Finnish",IS:"Icelandic",EL:"Greek",MT:"Maltese",CS:"Czech",SK:"Slovak",HU:"Hungarian",RO:"Romanian",BG:"Bulgarian",HR:"Croatian",SL:"Slovenian",SR:"Serbian",BS:"Bosnian",MK:"Macedonian",SQ:"Albanian",ET:"Estonian",LV:"Latvian",LT:"Lithuanian",UK:"Ukrainian",RU:"Russian",ZH:"Chinese",JA:"Japanese",KO:"Korean",VI:"Vietnamese",TH:"Thai",ID:"Indonesian",MS:"Malay",FIL:"Filipino",HI:"Hindi",UR:"Urdu",BN:"Bengali",SI:"Sinhala",NE:"Nepali",AR:"Arabic",FA:"Persian",HE:"Hebrew",TR:"Turkish"},SEX_FILTER_LABELS={F:"\u2640 Female",M:"\u2642 Male",N:"\u26A5 Diverse / neutral"};function voiceLangFromName(v){return(v.lang||String(v.id||"").split("_")[0]||"").toUpperCase()}function libraryVoiceLang(v){const fromName=voiceLangFromName(v),candidates=FLAG_LANGUAGE_CANDIDATES[String(v.flag||"").toUpperCase()];return candidates!=null&&candidates.length?candidates.includes(fromName)?fromName:candidates[0]:fromName}function libraryLanguageLabel(code){return LANGUAGE_LABELS2[code]||code}function populateLibraryFilters(){const langSel=$("library-filter-lang"),sexSel=$("library-filter-sex"),typeSel=$("library-filter-type"),tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");if(!langSel||!sexSel||!typeSel)return;const langSet=new Set,sexSet=new Set,typeSet=new Set,tagSet=new Set,groupSet=new Set;(_voices2||[]).forEach(v=>{const lang=libraryVoiceLang(v);lang&&langSet.add(lang),v.gender&&sexSet.add(v.gender);const type=voiceFileType(v);type&&typeSet.add(type),String(v.tag||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>tagSet.add(t));const g=(v.group||"").trim();g&&groupSet.add(g)});const langs=[...langSet].sort((a,b)=>libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b))),sexOrder=["F","M","N"],sexes=[...sexSet].sort((a,b)=>(sexOrder.indexOf(a)<0?99:sexOrder.indexOf(a))-(sexOrder.indexOf(b)<0?99:sexOrder.indexOf(b))),types=[...typeSet].sort((a,b)=>a.localeCompare(b)),tags=[...tagSet].sort((a,b)=>a.localeCompare(b)),groups=[...groupSet].sort((a,b)=>a.localeCompare(b)),sig=JSON.stringify([langs,sexes,types,tags,groups]);if(sig===_libraryFilterOptionsSig)return;_libraryFilterOptionsSig=sig;const keep={lang:langSel.value,sex:sexSel.value,type:typeSel.value,tag:tagSel==null?void 0:tagSel.value,group:groupSel==null?void 0:groupSel.value};langSel.innerHTML=''+langs.map(x=>``).join(""),sexSel.innerHTML=''+sexes.map(x=>``).join(""),typeSel.innerHTML=''+types.map(x=>``).join(""),tagSel&&(tagSel.innerHTML=''+tags.map(x=>``).join("")),groupSel&&(groupSel.innerHTML=''+groups.map(x=>``).join("")),langSel.value=langs.includes(keep.lang)?keep.lang:"",sexSel.value=sexes.includes(keep.sex)?keep.sex:"",typeSel.value=types.includes(keep.type)?keep.type:"",tagSel&&(tagSel.value=tags.includes(keep.tag)?keep.tag:""),groupSel&&(groupSel.value=groups.includes(keep.group)?keep.group:"")}function readLibraryFilters(){var _a2,_b2,_c2,_d2,_e2;_libraryFilters.text=(((_a2=$("library-filter-text"))==null?void 0:_a2.value)||"").trim().toLowerCase(),_libraryFilters.lang=((_b2=$("library-filter-lang"))==null?void 0:_b2.value)||"",_libraryFilters.sex=((_c2=$("library-filter-sex"))==null?void 0:_c2.value)||"",_libraryFilters.type=((_d2=$("library-filter-type"))==null?void 0:_d2.value)||"",_libraryFilters.rating=((_e2=$("library-filter-rating"))==null?void 0:_e2.value)||""}function libraryFilterMatch(v){const f=_libraryFilters;if(f.lang&&libraryVoiceLang(v)!==f.lang||f.sex&&(v.gender||"")!==f.sex||f.type&&voiceFileType(v)!==f.type)return!1;if(f.rating){const r=Number(v.rating||0),wanted=Number(f.rating);if(wanted===0&&r!==0||wanted===1&&r<1||wanted>1&&rString(x||"").toLowerCase()).join(" ").includes(f.text))return!1}return!0}function clearLibraryFilters(){["library-filter-text","library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{const el=$(id);el&&(el.value="")}),readLibraryFilters(),renderVoiceList2()}function libraryTtsBackend(){var _a2;return((_a2=$("library-tts-backend-select"))==null?void 0:_a2.value)||"voice_clone"}function needsDuration(v){return v.duration==null||Number.isNaN(Number(v.duration))}function voiceFileType(v){if(v.file_type)return String(v.file_type).replace(/^\./,"").toLowerCase();const match=String(v.path||v.filename||"").match(/\.([A-Za-z0-9]+)(?:$|[?#])/);return match?match[1].toLowerCase():"wav"}function voiceDbfs(v){var _a2;const value=v.loudness&&((_a2=v.loudness.dbfs)!=null?_a2:v.loudness.after_dbfs);return value==null||Number.isNaN(Number(value))?null:Number(value)}function fmtDbfs2(v){const db=voiceDbfs(v);return db==null?"-":db.toFixed(1)}function voiceBenchmark(v){return v.benchmark&&typeof v.benchmark=="object"&&Object.keys(v.benchmark).length>0?v.benchmark:null}function voiceBenchmarkElapsed(v){const b=voiceBenchmark(v),value=b&&b.elapsed_sec;return value==null||Number.isNaN(Number(value))?null:Number(value)}function voiceFactor(v){const b=voiceBenchmark(v);return b&&b.ok&&b.speed!=null?Number(b.speed):null}function fmtFactor(v){const f=voiceFactor(v);if(f!=null)return f.toFixed(2)+"x";const b=voiceBenchmark(v);return b&&!b.ok?"ERR":"-"}function fmtElapsed(v){const e=voiceBenchmarkElapsed(v),b=voiceBenchmark(v);return!b||!b.ok?b&&!b.ok?"ERR":"-":e!=null?e.toFixed(1)+"s":"-"}function fmtBenchmark2(v){const elapsed=fmtElapsed(v),factor=fmtFactor(v);return elapsed==="-"&&factor==="-"?"-":[elapsed,factor].filter(x=>x!=="-").join(" \xB7 ")}function benchmarkClass2(v){const b=voiceBenchmark(v);if(!b)return"";if(!b.ok||b.clipped||b.realtime_ok===!1)return"bench-bad";const elapsed=voiceBenchmarkElapsed(v);return elapsed!=null&&elapsed<=4?"bench-ok":"bench-warn"}function voiceBenchmarkAudioSec(v){const b=voiceBenchmark(v);return b&&b.ok&&b.audio_sec!=null?Number(b.audio_sec):null}function fmtBenchmarkAudio(v){const sec=voiceBenchmarkAudioSec(v);return sec!=null?sec.toFixed(1)+"s":"-"}function voiceWpm(v){const b=voiceBenchmark(v);if(!b||!b.ok||!b.audio_sec||!b.text)return null;const words=b.text.trim().split(/\s+/).length;return Math.round(words/(b.audio_sec/60))}function fmtWpm(v){const wpm=voiceWpm(v);return wpm!=null?wpm+" wpm":"-"}function voiceFileUrl2(v){const bust=v._audioVersion||v.updated_at||v.benchmarked_at||""||Date.now();return`/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`}function markVoiceAudioChanged(v){v._audioVersion=Date.now()}function benchmarkTitle2(v){const b=voiceBenchmark(v);if(!b)return"Not benchmarked yet";const parts=[];return b.ok?(parts.push(`total ${Number(b.elapsed_sec||0).toFixed(2)}s`),b.ttfa_ms!=null&&parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`),b.audio_sec!=null&&parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`),b.rtf!=null&&parts.push(`RTF ${Number(b.rtf).toFixed(2)}`),b.speed!=null&&parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`),b.clipped&&parts.push("output clipped")):(parts.push("benchmark failed"),b.error&&parts.push(b.error)),Array.isArray(b.advice)&&b.advice.length&&parts.push(b.advice.join(" | ")),b.benchmarked_at&&parts.push(`saved ${b.benchmarked_at}`),parts.join(" \xB7 ")}async function clientVoiceLoudness(v){if(!v.path)throw new Error("No audio path");const resp=await fetch(voiceFileUrl2(v),{cache:"no-store"});if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const audioData=await resp.arrayBuffer(),buffer=await new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(audioData.slice(0));let sum=0,peak=0,count=0;for(let ch=0;ch0?20*Math.log10(rms):null,peakDbfs=peak>0?20*Math.log10(peak):null;return{dbfs:dbfs==null?null:Number(dbfs.toFixed(2)),peak_dbfs:peakDbfs==null?null:Number(peakDbfs.toFixed(2))}}async function clientCalculateVoiceDb(){const voices=_bulkSelected2&&_bulkSelected2.size>0?(_voices2||[]).filter(v=>_bulkSelected2.has(v.id)):visibleLibraryVoices(),errors=[];let calculated=0;const stats={startedAt:Date.now(),ok:0,slow:0,errors:0,middleLabel:"Skipped"};setBenchmarkProgress(0,voices.length,"Preparing dB scan...",stats);for(const v of voices){setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats);try{v.loudness=await clientVoiceLoudness(v),await saveMeta2(v.id,{loudness:v.loudness}).catch(()=>{}),calculated++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs2(v)} dBFS`,status(`Calculated dB: ${calculated} / ${voices.length}`)}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats),await new Promise(resolve=>setTimeout(resolve,0))}return setBenchmarkProgress(voices.length,voices.length,"dB scan complete",stats),{calculated,errors,voices:voices.map(v=>({voice_id:v.id,loudness:v.loudness}))}}async function hydrateVoiceDuration(v,el){if(!(!v.path||!needsDuration(v)||v._durationLoading)){v._durationLoading=!0;try{const audio=new Audio;audio.preload="metadata",audio.src=voiceFileUrl2(v),await new Promise((resolve,reject)=>{audio.onloadedmetadata=resolve,audio.onerror=()=>reject(new Error("Could not read duration"))}),Number.isFinite(audio.duration)&&audio.duration>0&&(v.duration=audio.duration,el&&document.body.contains(el)&&(el.textContent=fmtDuration(v.duration),el.title=String(v.duration.toFixed(2)))),audio.removeAttribute("src"),audio.load()}catch(e){el&&document.body.contains(el)&&(el.title=e.message)}finally{v._durationLoading=!1}}}document.querySelectorAll(".vl-header [data-sort]").forEach(el=>el.addEventListener("click",()=>setSort(el.dataset.sort)));function dominantLanguages(limit=3){const counts=new Map;return(_voices2||[]).forEach(v=>{const lang=(v.lang||String(v.id||"").split("_")[0]||"?").toUpperCase();counts.set(lang,(counts.get(lang)||0)+1)}),[...counts.entries()].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0])).slice(0,limit).map(([lang,count])=>`${lang} ${count}`).join(" \xB7 ")||"-"}function updateLibraryInsights(state="ready"){const el=$("library-insights");if(!el)return;if(state==="loading"){el.innerHTML=[["\u2026","Loading"],["\u2026","Active"],["\u2026","Languages"],["\u2026","Benchmarks"],["\u2026","Quality"],["\u2026","Actions"]].map(([value,label])=>`${value}${label}
`).join("");return}if(state==="error"){el.innerHTML='FailedLibrary load
';return}const total=_voices2.length,active=_voices2.filter(v=>v.enabled!==!1).length,hidden=total-active,bench=_voices2.map(voiceBenchmark).filter(Boolean),slow=bench.filter(b=>b&&b.ok&&b.realtime_ok===!1).length,dbValues=_voices2.map(voiceDbfs).filter(v=>v!=null),avgDb=dbValues.length?(dbValues.reduce((a,b)=>a+b,0)/dbValues.length).toFixed(1):"-",missingRef=_voices2.filter(v=>!v.transcript).length,restart=_voices2.filter(v=>v.needs_tts_restart).length,tiles=[{value:`${_voices2.filter(v=>$("show-disabled-cb").checked||v.enabled!==!1).length}/${total}`,label:"Visible"},{value:`${active} on`,label:hidden?`${hidden} hidden`:"Active"},{value:dominantLanguages(),label:"Languages"},{value:bench.length?`${bench.length} done`:"-",label:slow?`${slow} slow`:"Benchmarks",filter:slow?"slow":"",title:slow?describeIssueVoices("slow"):"No slow voices"},{value:avgDb==="-"?"-":`${avgDb} dB`,label:missingRef?`${missingRef} no text`:"Avg loudness",filter:missingRef?"no_text":"",title:missingRef?describeIssueVoices("no_text"):"All visible voices have reference text"},{value:restart||"-",label:restart?"Need restart":"Restart flags",filter:restart?"restart":"",title:restart?describeIssueVoices("restart"):"No voices need restart"}];el.innerHTML=tiles.map(item=>{const filter=item.filter?` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"`:"",activeCls=item.filter&&item.filter===_libraryIssueFilter?" active":"",title=item.title?` title="${escHtml(item.title)}"`:"";return`${escHtml(item.value)}${escHtml(item.label)}
`}).join(""),el.querySelectorAll("[data-filter]").forEach(tile=>{const activate=()=>setLibraryIssueFilter(tile.dataset.filter||"");tile.addEventListener("click",activate),tile.addEventListener("keydown",e=>{(e.key==="Enter"||e.key===" ")&&(e.preventDefault(),activate())})})}function shouldRenderVoiceLibrary(){const section=$("s-voices");return!section||section.classList.contains("is-active")}async function loadVoiceLibrary2(options={}){const forceRefresh=!!(options&&options.refresh);return _libraryLoadPromise2?_libraryLoadPromise2.then(()=>{shouldRenderVoiceLibrary()&&_voices2.length&&renderVoiceList2()}):(_libraryLoadPromise2=(async()=>{setBusyButton("refresh-voices-btn",!0);const list=$("voice-list"),renderVisibleList=shouldRenderVoiceLibrary(),cached=_voices2.length===0?_vlCacheRead():null;cached&&Array.isArray(cached)&&cached.length&&(_voices2=cached,window._voices=_voices2,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices2),renderVisibleList&&renderVoiceList2(),updatePreviewVoiceMatchPanel(),status(`Loaded ${_voices2.length} voices`));const silent=_voices2.length>0;list&&!silent&&renderVisibleList&&(list.innerHTML=loadingMarkup("Loading voice library","Scanning voices, reference text, metadata, ratings, and benchmark results.",8)),!silent&&renderVisibleList&&($("voice-count").textContent="Loading voices\u2026",updateLibraryInsights("loading"),status("Loading voice library\u2026"));try{const fresh=typeof window.fetchVoiceLibrary=="function"?await window.fetchVoiceLibrary({refresh:forceRefresh}):await fetch("/api/voices"+(forceRefresh?"?refresh=1":"")).then(async r=>{if(!r.ok)throw new Error(r.statusText);return r.json()});_vlCacheWrite(fresh),_voices2=fresh,window._voices=_voices2,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices2),shouldRenderVoiceLibrary()&&renderVoiceList2(),updatePreviewVoiceMatchPanel(),typeof renderPerfHistory=="function"&&renderPerfHistory(),status(`Loaded ${_voices2.length} voices`)}catch(e){if(!silent&&renderVisibleList&&(list&&(list.innerHTML='Failed to load voices: '+(e.message||String(e))+"
"),$("voice-count").textContent="Load failed",updateLibraryInsights("error")),status("Voice library load failed: "+e.message),!silent)throw e}finally{setBusyButton("refresh-voices-btn",!1),_libraryLoadPromise2=null}})(),_libraryLoadPromise2)}$("refresh-voices-btn").addEventListener("click",()=>{_vlCacheClear(),loadVoiceLibrary2({refresh:!0})});async function exportVoiceBundles(ids){let ok=0;for(const id of ids)try{const r=await fetch("/api/voices/"+encodeURIComponent(id)+"/export");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const url=URL.createObjectURL(await r.blob()),a=document.createElement("a");a.href=url,a.download=id+".voice.zip",document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(url),1e4),ok++}catch(e){toast(`Export of ${id} failed: ${e.message}`,"error")}return ok&&toast(`Exported ${ok} voice bundle${ok===1?"":"s"}`,"success"),ok}async function importVoiceBundles(files){const msgs=[];let failed=0;for(const f of files)try{const fd=new FormData;fd.append("file",f);const r=await fetch("/api/voices/import",{method:"POST",body:fd}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);msgs.push(d.status==="unchanged"?`${d.voice_id} already present`:d.status==="renamed"?`${d.voice_id} (name was taken)`:d.voice_id)}catch(e){failed++,toast(`Import of ${f.name} failed: ${e.message}`,"error")}return msgs.length&&(typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),await loadVoiceLibrary2({refresh:!0}),toast("Imported: "+msgs.join(", "),failed?"error":"success")),msgs.length}window.exportVoiceBundles=exportVoiceBundles,window.importVoiceBundles=importVoiceBundles,$("export-voices-btn").addEventListener("click",async()=>{if(!_bulkSelected2.size){toast("Check one or more voices first","error");return}$("export-voices-btn").disabled=!0;try{await exportVoiceBundles([..._bulkSelected2])}finally{$("export-voices-btn").disabled=!1}}),$("import-voices-btn").addEventListener("click",()=>$("import-voices-input").click()),$("import-voices-input").addEventListener("change",async ev=>{const files=[...ev.target.files];if(ev.target.value="",!!files.length){$("import-voices-btn").disabled=!0;try{await importVoiceBundles(files)}finally{$("import-voices-btn").disabled=!1}}}),$("sync-voice-folders-btn").addEventListener("click",async()=>{$("sync-voice-folders-btn").disabled=!0,status("Syncing active_voices and hidden_voices\u2026");try{const r=await fetch("/api/voices/sync-folders",{method:"POST"});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),await loadVoiceLibrary2({refresh:!0});const conflicts=d.conflicts&&d.conflicts.length?`, ${d.conflicts.length} conflicts`:"";toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`,d.conflicts&&d.conflicts.length?"error":"success"),status("Synced folders. Restart Qwen3-TTS after changing active voices.")}catch(e){toast("Sync failed: "+e.message,"error"),status("Folder sync failed")}finally{$("sync-voice-folders-btn").disabled=!1}});function visibleLibraryVoices(){const showDisabled=$("show-disabled-cb").checked;return _voices2.filter(v=>showDisabled||v.enabled!==!1)}function libraryIssueMatch(v,filter=_libraryIssueFilter){const b=voiceBenchmark(v);return filter==="slow"?!!(b&&b.ok&&b.realtime_ok===!1):filter==="no_text"?!String(v.transcript||"").trim():filter==="restart"?!!v.needs_tts_restart:!0}function libraryIssueLabel(filter=_libraryIssueFilter){return{slow:"slow benchmark voices",no_text:"voices without reference text",restart:"voices needing TTS restart"}[filter]||"all voices"}function libraryIssueVoices(filter=_libraryIssueFilter){return visibleLibraryVoices().filter(v=>libraryIssueMatch(v,filter))}function describeIssueVoices(filter=_libraryIssueFilter,limit=12){const voices=libraryIssueVoices(filter).map(v=>v.id);if(!voices.length)return"No matching voices";const extra=voices.length>limit?`, +${voices.length-limit} more`:"";return voices.slice(0,limit).join(", ")+extra}function setLibraryIssueFilter(filter=""){_libraryIssueFilter=_libraryIssueFilter===filter?"":filter,renderVoiceList2(),status(_libraryIssueFilter?`${libraryIssueLabel()}: ${describeIssueVoices()}`:"Showing all visible voices")}function libraryTargetDb(){var _a2;const input=$("library-target-db"),raw=Number((_a2=input==null?void 0:input.value)!=null?_a2:-20),value=Number.isFinite(raw)?Math.min(-1,Math.max(-60,raw)):-20;return input&&(input.value=String(value)),value}$("calculate-db-btn").addEventListener("click",async()=>{$("calculate-db-btn").disabled=!0;const _calcTarget=_bulkSelected2&&_bulkSelected2.size>0?`${_bulkSelected2.size} selected`:"visible";status(`Calculating voice loudness (${_calcTarget})\u2026`);try{const d=await clientCalculateVoiceDb();renderVoiceList2();const extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Calculated dB for ${d.calculated} voices${extra}`,d.errors&&d.errors.length?"error":"success"),status("Calculated voice loudness. Use Normalize volume for visible WAV voices.")}catch(e){toast("Calculate dB failed: "+e.message,"error"),status("dB calculation failed")}finally{$("calculate-db-btn").disabled=!1}}),$("normalize-volume-btn").addEventListener("click",async()=>{var _a2;const target=libraryTargetDb(),visible=visibleLibraryVoices(),voices=visible.filter(v=>voiceFileType(v)==="wav"),skipped=visible.length-voices.length;if(!voices.length){toast("No visible WAV voices to normalize","error");return}if(!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped?` ${skipped} non-WAV voices will be skipped.`:""}`))return;$("normalize-volume-btn").disabled=!0,$("calculate-db-btn").disabled=!0;const stats={startedAt:Date.now(),ok:0,slow:skipped,errors:0,middleLabel:"Skipped"},errors=[];let normalized=0;setBenchmarkProgress(0,voices.length,`Normalizing to ${target} dBFS...`,stats),status(`Normalizing ${voices.length} voices to ${target} dBFS...`);try{for(const v of voices){setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats);try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a2=d.duration)!=null?_a2:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),normalized++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs2(v)} dBFS`}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats),status(`Normalized ${normalized} / ${voices.length}`),await new Promise(resolve=>setTimeout(resolve,0))}setBenchmarkProgress(voices.length,voices.length,"Volume normalization complete",stats),renderVoiceList2(),updateLibraryInsights();const extra=`${skipped?`, ${skipped} skipped`:""}${errors.length?`, ${errors.length} errors`:""}`;toast(`Normalized ${normalized} voices${extra}`,errors.length?"error":"success"),status("Volume normalized. Restart TTS before rebenchmarking these voices.")}catch(e){toast("Normalize volume failed: "+e.message,"error"),status("Normalize volume failed")}finally{$("normalize-volume-btn").disabled=!1,$("calculate-db-btn").disabled=!1}});function fmtClock(ms){if(!Number.isFinite(ms)||ms<0)return"-";const total=Math.round(ms/1e3),m=Math.floor(total/60),s=total%60;return`${m}:${String(s).padStart(2,"0")}`}function setBenchmarkProgress(done,total,label="",stats={}){const panel=$("benchmark-progress"),track=panel.querySelector(".benchmark-progress-track"),pct=total?Math.round(done/total*100):0;panel.hidden=!1,$("benchmark-progress-label").textContent=label||(done>=total?"Benchmark complete":"Benchmarking voices..."),$("benchmark-progress-count").textContent=`${done} / ${total}`,$("benchmark-progress-bar").style.width=pct+"%",track.setAttribute("aria-valuenow",String(pct));const live=$("benchmark-live-stats");if(live){const elapsed=stats.startedAt?Date.now()-stats.startedAt:0,avg=done>0?elapsed/done:0,eta=done>0&&total>done?avg*(total-done):0;live.innerHTML=[`Elapsed ${fmtClock(elapsed)}`,`Avg ${done?(avg/1e3).toFixed(1)+"s":"-"}`,`ETA ${done&&total>done?fmtClock(eta):"-"}`,`OK ${stats.ok||0}`,`${stats.middleLabel||"Slow"} ${stats.slow||0}`,`${stats.errorLabel||"Errors"} ${stats.errors||0}`].map(x=>`${escHtml(x)}`).join("")}const last=$("benchmark-live-last");last&&stats.last&&(last.textContent=stats.last)}async function clearTtsRestartFlags2(){const r=await fetch("/api/tts/restart-flags/clear",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();return _voices2.forEach(voice=>{voice.needs_tts_restart=!1}),document.querySelectorAll(".vl-row.edit-open").forEach(row=>row.classList.remove("opt-restart-needed")),updateLibraryInsights(),d}async function runVoiceBenchmark(voiceId="",opts={}){var _a2;const text=(_a2=opts.text)!=null?_a2:benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;const payload={active_only:!0,text};voiceId&&(payload.voice_id=voiceId);const r=await fetch("/api/voices/benchmark",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return r.json()}async function runVoiceBenchmarkBatch(){const voices=benchmarkTargetVoices(),text=benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;if(!voices.length)return toast("No voices to benchmark","error"),null;const total=voices.length,aggregate={benchmarked:0,errors:[],voices:[],text,active_only:!0},stats={startedAt:Date.now(),ok:0,slow:0,errors:0,last:""};voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&(row.classList.remove("benchmarking-active","benchmarking-done"),row.classList.add("benchmarking-pending"))}),setBenchmarkProgress(0,total,"Starting benchmark...",stats);let lastFailure="",sameFailure=0,aborted=!1;for(let i=0;ix.voice_id===voice.id),b=hit&&hit.benchmark;if(b&&b.ok){if(stats.ok++,b.realtime_ok===!1&&stats.slow++,stats.last=`${voice.id}: ${Number(b.elapsed_sec||0).toFixed(1)}s${b.speed!=null?` \xB7 ${Number(b.speed).toFixed(2)}x`:""}${b.realtime_ok===!1?" \xB7 slow":""}`,row){const bc=benchmarkClass2(voice),btitle=benchmarkTitle2(voice),durCell=row.querySelector(".vl-tbl-dur"),factorCell=row.querySelector(".vl-tbl-factor"),timeCell=row.querySelector(".vl-tbl-time"),wpmCell=row.querySelector(".vl-tbl-wpm");if(durCell&&(durCell.textContent=fmtBenchmarkAudio(voice),durCell.title=`${fmtBenchmarkAudio(voice)} \u2014 length of synthesised benchmark audio`),factorCell&&(factorCell.textContent=fmtFactor(voice),factorCell.className=`vl-tbl-factor ${bc}`,factorCell.title=btitle),timeCell&&(timeCell.textContent=fmtElapsed(voice),timeCell.className=`vl-tbl-time ${bc}`,timeCell.title=btitle),wpmCell){const wpm=voiceWpm(voice);wpmCell.textContent=fmtWpm(voice),wpmCell.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}}else{stats.errors++,stats.last=`${voice.id}: failed${b&&b.error?" \xB7 "+b.error:""}`;const reason=b&&b.error?String(b.error):"";sameFailure=reason&&reason===lastFailure?sameFailure+1:1,lastFailure=reason,/unreachable/i.test(reason)&&sameFailure>=3&&(toast(reason,"error"),aborted=!0)}}}catch(e){aggregate.errors.push({voice_id:voice.id,detail:e.message}),stats.errors++,stats.last=`${voice.id}: failed \xB7 ${e.message}`}row&&(row.classList.remove("benchmarking-active"),row.classList.add("benchmarking-done")),setBenchmarkProgress(i+1,total,`Finished ${voice.id}`,stats)}return voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&row.classList.remove("benchmarking-pending")}),setBenchmarkProgress(total,total,"Benchmark complete",stats),aggregate}function mergeBenchmarkResults(d){const byId=new Map((d.voices||[]).map(x=>[x.voice_id,x]));_voices2.forEach(v=>{const hit=byId.get(v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark)})}window.loadVoiceLibrary=loadVoiceLibrary2,window.mergeBenchmarkResults=mergeBenchmarkResults;function activeBenchmarkVoices(){return(_voices2||[]).filter(v=>v.enabled!==!1)}function benchmarkTargetVoices(){return typeof _bulkSelected2!="undefined"&&_bulkSelected2.size>0?(_voices2||[]).filter(v=>_bulkSelected2.has(v.id)):activeBenchmarkVoices()}function showBenchmarkConfirm(){const voices=benchmarkTargetVoices(),onlySelected=typeof _bulkSelected2!="undefined"&&_bulkSelected2.size>0;if(!benchmarkSampleText()){toast("Enter a benchmark sample sentence","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} ${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=!1,$("benchmark-confirm-start").focus()}function hideBenchmarkConfirm(){const panel=$("benchmark-confirm");panel&&(panel.hidden=!0)}$("benchmark-voices-btn").addEventListener("click",showBenchmarkConfirm),(_a=$("benchmark-confirm-cancel"))==null||_a.addEventListener("click",hideBenchmarkConfirm),(_b=$("benchmark-confirm-start"))==null||_b.addEventListener("click",async()=>{hideBenchmarkConfirm(),$("benchmark-voices-btn").disabled=!0,$("benchmark-confirm-start").disabled=!0,status("Benchmarking active voices...");try{const d=await runVoiceBenchmarkBatch();if(!d)return;mergeBenchmarkResults(d),await loadVoiceLibrary2();const slow=(d.voices||[]).filter(x=>x.benchmark&&x.benchmark.realtime_ok===!1).length,extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Benchmarked ${d.benchmarked} voices${slow?`, ${slow} slow`:""}${extra}`,d.errors&&d.errors.length?"error":"success"),status("Benchmark saved with TTFA, total time, RTF, and speed.")}catch(e){toast("Benchmark failed: "+e.message,"error"),status("Benchmark failed")}finally{$("benchmark-voices-btn").disabled=!1,$("benchmark-confirm-start").disabled=!1}}),$("copy-active-voices-btn").addEventListener("click",async()=>{const useSelected=_bulkSelected2&&_bulkSelected2.size>0,ids=useSelected?[..._bulkSelected2]:activeVoiceIds(),label=useSelected?`${ids.length} selected`:`${ids.length} active`;if(!ids.length){toast("No voices to copy","error");return}await copyText(ids.join(", ")),toast("Copied "+label+" voices","success"),status("Copied "+label+" voices to clipboard")});async function _verifyVoicesRoundtrip(ids,opts){opts=opts||{};const results=[],queue=ids.slice(),worker=async()=>{for(;queue.length;){const id=queue.shift(),v=(window._voices||[]).find(x=>x.id===id),text=v&&v.transcript&&v.transcript.trim()||getLibAddSampleText(v&&v.lang||"EN"),backend=typeof _ttsBackendForVoice=="function"?_ttsBackendForVoice(id,libraryTtsBackend()):libraryTtsBackend();try{const rt=await _voiceRoundtripCheck(id,text,backend);results.push({id,text,transcript:rt.transcript,score:rt.score,ok:rt.score>=.5})}catch(e){results.push({id,text,error:e.message||String(e),ok:!1})}opts.onProgress&&opts.onProgress(results.length,ids.length,id)}};return await Promise.all(Array.from({length:Math.min(2,ids.length)},worker)),results}(_c=$("verify-voices-stt-btn"))==null||_c.addEventListener("click",async()=>{const ids=_bulkSelected2&&_bulkSelected2.size>0?[..._bulkSelected2]:activeVoiceIds();if(!ids.length){toast("No voices to verify","error");return}if(!confirm(`Verify ${ids.length} voice(s) by transcribing a synthesized line back with Whisper and comparing it to the original text? This makes one extra synth + STT call per voice.`))return;const btn=$("verify-voices-stt-btn");btn&&(btn.disabled=!0);const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="verify-voices-overlay",ov.innerHTML=` Verifying voices
+ `,list.appendChild(card)}))}function applyQwenSample(sample){$("design-instruct").value=sample.description,$("design-sample-text").value=sample.text,$("design-language").value=sample.language,$("design-gender").value=sample.gender,currentDesignSource=sample,$("design-result").style.display="none",$("design-save-result").style.display="none",$("design-instruct").scrollIntoView({behavior:"smooth",block:"nearest"})}function isDialogueDesign(instruct,text,source=null){if(source&&source.dialogue)return!0;const speakers=new Set;if(String(instruct||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/);match&&speakers.add(match[1].trim())}),speakers.size<2)return!1;const turnSpeakers=new Set;return String(text||"").split(/\n+/).forEach(line=>{const match=line.trim().match(/^([^:]{1,40}):\s*(.+)$/);match&&speakers.has(match[1].trim())&&turnSpeakers.add(match[1].trim())}),turnSpeakers.size>=2}function voiceDesignPayload(instruct,sampleText,language,source=null,gender=null){var _a2;return{instruct,sample_text:sampleText,language,gender:gender||(source==null?void 0:source.gender)||((_a2=$("design-gender"))==null?void 0:_a2.value)||"",dialogue:isDialogueDesign(instruct,sampleText,source)}}let _dVoiceIdManual=!1;function designSafeName(name){const base=name||"VoiceDesign";return(typeof _umlautSafe=="function"?_umlautSafe(base):String(base)).replace(/^[A-Z]{2}_[FMN]_/,"").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,42)||"VoiceDesign"}function voiceIdSafePart(value,fallback="style"){return(typeof _umlautSafe=="function"?_umlautSafe(value||fallback):String(value||fallback)).replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,32)||fallback}function suggestedStyleVoiceId2(baseId,style){const suffix=voiceIdSafePart(style||"style");return`${baseId}_${suffix}`.slice(0,96)}function _updateDVoiceId(){if(_dVoiceIdManual)return;const lang=$("d-lang").value,gender=$("d-gender").value,name=$("d-name").value.trim();$("d-voice-id").value=name?`${lang}_${gender}_${name}`:""}["d-lang","d-gender"].forEach(id=>["change","input"].forEach(ev=>$(id).addEventListener(ev,_updateDVoiceId))),$("d-name").addEventListener("input",()=>{_dVoiceIdManual=!1,_updateDVoiceId()}),$("d-voice-id").addEventListener("input",()=>{_dVoiceIdManual=!0}),seedDesignPresets(),refreshDesignPresetSelect(),renderQwenSampleCards(),syncDesignPresetsToServer(),$("design-preset-select").addEventListener("change",()=>{$("design-preset-select").value&&applyDesignPreset($("design-preset-select").value)}),$("design-preset-load").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}applyDesignPreset(name)}),$("design-preset-save").addEventListener("click",()=>{const name=$("design-preset-name").value.trim()||$("design-preset-select").value;if(!name){toast("Enter a preset name","error"),$("design-preset-name").focus();return}const presets=loadDesignPresets2();presets[name]={description:$("design-instruct").value,sample_text:$("design-sample-text").value,language:$("design-language").value,gender:$("design-gender").value,dialogue:isDialogueDesign($("design-instruct").value,$("design-sample-text").value,currentDesignSource)},saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-select").value=name,toast("Preset saved: "+name,"success")}),$("design-preset-delete").addEventListener("click",()=>{const name=$("design-preset-select").value||$("design-preset-name").value.trim();if(!name){toast("Select a preset first","error");return}const presets=loadDesignPresets2();if(!presets[name]){toast("Preset not found","error");return}delete presets[name],saveDesignPresets(presets),syncDesignPresetsToServer(),refreshDesignPresetSelect(),$("design-preset-name").value="",toast("Preset deleted: "+name,"success")}),["design-instruct","design-sample-text"].forEach(id=>$(id).addEventListener("input",()=>{currentDesignSource=null,id==="design-sample-text"&&($("d-transcript").value=$("design-sample-text").value)}));const DESIGN_CHIPS=[["Gender",!0,["male voice","female voice","neutral voice"]],["Age",!0,["child","teenager","young adult","middle-aged","elderly"]],["Pitch",!0,["high pitch","mid pitch","low pitch"]],["Pace",!0,["fast pace","moderate pace","slow pace"]],["Accent / region",!1,["British accent","American accent","Australian accent","Irish accent","German accent","Southern accent"]],["Tone / timbre",!1,["warm","husky","crisp","smooth","rich","magnetic","authoritative"]],["Emotion",!1,["cheerful","calm","serious","gentle","enthusiastic","sad"]]],_chipRe=p=>new RegExp("(^|[,.;]\\s*)"+p.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"(?=\\s*(?:[,.;]|$))","i"),chipHas=(text,p)=>_chipRe(p).test(text);function chipRemove(text,p){return text.replace(_chipRe(p),"").replace(/^\s*[,;]\s*/,"")}function chipAdd(text,p){const t=text.trimEnd();return t?/[.,;]$/.test(t)?t+" "+p:t+", "+p:p}function syncDesignChips(){const v=$("design-instruct").value;document.querySelectorAll("#design-chips [data-phrase]").forEach(b=>b.setAttribute("aria-pressed",String(chipHas(v,b.dataset.phrase))))}function renderDesignChips(){const tr=window.t||(x=>x),box=$("design-chips");DESIGN_CHIPS.forEach(([label,exclusive,phrases],i)=>{const g=document.createElement("div");g.className="design-chip-group",g.setAttribute("role","group"),g.setAttribute("aria-label",tr(label)),g.innerHTML='
',g.firstChild.textContent=tr(label),phrases.forEach(p=>{const b=document.createElement("button");b.type="button",b.className="design-chip",b.dataset.phrase=p,b.dataset.group=i,b.setAttribute("aria-pressed","false"),b.textContent=tr(p),g.appendChild(b)}),box.appendChild(g)}),box.addEventListener("click",e=>{const b=e.target.closest("[data-phrase]");if(!b)return;const ta=$("design-instruct");let v=ta.value;chipHas(v,b.dataset.phrase)?v=chipRemove(v,b.dataset.phrase):(DESIGN_CHIPS[b.dataset.group][1]&&DESIGN_CHIPS[b.dataset.group][2].forEach(o=>{v=chipRemove(v,o)}),v=chipAdd(v,b.dataset.phrase)),ta.value=v,ta.dispatchEvent(new Event("input",{bubbles:!0}))}),box.addEventListener("focusin",syncDesignChips),box.addEventListener("pointerenter",syncDesignChips),$("design-instruct").addEventListener("input",syncDesignChips),syncDesignChips()}renderDesignChips(),document.querySelectorAll(".qwen-sample").forEach(card=>{const sample=QWEN_DESIGN_SAMPLES[card.dataset.qwenSample],state=card.querySelector(".qwen-state"),audio=card.querySelector("audio");card.querySelector(".qwen-use").addEventListener("click",()=>{applyQwenSample(sample),toast("Voice Design sample loaded","success")}),card.querySelector(".qwen-preview").addEventListener("click",async e=>{const btn=e.currentTarget;btn.disabled=!0,state.textContent="Generating preview\u2026";try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(sample.description,sample.text,sample.language,sample))});if(!r.ok){const err=await r.json().catch(()=>({}));throw new Error(err.detail||r.statusText)}const d=await r.json();audio.src="/api/audio/"+d.id,audio.style.display="",audio.play().catch(()=>{}),state.textContent="Preview ready"}catch(err){state.textContent="Preview failed",toast("Sample preview failed: "+err.message,"error")}finally{btn.disabled=!1}})});async function runVoiceDesign(){const baseInstruct=$("design-instruct").value.trim(),sample=$("design-sample-text").value.trim(),instruct=baseInstruct;if(!instruct){toast("Enter a voice description first","error");return}$("design-generate-btn").disabled=!0,$("design-status").textContent="Generating\u2026",$("design-result").style.display="none",$("design-save-result").style.display="none",status("Generating voice design\u2026");try{const r=await fetch("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(voiceDesignPayload(instruct,sample,$("design-language").value,currentDesignSource,$("design-gender").value))});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();designedFileId2=d.id,trimmedFileId2=null,editingVoiceId2=null,$("design-audio").src="/api/audio/"+d.id,$("design-result").style.display="flex",$("design-status").textContent="Done ("+d.duration.toFixed(1)+" s)";const langCode=DESIGN_LANG_CODE[$("design-language").value]||"EN";$("d-lang").value=langCode,$("d-gender").value=$("design-gender").value,$("d-name").value=designSafeName((currentDesignSource==null?void 0:currentDesignSource.title)||(currentDesignSource==null?void 0:currentDesignSource.name)||$("design-preset-name").value||"VoiceDesign"),_dVoiceIdManual=!1,_updateDVoiceId(),$("d-transcript").value=sample,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",$("transcript-area").value||($("transcript-area").value=sample),$("design-audio").play().catch(()=>{}),$("design-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Voice generated and export fields filled.","success"),status("Voice design ready")}catch(e){$("design-status").textContent="Failed: "+e.message,toast("Voice design failed: "+e.message,"error"),status("Voice design failed")}finally{$("design-generate-btn").disabled=!1}}$("design-generate-btn").addEventListener("click",runVoiceDesign),$("design-retry-btn").addEventListener("click",runVoiceDesign),$("design-save-btn").addEventListener("click",async()=>{if(!designedFileId2){toast("No voice generated yet","error");return}const voiceId=$("d-voice-id").value.trim();if(!voiceId){toast("Enter a Voice ID first","error"),$("d-name").focus();return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("design-save-btn").disabled=!0;try{const r=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designedFileId2,voice_id:voiceId,transcript:$("d-transcript").value})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const saved=await r.json();await saveMeta(saved.voice_id,{gender:$("d-gender").value,flag:LANG_FLAG_DEFAULT[$("d-lang").value]||void 0,transcript:$("d-transcript").value,note:"Voice Design: "+$("design-instruct").value.slice(0,240),origin:"designed",voice_design_prompt:$("design-instruct").value}).catch(()=>{}),await loadVoiceLibrary().catch(()=>{}),$("design-save-result").style.display="flex",$("design-save-result").scrollIntoView({behavior:"smooth",block:"nearest"}),toast("Exported to Voice Clone Library: "+saved.voice_id,"success"),status("Exported to Voice Clone Library: "+saved.voice_id)}catch(e){toast("Save failed: "+e.message,"error")}finally{$("design-save-btn").disabled=!1}}),$("design-download-btn").addEventListener("click",()=>{if(!designedFileId2)return;const a=document.createElement("a");a.href="/api/audio/"+designedFileId2,a.download=($("d-voice-id").value.trim()||"voice_design")+".wav",a.click()}),(_d=$("clone-refresh-stt-btn"))==null||_d.addEventListener("click",async()=>{var _a2;$("clone-refresh-stt-btn").disabled=!0;try{await refreshSttBackends((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)}finally{$("clone-refresh-stt-btn").disabled=!1}}),$("transcribe-btn").addEventListener("click",async()=>{var _a2,_b2;const id=trimmedFileId2||designedFileId2||currentFileId2;if(!id){toast("No audio to transcribe","error");return}const btn=$("transcribe-btn"),status2=$("transcribe-status"),area=$("transcript-area"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='
Transcribing\u2026',status2&&(status2.className="clone-tr-status working",status2.innerHTML='
Listening to your recording\u2026'),area&&(area.classList.add("transcribing"),area.placeholder="Transcribing your audio \u2014 please wait\u2026");try{const backend=((_a2=$("clone-stt-backend"))==null?void 0:_a2.value)||"configured",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();area&&(area.value=d.text),status2&&(status2.className="clone-tr-status done",status2.innerHTML='
Transcribed'),toast("Transcription complete","success"),(_b2=window._cloneScheduleAutoSave)==null||_b2.call(window)}catch(e){status2&&(status2.className="clone-tr-status error",status2.innerHTML=`
Failed: ${escHtml(e.message||String(e))}`),toast("Transcription failed: "+e.message,"error")}finally{btn.disabled=!1,btn.innerHTML=orig,area&&(area.classList.remove("transcribing"),area.placeholder="Type or auto-transcribe the spoken text\u2026")}}),function(){var _a2,_b2,_c2;const g=id=>document.getElementById(id);let _idManual=!1,_prevName="Sam",_autoSaveTimer=null,_lastAutoSaved="";function buildVoiceId(){var _a3,_b3,_c3;if(_idManual){scheduleAutoSave();return}const lang=((_a3=g("lang-select"))==null?void 0:_a3.value)||"EN",gender=((_b3=g("gender-select"))==null?void 0:_b3.value)||"N",name=(((_c3=g("name-input"))==null?void 0:_c3.value)||"").trim().replace(/\s+/g,""),vid=g("voice-id-input");vid&&name&&(vid.value=`${lang}_${gender}_${name}`,vid.dispatchEvent(new Event("input"))),scheduleAutoSave()}const nameField=g("clone-your-name");nameField==null||nameField.addEventListener("input",()=>{const name=nameField.value.trim();if(!name)return;const sample=g("clone-sample-text");if(sample){const prev=sample.dataset.sampleName||_prevName,re=new RegExp("\\b"+prev.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b");re.test(sample.value)&&(sample.value=sample.value.replace(re,name)),sample.dataset.sampleName=name}_prevName=name;const ni=g("name-input");ni&&(ni.value=name.replace(/\s+/g,"")),buildVoiceId()}),["lang-select","gender-select"].forEach(id=>["change","input"].forEach(ev=>{var _a3;return(_a3=g(id))==null?void 0:_a3.addEventListener(ev,buildVoiceId)})),(_a2=g("name-input"))==null||_a2.addEventListener("input",buildVoiceId),(_b2=g("voice-id-input"))==null||_b2.addEventListener("input",e=>{e.isTrusted&&(_idManual=!0),scheduleAutoSave()}),(_c2=g("transcript-area"))==null||_c2.addEventListener("input",scheduleAutoSave),window._cloneAutoTranscribe=function(){var _a3;const ta=g("transcript-area");if(ta&&ta.value.trim()){scheduleAutoSave();return}(_a3=g("transcribe-btn"))==null||_a3.click()};function canAutoSave(){var _a3,_b3;const vid=(((_a3=g("voice-id-input"))==null?void 0:_a3.value)||"").trim(),tr=(((_b3=g("transcript-area"))==null?void 0:_b3.value)||"").trim();return!!((typeof trimmedFileId2!="undefined"&&trimmedFileId2||typeof designedFileId2!="undefined"&&designedFileId2)&&vid&&tr&&(typeof validateVoiceId!="function"||validateVoiceId(vid)))}function scheduleAutoSave(){const toggle=g("clone-autosave-toggle");!toggle||!toggle.checked||(clearTimeout(_autoSaveTimer),_autoSaveTimer=setTimeout(()=>{var _a3;if(!canAutoSave())return;const sig=(g("voice-id-input").value+"|"+g("transcript-area").value).trim();sig!==_lastAutoSaved&&(_lastAutoSaved=sig,(_a3=g("save-btn"))==null||_a3.click())},1600))}window._cloneScheduleAutoSave=scheduleAutoSave}(),function(){const picker=document.getElementById("clone-src-picker");if(!picker)return;const cards=[...document.querySelectorAll(".clone-src-card")],tabs=[...picker.querySelectorAll(".clone-src-tab")],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")}(),Object.assign(root,{CLONE_SAMPLE_TEXTS,CLONE_SAMPLE_NAMES,cloneSampleForLang,ws,wsRegions,currentFileId:currentFileId2,trimmedFileId:trimmedFileId2,designedFileId:designedFileId2,editingVoiceId:editingVoiceId2,editingVoicePath:editingVoicePath2,initWaveSurfer,updateRegion,updateDurationLabel,loadAudioId:loadAudioId2,dropZone,fileInput,uploadFile,loadLibraryVoiceAudio:loadLibraryVoiceAudio2,RAW_MIC_CONSTRAINTS,visibleMicrophoneCount,microphoneErrorMessage:microphoneErrorMessage2,requestMicrophoneStream:requestMicrophoneStream2,_cloneMonState,_cloneRenderMeter,_cloneStartMeter,_cloneStartMonitor,_cloneStopMonitor,mediaRec,recChunks,recTimer,recSecs,DESIGN_LANG_CODE,DESIGN_GENDER_WORD,DESIGN_PRESET_KEY,DESIGN_PRESET_SEEDED_KEY,DEFAULT_DESIGN_PRESETS,QWEN_DESIGN_SAMPLES,currentDesignSource,loadDesignPresets:loadDesignPresets2,saveDesignPresets,syncDesignPresetsToServer,seedDesignPresets,refreshDesignPresetSelect,applyDesignPreset,renderDesignPresetLibrary,renderQwenSampleCards,applyQwenSample,isDialogueDesign,voiceDesignPayload,_dVoiceIdManual,designSafeName,voiceIdSafePart,suggestedStyleVoiceId:suggestedStyleVoiceId2,_updateDVoiceId,runVoiceDesign}),root.TTVC=root.TTVC||{},root.TTVC.voiceClone=root.TTVC.voiceClone||{},Object.assign(root.TTVC.voiceClone,{samples:{forLanguage:cloneSampleForLang},audio:{init:initWaveSurfer,load:loadAudioId2,upload:uploadFile},design:{run:runVoiceDesign,buildPayload:voiceDesignPayload}})})(window),setupVocalIsolation({btn:"yt-iso-btn",toggle:"yt-iso-toggle",msg:"yt-iso-msg",getId:()=>currentFileId,getDur:()=>parseFloat($("trim-end").value)||0,apply:(id,dur)=>loadAudioId(id,dur)}),function(){const det=document.getElementById("archetype-gallery");if(!det)return;const tr=s=>window.t?window.t(s):s,$a=id=>document.getElementById(id),PAGE=24,FAV_KEY="ttsvc-archetype-favs",FACETS=[["gender","Gender",["male","female","neutral"]],["age","Age",["child","teenager","young adult","middle-aged","elderly"]],["pitch","Pitch",["high","mid","low"]],["accent","Accent / region",["none","british","american","australian","irish","german","southern"]],["language","Language",["German","English"]]],st={gender:"",age:"",pitch:"",accent:"",language:"",category:"",q:"",favOnly:!1,offset:0,total:0};let favs=new Set,booted=!1,token=0,objUrl="";try{favs=new Set(JSON.parse(localStorage.getItem(FAV_KEY)||"[]"))}catch{}const saveFavs=()=>{try{localStorage.setItem(FAV_KEY,JSON.stringify([...favs]))}catch{}},archSay=m=>{$a("arch-status").textContent=m},lang=()=>window.__APP_LANG__==="de"?"de":"en";function buildChips(){const box=$a("arch-chips");FACETS.forEach(([key,label,vals])=>{const g=document.createElement("div");g.className="design-chip-group",g.setAttribute("role","group"),g.setAttribute("aria-label",tr(label));const l=document.createElement("span");l.className="design-chip-label",l.textContent=tr(label),g.appendChild(l),vals.forEach(v=>{const b=document.createElement("button");b.type="button",b.className="design-chip",b.dataset.k=key,b.dataset.v=v,b.setAttribute("aria-pressed","false"),b.textContent=tr(v),g.appendChild(b)}),box.appendChild(g)});const f=document.createElement("button");f.type="button",f.className="design-chip",f.id="arch-fav-only",f.setAttribute("aria-pressed","false"),f.textContent=tr("Favourites only"),box.appendChild(f),box.addEventListener("click",e=>{const b=e.target.closest("button");b&&(b.id==="arch-fav-only"?st.favOnly=!st.favOnly:st[b.dataset.k]=st[b.dataset.k]===b.dataset.v?"":b.dataset.v,box.querySelectorAll("[data-k]").forEach(x=>x.setAttribute("aria-pressed",String(st[x.dataset.k]===x.dataset.v))),f.setAttribute("aria-pressed",String(st.favOnly)),load(!0))})}function card(a){const name=lang()==="de"?a.name_de:a.name_en,el=document.createElement("article");el.className="arch-card",el.dataset.id=a.id;const h=document.createElement("h3");h.textContent=name;const f=document.createElement("div");f.className="arch-facets",f.textContent=[a.language,...Object.values(a.facets)].map(tr).join(" \xB7 ");const p=document.createElement("p");p.textContent=a.category;const row=document.createElement("div");row.className="arch-actions";const mk=(cls,text,label)=>{const b=document.createElement("button");return b.type="button",b.className=cls,b.textContent=text,label&&b.setAttribute("aria-label",label+": "+name),row.appendChild(b),b};return mk("btn-primary arch-play",tr("Preview"),tr("Preview")),mk("btn-secondary arch-use",tr("Use voice"),tr("Use voice")),mk("btn-secondary arch-fav",favs.has(a.id)?"\u2605":"\u2606",tr("Favourite")).setAttribute("aria-pressed",String(favs.has(a.id))),el.append(h,f,p,row),el}async function load(reset){const my=++token;reset&&(st.offset=0,$a("arch-grid").textContent="");const q=new URLSearchParams({offset:st.offset,limit:st.favOnly?100:PAGE});["gender","age","pitch","accent","language","category","q"].forEach(k=>st[k]&&q.set(k,st[k])),archSay(tr("Loading\u2026"));try{const r=await fetch("/api/archetypes?"+q);if(!r.ok)throw new Error(r.statusText);const d=await r.json();if(my!==token)return;if(!$a("arch-category").options.length){const sel=$a("arch-category");[["",tr("All categories")],...d.categories.map(c=>[c,c])].forEach(([v,t])=>sel.add(new Option(t,v)))}const items=st.favOnly?d.items.filter(a=>favs.has(a.id)):d.items,grid=$a("arch-grid");items.forEach(a=>grid.appendChild(card(a))),st.offset+=d.items.length,st.total=d.total,$a("arch-more").hidden=st.favOnly||st.offset>=d.total,archSay(grid.children.length?grid.children.length+" / "+(st.favOnly?grid.children.length:d.total):tr("No voices match."))}catch{my===token&&archSay(tr("Could not load the gallery."))}}async function play(a,btn){btn.disabled=!0,archSay(tr("Rendering preview\u2026"));try{const r=await fetch("/api/archetypes/"+encodeURIComponent(a)+"/preview",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}objUrl&&URL.revokeObjectURL(objUrl),objUrl=URL.createObjectURL(await r.blob());const au=$a("arch-audio");au.src=objUrl,await au.play().catch(()=>{}),archSay(tr("Preview ready"))}catch(e){archSay(tr("Preview failed")+": "+e.message)}finally{btn.disabled=!1}}async function use(id,btn){btn.disabled=!0,archSay(tr("Saving voice\u2026"));try{const r=await fetch("/api/archetypes/"+encodeURIComponent(id)+"/use",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();if(archSay(tr("Voice saved")+": "+d.voice_id),typeof toast=="function"&&toast(tr("Voice saved")+": "+d.voice_id,"success"),!btn.parentNode.querySelector(".arch-open")){const o=document.createElement("button");o.type="button",o.className="btn-secondary arch-open",o.textContent=tr("Open in Design"),o.dataset.instruct=d.instruct,o.dataset.language=d.language,btn.parentNode.insertBefore(o,btn.nextSibling),o.focus()}typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){archSay(tr("Save failed")+": "+e.message),typeof toast=="function"&&toast(e.message,"error")}finally{btn.disabled=!1}}function boot(){if(booted)return;booted=!0,buildChips();let t;$a("arch-q").addEventListener("input",e=>{clearTimeout(t),t=setTimeout(()=>{st.q=e.target.value.trim(),load(!0)},250)}),$a("arch-category").addEventListener("change",e=>{st.category=e.target.value,load(!0)}),$a("arch-more").addEventListener("click",()=>load(!1)),$a("arch-grid").addEventListener("click",e=>{const b=e.target.closest("button");if(!b)return;const id=b.closest(".arch-card").dataset.id;if(b.classList.contains("arch-play"))play(id,b);else if(b.classList.contains("arch-use"))use(id,b);else if(b.classList.contains("arch-fav"))favs.has(id)?favs.delete(id):favs.add(id),saveFavs(),b.textContent=favs.has(id)?"\u2605":"\u2606",b.setAttribute("aria-pressed",String(favs.has(id)));else if(b.classList.contains("arch-open")){const ta=$a("design-instruct");ta.value=b.dataset.instruct,ta.dispatchEvent(new Event("input",{bubbles:!0}));const sel=$a("d-lang");if(sel)for(const o of sel.options)o.value===b.dataset.language&&(sel.value=o.value);ta.scrollIntoView({behavior:matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth",block:"center"}),ta.focus()}}),load(!0)}det.addEventListener("toggle",()=>{det.open&&boot()}),det.open&&boot()}(),function(root){var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s;$("save-btn").addEventListener("click",async()=>{const id=trimmedFileId||designedFileId||currentFileId;if(!id){toast("No audio ready","error");return}const voiceId=$("voice-id-input").value.trim();if(!voiceId){toast("Enter a Voice ID","error");return}if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}$("save-btn").disabled=!0;try{const payload={id,voice_id:voiceId,path:editingVoicePath,transcript:$("transcript-area").value},sendSave=endpoint=>fetch(endpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});let fallbackSave=!1,r=await sendSave(editingVoiceId?"/api/voice-replace":"/api/save");if(editingVoiceId&&(r.status===404||r.status===405)&&(fallbackSave=!0,status("Update endpoint unavailable; saving as a regular voice\u2026"),r=await sendSave("/api/save")),!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();$("save-result").style.display="",toast((editingVoiceId?"Voice updated: ":"Voice saved: ")+d.voice_id,"success"),fallbackSave&&editingVoiceId&&voiceId!==editingVoiceId&&((await fetch("/api/voice/"+encodeURIComponent(editingVoiceId),{method:"DELETE"})).ok||status("Saved renamed voice; old library entry may need manual deletion.")),editingVoiceId=null,editingVoicePath=null}catch(e){toast("Save failed: "+e.message,"error")}finally{$("save-btn").disabled=!1}});let _voices2=[],_pendingSelectId2=null,_sortField="id",_sortDir=1,_libraryIssueFilter="",_activePlayButton=null,_activePlayVoiceId=null,_activePlayUrl=null,_libraryLoadPromise2=null;const BENCHMARK_SAMPLE_STORAGE_KEY="vcf-benchmark-sample-text",_VL_CACHE_KEY="ttsvc_vc";function _vlCacheRead(){try{const value=JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY)||"null");return Array.isArray(value)?value:Array.isArray(value==null?void 0:value.voices)?value.voices:null}catch{return null}}function _vlCacheWrite(voices){try{sessionStorage.setItem(_VL_CACHE_KEY,JSON.stringify(voices))}catch{}}function _vlCacheClear(){try{sessionStorage.removeItem(_VL_CACHE_KEY)}catch{}}window._vlCacheClear=_vlCacheClear;const _libraryFilters={text:"",lang:"",sex:"",type:"",rating:""};let _libraryFilterOptionsSig="";const DEFAULT_BENCHMARK_SAMPLE_TEXT="Hello, how are you today? Please read this sample clearly for a fair voice benchmark.",BENCHMARK_PRESETS={de:"Die Welt ist voller Geschichten, die darauf warten, erz\xE4hlt zu werden \u2014 von mutigen Helden und stillen Tr\xE4umern.",en:"The old lighthouse stood firm against the crashing waves, its beam sweeping silently across the dark and restless sea.",de2:"Victor jagt zw\xF6lf Boxk\xE4mpfer quer \xFCber den gro\xDFen Sylter Deich. Im Winter ist es kalt und die Tage sind kurz.",en2:"She sells seashells by the seashore. Peter Piper picked a peck of pickled peppers on a perfectly pleasant afternoon.",reset:DEFAULT_BENCHMARK_SAMPLE_TEXT};function benchmarkSampleText(){const el=$("benchmark-sample-text");return el&&el.value.trim()||DEFAULT_BENCHMARK_SAMPLE_TEXT}function initBenchmarkSampleControls(){var _a2,_b2;const sample=$("benchmark-sample-text");if(!sample)return;sample.value=localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY)||DEFAULT_BENCHMARK_SAMPLE_TEXT,sample.addEventListener("input",debounce(()=>{localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value.trim()),status("Benchmark sample sentence saved")},500)),(_a2=$("benchmark-reset-sample-btn"))==null||_a2.addEventListener("click",()=>{sample.value=DEFAULT_BENCHMARK_SAMPLE_TEXT,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),status("Benchmark sample sentence reset")});const presetSel=$("benchmark-preset-select");presetSel&&presetSel.addEventListener("change",()=>{const key=presetSel.value;if(!key||!BENCHMARK_PRESETS[key]){presetSel.value="";return}sample.value=BENCHMARK_PRESETS[key],localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,sample.value),presetSel.value="",status("Benchmark sample sentence loaded")}),(_b2=$("benchmark-use-preview-btn"))==null||_b2.addEventListener("click",()=>{var _a3;const text=(_a3=$("preview-text-area"))==null?void 0:_a3.value.trim();if(!text){toast("Preview text is empty","error");return}sample.value=text,localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY,text),status("Benchmark sample sentence copied from TTS preview")})}function _displaySource(v){if(v.origin)return v.origin;if(v.note){const m=v.note.match(/^Rehearser\s*·\s*(.+?)\s*·/);if(m)return m[1].trim()}if(v.tag){const tags=v.tag.split(",").map(t=>t.trim().toLowerCase());if(tags.includes("fish-audio")||tags.includes("fishaudio"))return"fish-audio"}return""}function getSortValue(v,field){var _a2,_b2,_c2,_d2,_e2,_f2;switch(field){case"has_picture":return v.has_picture?1:0;case"flag":return(v.flag||"").toLowerCase();case"gender":return(_a2={F:0,M:1,N:2}[v.gender])!=null?_a2:3;case"id":return v.id.toLowerCase();case"file_type":return voiceFileType(v);case"duration":return v.duration||0;case"dbfs":return(_b2=voiceDbfs(v))!=null?_b2:-999;case"benchmark":case"factor":return-((_c2=voiceFactor(v))!=null?_c2:-999);case"elapsed":return(_d2=voiceBenchmarkElapsed(v))!=null?_d2:999;case"bench_audio":return(_e2=voiceBenchmarkAudioSec(v))!=null?_e2:999;case"wpm":return(_f2=voiceWpm(v))!=null?_f2:-1;case"transcript":return(v.transcript||"").toLowerCase();case"note":return(v.note||"").toLowerCase();case"source":return(_displaySource(v)||"").toLowerCase();case"seed":return v.seed!=null?v.seed:9999999;case"tag":return(v.tag||"").toLowerCase();case"rating":return v.rating||0;case"enabled":return v.enabled===!1?0:1;default:return""}}function setSort(field){_sortDir=_sortField===field?_sortDir*-1:1,_sortField=field,syncSortHeaders(),renderVoiceList2()}function toggleSortDir(){_sortDir*=-1,syncSortHeaders(),renderVoiceList2()}function syncSortHeaders(){document.querySelectorAll(".vl-header [data-sort], .vl-table-header [data-sort], .vl-compact-header [data-sort]").forEach(el=>{el.classList.remove("sort-asc","sort-desc"),el.removeAttribute("aria-sort"),el.dataset.sort===_sortField&&(el.classList.add(_sortDir===1?"sort-asc":"sort-desc"),el.setAttribute("aria-sort",_sortDir===1?"ascending":"descending"))});const sel=document.getElementById("voice-sort-field");sel&&sel.value!==_sortField&&(sel.value=_sortField);const dirBtn=document.getElementById("voice-sort-dir");if(dirBtn){const icon=dirBtn.querySelector(".mdi");icon&&(icon.className=_sortDir===1?"mdi mdi-arrow-up":"mdi mdi-arrow-down"),dirBtn.title=_sortDir===1?"Ascending \u2014 click to reverse":"Descending \u2014 click to reverse"}}document.addEventListener("click",e=>{var _a2,_b2,_c2;if(e.target.closest("#voice-sort-dir")&&toggleSortDir(),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==null||btn.classList.toggle("active",window._voiceGroupByTag);const icon=btn==null?void 0:btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline"),renderVoiceList2()}else if(e.target.closest("#voice-table-view-btn")){window._voiceTableView=!window._voiceTableView,window._voiceTableView||(window._voiceTableEditMode=!1);try{localStorage.setItem("vl-table-view",window._voiceTableView?"1":"0")}catch{}const btn=document.getElementById("voice-table-view-btn");btn==null||btn.classList.toggle("active",window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");if(editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode)),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",window._voiceTableView),(_b2=document.querySelector(".voices-workbench"))==null||_b2.classList.toggle("table-edit-mode",!!window._voiceTableEditMode),window._voiceTableView){document.querySelectorAll(".edit-open").forEach(r=>r.classList.remove("edit-open"));const inspector=document.getElementById("voices-inspector");inspector&&(inspector.innerHTML='
Table View Mode
Click a row to exit table view and edit.
')}renderVoiceList2()}else if(e.target.closest("#voice-table-edit-btn")){window._voiceTableEditMode=!window._voiceTableEditMode;const editBtn=document.getElementById("voice-table-edit-btn");editBtn==null||editBtn.classList.toggle("active",window._voiceTableEditMode),(_c2=document.querySelector(".voices-workbench"))==null||_c2.classList.toggle("table-edit-mode",window._voiceTableEditMode),renderVoiceList2()}});try{window._voiceGroupByTag=localStorage.getItem("vl-group-by-tag")==="1"}catch{}try{window._voiceTableView=localStorage.getItem("vl-table-view")==="1"}catch{}document.addEventListener("click",e=>{const th=e.target.closest(".vl-th-sortable");if(th){const field=th.dataset.sort;if(field){const sel=document.getElementById("voice-sort-field");sel&&(sel.value=field),setSort(field)}}}),document.addEventListener("change",e=>{e.target.id==="voice-sort-field"&&setSort(e.target.value)}),document.addEventListener("change",async e=>{if(e.target.classList.contains("vl-inline-edit")){const row=e.target.closest(".vl-row");if(!row)return;const voiceId=row.dataset.id,field=e.target.dataset.field;let value=e.target.type==="checkbox"?e.target.checked:e.target.value;field==="rating"&&(value=parseInt(value)||0);const payload={};payload[field]=value;try{await saveMeta2(voiceId,payload);const v=_voices2.find(vv=>vv.id===voiceId);v&&(v[field]=value),typeof toast=="function"&&toast("Saved "+field,"success")}catch{typeof toast=="function"&&toast("Failed to save "+field,"error")}}});const FLAG_LANGUAGE_CANDIDATES={GB:["EN"],US:["EN"],AU:["EN"],NZ:["EN"],IE:["EN"],ZA:["EN"],NG:["EN"],KE:["EN"],GH:["EN"],JM:["EN"],TT:["EN"],CA:["EN","FR"],IN:["EN","HI"],SG:["EN","ZH"],PH:["EN","FIL"],MT:["EN","MT"],DE:["DE"],AT:["DE"],CH:["DE","FR","IT"],FR:["FR"],BE:["FR","NL"],LU:["FR","DE"],ES:["ES"],MX:["ES"],AR:["ES"],CO:["ES"],CL:["ES"],PE:["ES"],VE:["ES"],UY:["ES"],EC:["ES"],BO:["ES"],CR:["ES"],CU:["ES"],DO:["ES"],PT:["PT"],BR:["PT"],IT:["IT"],NL:["NL"],PL:["PL"],SE:["SV"],DK:["DA"],NO:["NO"],FI:["FI"],IS:["IS"],GR:["EL"],CY:["EL","TR"],CZ:["CS"],SK:["SK"],HU:["HU"],RO:["RO"],BG:["BG"],HR:["HR"],SI:["SL"],RS:["SR"],BA:["BS"],ME:["SR"],MK:["MK"],AL:["SQ"],EE:["ET"],LV:["LV"],LT:["LT"],UA:["UK"],RU:["RU"],BY:["RU"],MD:["RO"],TR:["TR"],CN:["ZH"],TW:["ZH"],HK:["ZH"],MO:["ZH"],JP:["JA"],KR:["KO"],VN:["VI"],TH:["TH"],ID:["ID"],MY:["MS"],PK:["UR"],BD:["BN"],LK:["SI"],NP:["NE"],SA:["AR"],EG:["AR"],AE:["AR"],MA:["AR"],QA:["AR"],KW:["AR"],OM:["AR"],JO:["AR"],LB:["AR"],IQ:["AR"],IR:["FA"],IL:["HE"]},LANGUAGE_LABELS2={EN:"English",DE:"German",FR:"French",ES:"Spanish",PT:"Portuguese",IT:"Italian",NL:"Dutch",PL:"Polish",SV:"Swedish",DA:"Danish",NO:"Norwegian",FI:"Finnish",IS:"Icelandic",EL:"Greek",MT:"Maltese",CS:"Czech",SK:"Slovak",HU:"Hungarian",RO:"Romanian",BG:"Bulgarian",HR:"Croatian",SL:"Slovenian",SR:"Serbian",BS:"Bosnian",MK:"Macedonian",SQ:"Albanian",ET:"Estonian",LV:"Latvian",LT:"Lithuanian",UK:"Ukrainian",RU:"Russian",ZH:"Chinese",JA:"Japanese",KO:"Korean",VI:"Vietnamese",TH:"Thai",ID:"Indonesian",MS:"Malay",FIL:"Filipino",HI:"Hindi",UR:"Urdu",BN:"Bengali",SI:"Sinhala",NE:"Nepali",AR:"Arabic",FA:"Persian",HE:"Hebrew",TR:"Turkish"},SEX_FILTER_LABELS={F:"\u2640 Female",M:"\u2642 Male",N:"\u26A5 Diverse / neutral"};function voiceLangFromName(v){return(v.lang||String(v.id||"").split("_")[0]||"").toUpperCase()}function libraryVoiceLang(v){const fromName=voiceLangFromName(v),candidates=FLAG_LANGUAGE_CANDIDATES[String(v.flag||"").toUpperCase()];return candidates!=null&&candidates.length?candidates.includes(fromName)?fromName:candidates[0]:fromName}function libraryLanguageLabel(code){return LANGUAGE_LABELS2[code]||code}function populateLibraryFilters(){const langSel=$("library-filter-lang"),sexSel=$("library-filter-sex"),typeSel=$("library-filter-type"),tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");if(!langSel||!sexSel||!typeSel)return;const langSet=new Set,sexSet=new Set,typeSet=new Set,tagSet=new Set,groupSet=new Set;(_voices2||[]).forEach(v=>{const lang=libraryVoiceLang(v);lang&&langSet.add(lang),v.gender&&sexSet.add(v.gender);const type=voiceFileType(v);type&&typeSet.add(type),String(v.tag||"").split(",").map(t=>t.trim()).filter(Boolean).forEach(t=>tagSet.add(t));const g=(v.group||"").trim();g&&groupSet.add(g)});const langs=[...langSet].sort((a,b)=>libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b))),sexOrder=["F","M","N"],sexes=[...sexSet].sort((a,b)=>(sexOrder.indexOf(a)<0?99:sexOrder.indexOf(a))-(sexOrder.indexOf(b)<0?99:sexOrder.indexOf(b))),types=[...typeSet].sort((a,b)=>a.localeCompare(b)),tags=[...tagSet].sort((a,b)=>a.localeCompare(b)),groups=[...groupSet].sort((a,b)=>a.localeCompare(b)),sig=JSON.stringify([langs,sexes,types,tags,groups]);if(sig===_libraryFilterOptionsSig)return;_libraryFilterOptionsSig=sig;const keep={lang:langSel.value,sex:sexSel.value,type:typeSel.value,tag:tagSel==null?void 0:tagSel.value,group:groupSel==null?void 0:groupSel.value};langSel.innerHTML='
'+langs.map(x=>`
`).join(""),sexSel.innerHTML='
'+sexes.map(x=>`
`).join(""),typeSel.innerHTML='
'+types.map(x=>`
`).join(""),tagSel&&(tagSel.innerHTML='
'+tags.map(x=>`
`).join("")),groupSel&&(groupSel.innerHTML='
'+groups.map(x=>`
`).join("")),langSel.value=langs.includes(keep.lang)?keep.lang:"",sexSel.value=sexes.includes(keep.sex)?keep.sex:"",typeSel.value=types.includes(keep.type)?keep.type:"",tagSel&&(tagSel.value=tags.includes(keep.tag)?keep.tag:""),groupSel&&(groupSel.value=groups.includes(keep.group)?keep.group:"")}function readLibraryFilters(){var _a2,_b2,_c2,_d2,_e2;_libraryFilters.text=(((_a2=$("library-filter-text"))==null?void 0:_a2.value)||"").trim().toLowerCase(),_libraryFilters.lang=((_b2=$("library-filter-lang"))==null?void 0:_b2.value)||"",_libraryFilters.sex=((_c2=$("library-filter-sex"))==null?void 0:_c2.value)||"",_libraryFilters.type=((_d2=$("library-filter-type"))==null?void 0:_d2.value)||"",_libraryFilters.rating=((_e2=$("library-filter-rating"))==null?void 0:_e2.value)||""}function libraryFilterMatch(v){const f=_libraryFilters;if(f.lang&&libraryVoiceLang(v)!==f.lang||f.sex&&(v.gender||"")!==f.sex||f.type&&voiceFileType(v)!==f.type)return!1;if(f.rating){const r=Number(v.rating||0),wanted=Number(f.rating);if(wanted===0&&r!==0||wanted===1&&r<1||wanted>1&&r
String(x||"").toLowerCase()).join(" ").includes(f.text))return!1}return!0}function clearLibraryFilters(){["library-filter-text","library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{const el=$(id);el&&(el.value="")}),readLibraryFilters(),renderVoiceList2()}function libraryTtsBackend(){var _a2;return((_a2=$("library-tts-backend-select"))==null?void 0:_a2.value)||"voice_clone"}function needsDuration(v){return v.duration==null||Number.isNaN(Number(v.duration))}function voiceFileType(v){if(v.file_type)return String(v.file_type).replace(/^\./,"").toLowerCase();const match=String(v.path||v.filename||"").match(/\.([A-Za-z0-9]+)(?:$|[?#])/);return match?match[1].toLowerCase():"wav"}function voiceDbfs(v){var _a2;const value=v.loudness&&((_a2=v.loudness.dbfs)!=null?_a2:v.loudness.after_dbfs);return value==null||Number.isNaN(Number(value))?null:Number(value)}function fmtDbfs2(v){const db=voiceDbfs(v);return db==null?"-":db.toFixed(1)}function voiceBenchmark(v){return v.benchmark&&typeof v.benchmark=="object"&&Object.keys(v.benchmark).length>0?v.benchmark:null}function voiceBenchmarkElapsed(v){const b=voiceBenchmark(v),value=b&&b.elapsed_sec;return value==null||Number.isNaN(Number(value))?null:Number(value)}function voiceFactor(v){const b=voiceBenchmark(v);return b&&b.ok&&b.speed!=null?Number(b.speed):null}function fmtFactor(v){const f=voiceFactor(v);if(f!=null)return f.toFixed(2)+"x";const b=voiceBenchmark(v);return b&&!b.ok?"ERR":"-"}function fmtElapsed(v){const e=voiceBenchmarkElapsed(v),b=voiceBenchmark(v);return!b||!b.ok?b&&!b.ok?"ERR":"-":e!=null?e.toFixed(1)+"s":"-"}function fmtBenchmark2(v){const elapsed=fmtElapsed(v),factor=fmtFactor(v);return elapsed==="-"&&factor==="-"?"-":[elapsed,factor].filter(x=>x!=="-").join(" \xB7 ")}function benchmarkClass2(v){const b=voiceBenchmark(v);if(!b)return"";if(!b.ok||b.clipped||b.realtime_ok===!1)return"bench-bad";const elapsed=voiceBenchmarkElapsed(v);return elapsed!=null&&elapsed<=4?"bench-ok":"bench-warn"}function voiceBenchmarkAudioSec(v){const b=voiceBenchmark(v);return b&&b.ok&&b.audio_sec!=null?Number(b.audio_sec):null}function fmtBenchmarkAudio(v){const sec=voiceBenchmarkAudioSec(v);return sec!=null?sec.toFixed(1)+"s":"-"}function voiceWpm(v){const b=voiceBenchmark(v);if(!b||!b.ok||!b.audio_sec||!b.text)return null;const words=b.text.trim().split(/\s+/).length;return Math.round(words/(b.audio_sec/60))}function fmtWpm(v){const wpm=voiceWpm(v);return wpm!=null?wpm+" wpm":"-"}function voiceFileUrl2(v){const bust=v._audioVersion||v.updated_at||v.benchmarked_at||""||Date.now();return`/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`}function markVoiceAudioChanged(v){v._audioVersion=Date.now()}function benchmarkTitle2(v){const b=voiceBenchmark(v);if(!b)return"Not benchmarked yet";const parts=[];return b.ok?(parts.push(`total ${Number(b.elapsed_sec||0).toFixed(2)}s`),b.ttfa_ms!=null&&parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`),b.audio_sec!=null&&parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`),b.rtf!=null&&parts.push(`RTF ${Number(b.rtf).toFixed(2)}`),b.speed!=null&&parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`),b.clipped&&parts.push("output clipped")):(parts.push("benchmark failed"),b.error&&parts.push(b.error)),Array.isArray(b.advice)&&b.advice.length&&parts.push(b.advice.join(" | ")),b.benchmarked_at&&parts.push(`saved ${b.benchmarked_at}`),parts.join(" \xB7 ")}async function clientVoiceLoudness(v){if(!v.path)throw new Error("No audio path");const resp=await fetch(voiceFileUrl2(v),{cache:"no-store"});if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const audioData=await resp.arrayBuffer(),buffer=await new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(audioData.slice(0));let sum=0,peak=0,count=0;for(let ch=0;ch0?20*Math.log10(rms):null,peakDbfs=peak>0?20*Math.log10(peak):null;return{dbfs:dbfs==null?null:Number(dbfs.toFixed(2)),peak_dbfs:peakDbfs==null?null:Number(peakDbfs.toFixed(2))}}async function clientCalculateVoiceDb(){const voices=_bulkSelected2&&_bulkSelected2.size>0?(_voices2||[]).filter(v=>_bulkSelected2.has(v.id)):visibleLibraryVoices(),errors=[];let calculated=0;const stats={startedAt:Date.now(),ok:0,slow:0,errors:0,middleLabel:"Skipped"};setBenchmarkProgress(0,voices.length,"Preparing dB scan...",stats);for(const v of voices){setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats);try{v.loudness=await clientVoiceLoudness(v),await saveMeta2(v.id,{loudness:v.loudness}).catch(()=>{}),calculated++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs2(v)} dBFS`,status(`Calculated dB: ${calculated} / ${voices.length}`)}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(calculated+errors.length,voices.length,`Calculating dB: ${v.id}`,stats),await new Promise(resolve=>setTimeout(resolve,0))}return setBenchmarkProgress(voices.length,voices.length,"dB scan complete",stats),{calculated,errors,voices:voices.map(v=>({voice_id:v.id,loudness:v.loudness}))}}async function hydrateVoiceDuration(v,el){if(!(!v.path||!needsDuration(v)||v._durationLoading)){v._durationLoading=!0;try{const audio=new Audio;audio.preload="metadata",audio.src=voiceFileUrl2(v),await new Promise((resolve,reject)=>{audio.onloadedmetadata=resolve,audio.onerror=()=>reject(new Error("Could not read duration"))}),Number.isFinite(audio.duration)&&audio.duration>0&&(v.duration=audio.duration,el&&document.body.contains(el)&&(el.textContent=fmtDuration(v.duration),el.title=String(v.duration.toFixed(2)))),audio.removeAttribute("src"),audio.load()}catch(e){el&&document.body.contains(el)&&(el.title=e.message)}finally{v._durationLoading=!1}}}document.querySelectorAll(".vl-header [data-sort]").forEach(el=>el.addEventListener("click",()=>setSort(el.dataset.sort)));function dominantLanguages(limit=3){const counts=new Map;return(_voices2||[]).forEach(v=>{const lang=(v.lang||String(v.id||"").split("_")[0]||"?").toUpperCase();counts.set(lang,(counts.get(lang)||0)+1)}),[...counts.entries()].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0])).slice(0,limit).map(([lang,count])=>`${lang} ${count}`).join(" \xB7 ")||"-"}function updateLibraryInsights(state="ready"){const el=$("library-insights");if(!el)return;if(state==="loading"){el.innerHTML=[["\u2026","Loading"],["\u2026","Active"],["\u2026","Languages"],["\u2026","Benchmarks"],["\u2026","Quality"],["\u2026","Actions"]].map(([value,label])=>`${value}${label}
`).join("");return}if(state==="error"){el.innerHTML='FailedLibrary load
';return}const total=_voices2.length,active=_voices2.filter(v=>v.enabled!==!1).length,hidden=total-active,bench=_voices2.map(voiceBenchmark).filter(Boolean),slow=bench.filter(b=>b&&b.ok&&b.realtime_ok===!1).length,dbValues=_voices2.map(voiceDbfs).filter(v=>v!=null),avgDb=dbValues.length?(dbValues.reduce((a,b)=>a+b,0)/dbValues.length).toFixed(1):"-",missingRef=_voices2.filter(v=>!v.transcript).length,restart=_voices2.filter(v=>v.needs_tts_restart).length,tiles=[{value:`${_voices2.filter(v=>$("show-disabled-cb").checked||v.enabled!==!1).length}/${total}`,label:"Visible"},{value:`${active} on`,label:hidden?`${hidden} hidden`:"Active"},{value:dominantLanguages(),label:"Languages"},{value:bench.length?`${bench.length} done`:"-",label:slow?`${slow} slow`:"Benchmarks",filter:slow?"slow":"",title:slow?describeIssueVoices("slow"):"No slow voices"},{value:avgDb==="-"?"-":`${avgDb} dB`,label:missingRef?`${missingRef} no text`:"Avg loudness",filter:missingRef?"no_text":"",title:missingRef?describeIssueVoices("no_text"):"All visible voices have reference text"},{value:restart||"-",label:restart?"Need restart":"Restart flags",filter:restart?"restart":"",title:restart?describeIssueVoices("restart"):"No voices need restart"}];el.innerHTML=tiles.map(item=>{const filter=item.filter?` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"`:"",activeCls=item.filter&&item.filter===_libraryIssueFilter?" active":"",title=item.title?` title="${escHtml(item.title)}"`:"";return`${escHtml(item.value)}${escHtml(item.label)}
`}).join(""),el.querySelectorAll("[data-filter]").forEach(tile=>{const activate=()=>setLibraryIssueFilter(tile.dataset.filter||"");tile.addEventListener("click",activate),tile.addEventListener("keydown",e=>{(e.key==="Enter"||e.key===" ")&&(e.preventDefault(),activate())})})}function shouldRenderVoiceLibrary(){const section=$("s-voices");return!section||section.classList.contains("is-active")}async function loadVoiceLibrary2(options={}){const forceRefresh=!!(options&&options.refresh);return _libraryLoadPromise2?_libraryLoadPromise2.then(()=>{shouldRenderVoiceLibrary()&&_voices2.length&&renderVoiceList2()}):(_libraryLoadPromise2=(async()=>{setBusyButton("refresh-voices-btn",!0);const list=$("voice-list"),renderVisibleList=shouldRenderVoiceLibrary(),cached=_voices2.length===0?_vlCacheRead():null;cached&&Array.isArray(cached)&&cached.length&&(_voices2=cached,window._voices=_voices2,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices2),renderVisibleList&&renderVoiceList2(),updatePreviewVoiceMatchPanel(),status(`Loaded ${_voices2.length} voices`));const silent=_voices2.length>0;list&&!silent&&renderVisibleList&&(list.innerHTML=loadingMarkup("Loading voice library","Scanning voices, reference text, metadata, ratings, and benchmark results.",8)),!silent&&renderVisibleList&&($("voice-count").textContent="Loading voices\u2026",updateLibraryInsights("loading"),status("Loading voice library\u2026"));try{const fresh=typeof window.fetchVoiceLibrary=="function"?await window.fetchVoiceLibrary({refresh:forceRefresh}):await fetch("/api/voices"+(forceRefresh?"?refresh=1":"")).then(async r=>{if(!r.ok)throw new Error(r.statusText);return r.json()});_vlCacheWrite(fresh),_voices2=fresh,window._voices=_voices2,typeof window.updateVoiceTree=="function"&&window.updateVoiceTree(_voices2),shouldRenderVoiceLibrary()&&renderVoiceList2(),updatePreviewVoiceMatchPanel(),typeof renderPerfHistory=="function"&&renderPerfHistory(),status(`Loaded ${_voices2.length} voices`)}catch(e){if(!silent&&renderVisibleList&&(list&&(list.innerHTML='Failed to load voices: '+(e.message||String(e))+"
"),$("voice-count").textContent="Load failed",updateLibraryInsights("error")),status("Voice library load failed: "+e.message),!silent)throw e}finally{setBusyButton("refresh-voices-btn",!1),_libraryLoadPromise2=null}})(),_libraryLoadPromise2)}$("refresh-voices-btn").addEventListener("click",()=>{_vlCacheClear(),loadVoiceLibrary2({refresh:!0})});async function exportVoiceBundles(ids){let ok=0;for(const id of ids)try{const r=await fetch("/api/voices/"+encodeURIComponent(id)+"/export");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const url=URL.createObjectURL(await r.blob()),a=document.createElement("a");a.href=url,a.download=id+".voice.zip",document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(url),1e4),ok++}catch(e){toast(`Export of ${id} failed: ${e.message}`,"error")}return ok&&toast(`Exported ${ok} voice bundle${ok===1?"":"s"}`,"success"),ok}async function importVoiceBundles(files){const msgs=[];let failed=0;for(const f of files)try{const fd=new FormData;fd.append("file",f);const r=await fetch("/api/voices/import",{method:"POST",body:fd}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);msgs.push(d.status==="unchanged"?`${d.voice_id} already present`:d.status==="renamed"?`${d.voice_id} (name was taken)`:d.voice_id)}catch(e){failed++,toast(`Import of ${f.name} failed: ${e.message}`,"error")}return msgs.length&&(typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),await loadVoiceLibrary2({refresh:!0}),toast("Imported: "+msgs.join(", "),failed?"error":"success")),msgs.length}window.exportVoiceBundles=exportVoiceBundles,window.importVoiceBundles=importVoiceBundles,$("export-voices-btn").addEventListener("click",async()=>{if(!_bulkSelected2.size){toast("Check one or more voices first","error");return}$("export-voices-btn").disabled=!0;try{await exportVoiceBundles([..._bulkSelected2])}finally{$("export-voices-btn").disabled=!1}}),$("import-voices-btn").addEventListener("click",()=>$("import-voices-input").click()),$("import-voices-input").addEventListener("change",async ev=>{const files=[...ev.target.files];if(ev.target.value="",!!files.length){$("import-voices-btn").disabled=!0;try{await importVoiceBundles(files)}finally{$("import-voices-btn").disabled=!1}}}),$("sync-voice-folders-btn").addEventListener("click",async()=>{$("sync-voice-folders-btn").disabled=!0,status("Syncing active_voices and hidden_voices\u2026");try{const r=await fetch("/api/voices/sync-folders",{method:"POST"});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),await loadVoiceLibrary2({refresh:!0});const conflicts=d.conflicts&&d.conflicts.length?`, ${d.conflicts.length} conflicts`:"";toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`,d.conflicts&&d.conflicts.length?"error":"success"),status("Synced folders. Restart Qwen3-TTS after changing active voices.")}catch(e){toast("Sync failed: "+e.message,"error"),status("Folder sync failed")}finally{$("sync-voice-folders-btn").disabled=!1}});function visibleLibraryVoices(){const showDisabled=$("show-disabled-cb").checked;return _voices2.filter(v=>showDisabled||v.enabled!==!1)}function libraryIssueMatch(v,filter=_libraryIssueFilter){const b=voiceBenchmark(v);return filter==="slow"?!!(b&&b.ok&&b.realtime_ok===!1):filter==="no_text"?!String(v.transcript||"").trim():filter==="restart"?!!v.needs_tts_restart:!0}function libraryIssueLabel(filter=_libraryIssueFilter){return{slow:"slow benchmark voices",no_text:"voices without reference text",restart:"voices needing TTS restart"}[filter]||"all voices"}function libraryIssueVoices(filter=_libraryIssueFilter){return visibleLibraryVoices().filter(v=>libraryIssueMatch(v,filter))}function describeIssueVoices(filter=_libraryIssueFilter,limit=12){const voices=libraryIssueVoices(filter).map(v=>v.id);if(!voices.length)return"No matching voices";const extra=voices.length>limit?`, +${voices.length-limit} more`:"";return voices.slice(0,limit).join(", ")+extra}function setLibraryIssueFilter(filter=""){_libraryIssueFilter=_libraryIssueFilter===filter?"":filter,renderVoiceList2(),status(_libraryIssueFilter?`${libraryIssueLabel()}: ${describeIssueVoices()}`:"Showing all visible voices")}function libraryTargetDb(){var _a2;const input=$("library-target-db"),raw=Number((_a2=input==null?void 0:input.value)!=null?_a2:-20),value=Number.isFinite(raw)?Math.min(-1,Math.max(-60,raw)):-20;return input&&(input.value=String(value)),value}$("calculate-db-btn").addEventListener("click",async()=>{$("calculate-db-btn").disabled=!0;const _calcTarget=_bulkSelected2&&_bulkSelected2.size>0?`${_bulkSelected2.size} selected`:"visible";status(`Calculating voice loudness (${_calcTarget})\u2026`);try{const d=await clientCalculateVoiceDb();renderVoiceList2();const extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Calculated dB for ${d.calculated} voices${extra}`,d.errors&&d.errors.length?"error":"success"),status("Calculated voice loudness. Use Normalize volume for visible WAV voices.")}catch(e){toast("Calculate dB failed: "+e.message,"error"),status("dB calculation failed")}finally{$("calculate-db-btn").disabled=!1}}),$("normalize-volume-btn").addEventListener("click",async()=>{var _a2;const target=libraryTargetDb(),visible=visibleLibraryVoices(),voices=visible.filter(v=>voiceFileType(v)==="wav"),skipped=visible.length-voices.length;if(!voices.length){toast("No visible WAV voices to normalize","error");return}if(!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped?` ${skipped} non-WAV voices will be skipped.`:""}`))return;$("normalize-volume-btn").disabled=!0,$("calculate-db-btn").disabled=!0;const stats={startedAt:Date.now(),ok:0,slow:skipped,errors:0,middleLabel:"Skipped"},errors=[];let normalized=0;setBenchmarkProgress(0,voices.length,`Normalizing to ${target} dBFS...`,stats),status(`Normalizing ${voices.length} voices to ${target} dBFS...`);try{for(const v of voices){setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats);try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a2=d.duration)!=null?_a2:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),normalized++,stats.ok++,stats.last=`${v.id}: ${fmtDbfs2(v)} dBFS`}catch(e){errors.push({voice_id:v.id,detail:e.message}),stats.errors++,stats.last=`${v.id}: ${e.message}`}setBenchmarkProgress(normalized+errors.length,voices.length,`Normalizing: ${v.id}`,stats),status(`Normalized ${normalized} / ${voices.length}`),await new Promise(resolve=>setTimeout(resolve,0))}setBenchmarkProgress(voices.length,voices.length,"Volume normalization complete",stats),renderVoiceList2(),updateLibraryInsights();const extra=`${skipped?`, ${skipped} skipped`:""}${errors.length?`, ${errors.length} errors`:""}`;toast(`Normalized ${normalized} voices${extra}`,errors.length?"error":"success"),status("Volume normalized. Restart TTS before rebenchmarking these voices.")}catch(e){toast("Normalize volume failed: "+e.message,"error"),status("Normalize volume failed")}finally{$("normalize-volume-btn").disabled=!1,$("calculate-db-btn").disabled=!1}});function fmtClock(ms){if(!Number.isFinite(ms)||ms<0)return"-";const total=Math.round(ms/1e3),m=Math.floor(total/60),s=total%60;return`${m}:${String(s).padStart(2,"0")}`}function setBenchmarkProgress(done,total,label="",stats={}){const panel=$("benchmark-progress"),track=panel.querySelector(".benchmark-progress-track"),pct=total?Math.round(done/total*100):0;panel.hidden=!1,$("benchmark-progress-label").textContent=label||(done>=total?"Benchmark complete":"Benchmarking voices..."),$("benchmark-progress-count").textContent=`${done} / ${total}`,$("benchmark-progress-bar").style.width=pct+"%",track.setAttribute("aria-valuenow",String(pct));const live=$("benchmark-live-stats");if(live){const elapsed=stats.startedAt?Date.now()-stats.startedAt:0,avg=done>0?elapsed/done:0,eta=done>0&&total>done?avg*(total-done):0;live.innerHTML=[`Elapsed ${fmtClock(elapsed)}`,`Avg ${done?(avg/1e3).toFixed(1)+"s":"-"}`,`ETA ${done&&total>done?fmtClock(eta):"-"}`,`OK ${stats.ok||0}`,`${stats.middleLabel||"Slow"} ${stats.slow||0}`,`${stats.errorLabel||"Errors"} ${stats.errors||0}`].map(x=>`${escHtml(x)}`).join("")}const last=$("benchmark-live-last");last&&stats.last&&(last.textContent=stats.last)}async function clearTtsRestartFlags2(){const r=await fetch("/api/tts/restart-flags/clear",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();return _voices2.forEach(voice=>{voice.needs_tts_restart=!1}),document.querySelectorAll(".vl-row.edit-open").forEach(row=>row.classList.remove("opt-restart-needed")),updateLibraryInsights(),d}async function runVoiceBenchmark(voiceId="",opts={}){var _a2;const text=(_a2=opts.text)!=null?_a2:benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;const payload={active_only:!0,text};voiceId&&(payload.voice_id=voiceId);const r=await fetch("/api/voices/benchmark",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return r.json()}async function runVoiceBenchmarkBatch(){const voices=benchmarkTargetVoices(),text=benchmarkSampleText();if(!text)return toast("Enter a benchmark sample sentence","error"),null;if(!voices.length)return toast("No voices to benchmark","error"),null;const total=voices.length,aggregate={benchmarked:0,errors:[],voices:[],text,active_only:!0},stats={startedAt:Date.now(),ok:0,slow:0,errors:0,last:""};voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&(row.classList.remove("benchmarking-active","benchmarking-done"),row.classList.add("benchmarking-pending"))}),setBenchmarkProgress(0,total,"Starting benchmark...",stats);let lastFailure="",sameFailure=0,aborted=!1;for(let i=0;ix.voice_id===voice.id),b=hit&&hit.benchmark;if(b&&b.ok){if(stats.ok++,b.realtime_ok===!1&&stats.slow++,stats.last=`${voice.id}: ${Number(b.elapsed_sec||0).toFixed(1)}s${b.speed!=null?` \xB7 ${Number(b.speed).toFixed(2)}x`:""}${b.realtime_ok===!1?" \xB7 slow":""}`,row){const bc=benchmarkClass2(voice),btitle=benchmarkTitle2(voice),durCell=row.querySelector(".vl-tbl-dur"),factorCell=row.querySelector(".vl-tbl-factor"),timeCell=row.querySelector(".vl-tbl-time"),wpmCell=row.querySelector(".vl-tbl-wpm");if(durCell&&(durCell.textContent=fmtBenchmarkAudio(voice),durCell.title=`${fmtBenchmarkAudio(voice)} \u2014 length of synthesised benchmark audio`),factorCell&&(factorCell.textContent=fmtFactor(voice),factorCell.className=`vl-tbl-factor ${bc}`,factorCell.title=btitle),timeCell&&(timeCell.textContent=fmtElapsed(voice),timeCell.className=`vl-tbl-time ${bc}`,timeCell.title=btitle),wpmCell){const wpm=voiceWpm(voice);wpmCell.textContent=fmtWpm(voice),wpmCell.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}}else{stats.errors++,stats.last=`${voice.id}: failed${b&&b.error?" \xB7 "+b.error:""}`;const reason=b&&b.error?String(b.error):"";sameFailure=reason&&reason===lastFailure?sameFailure+1:1,lastFailure=reason,/unreachable/i.test(reason)&&sameFailure>=3&&(toast(reason,"error"),aborted=!0)}}}catch(e){aggregate.errors.push({voice_id:voice.id,detail:e.message}),stats.errors++,stats.last=`${voice.id}: failed \xB7 ${e.message}`}row&&(row.classList.remove("benchmarking-active"),row.classList.add("benchmarking-done")),setBenchmarkProgress(i+1,total,`Finished ${voice.id}`,stats)}return voices.forEach(v=>{const row=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);row&&row.classList.remove("benchmarking-pending")}),setBenchmarkProgress(total,total,"Benchmark complete",stats),aggregate}function mergeBenchmarkResults(d){const byId=new Map((d.voices||[]).map(x=>[x.voice_id,x]));_voices2.forEach(v=>{const hit=byId.get(v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark)})}window.loadVoiceLibrary=loadVoiceLibrary2,window.mergeBenchmarkResults=mergeBenchmarkResults;function activeBenchmarkVoices(){return(_voices2||[]).filter(v=>v.enabled!==!1)}function benchmarkTargetVoices(){return typeof _bulkSelected2!="undefined"&&_bulkSelected2.size>0?(_voices2||[]).filter(v=>_bulkSelected2.has(v.id)):activeBenchmarkVoices()}function showBenchmarkConfirm(){const voices=benchmarkTargetVoices(),onlySelected=typeof _bulkSelected2!="undefined"&&_bulkSelected2.size>0;if(!benchmarkSampleText()){toast("Enter a benchmark sample sentence","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} ${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=!1,$("benchmark-confirm-start").focus()}function hideBenchmarkConfirm(){const panel=$("benchmark-confirm");panel&&(panel.hidden=!0)}$("benchmark-voices-btn").addEventListener("click",showBenchmarkConfirm),(_a=$("benchmark-confirm-cancel"))==null||_a.addEventListener("click",hideBenchmarkConfirm),(_b=$("benchmark-confirm-start"))==null||_b.addEventListener("click",async()=>{hideBenchmarkConfirm(),$("benchmark-voices-btn").disabled=!0,$("benchmark-confirm-start").disabled=!0,status("Benchmarking active voices...");try{const d=await runVoiceBenchmarkBatch();if(!d)return;mergeBenchmarkResults(d),await loadVoiceLibrary2();const slow=(d.voices||[]).filter(x=>x.benchmark&&x.benchmark.realtime_ok===!1).length,extra=d.errors&&d.errors.length?`, ${d.errors.length} errors`:"";toast(`Benchmarked ${d.benchmarked} voices${slow?`, ${slow} slow`:""}${extra}`,d.errors&&d.errors.length?"error":"success"),status("Benchmark saved with TTFA, total time, RTF, and speed.")}catch(e){toast("Benchmark failed: "+e.message,"error"),status("Benchmark failed")}finally{$("benchmark-voices-btn").disabled=!1,$("benchmark-confirm-start").disabled=!1}}),$("copy-active-voices-btn").addEventListener("click",async()=>{const ids=_bulkSelected2&&_bulkSelected2.size>0?[..._bulkSelected2]:activeVoiceIds();if(!ids.length){toast("No voices to copy","error");return}const text=ids.join(", ");if(await copyText(text)){const msg=`${ids.length} voice name${ids.length===1?"":"s"} copied to clipboard`;toast(msg,"success"),status(msg)}else copyTextDialog(text,`${ids.length} voice name${ids.length===1?"":"s"}`)});async function _verifyVoicesRoundtrip(ids,opts){opts=opts||{};const results=[],queue=ids.slice(),worker=async()=>{for(;queue.length;){const id=queue.shift(),v=(window._voices||[]).find(x=>x.id===id),text=v&&v.transcript&&v.transcript.trim()||getLibAddSampleText(v&&v.lang||"EN"),backend=typeof _ttsBackendForVoice=="function"?_ttsBackendForVoice(id,libraryTtsBackend()):libraryTtsBackend();try{const rt=await _voiceRoundtripCheck(id,text,backend);results.push({id,text,transcript:rt.transcript,score:rt.score,ok:rt.score>=.5})}catch(e){results.push({id,text,error:e.message||String(e),ok:!1})}opts.onProgress&&opts.onProgress(results.length,ids.length,id)}};return await Promise.all(Array.from({length:Math.min(2,ids.length)},worker)),results}(_c=$("verify-voices-stt-btn"))==null||_c.addEventListener("click",async()=>{const ids=_bulkSelected2&&_bulkSelected2.size>0?[..._bulkSelected2]:activeVoiceIds();if(!ids.length){toast("No voices to verify","error");return}if(!confirm(`Verify ${ids.length} voice(s) by transcribing a synthesized line back with Whisper and comparing it to the original text? This makes one extra synth + STT call per voice.`))return;const btn=$("verify-voices-stt-btn");btn&&(btn.disabled=!0);const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="verify-voices-overlay",ov.innerHTML=` Verifying voices
0 / ${ids.length}
`,document.body.appendChild(ov);let cancelled=!1;ov.querySelector("#vv-cancel").addEventListener("click",()=>{cancelled=!0});const fill=ov.querySelector("#vv-fill"),msg=ov.querySelector("#vv-msg"),results=await _verifyVoicesRoundtrip(ids,{onProgress:(done,total,id)=>{if(fill&&(fill.style.width=done/total*100+"%"),msg&&(msg.textContent=`${done} / ${total} \xB7 ${id}`),cancelled)throw new Error("cancelled")}}).catch(()=>[]);ov.remove(),btn&&(btn.disabled=!1);const failed=results.filter(r=>!r.ok),resOv=document.createElement("div");resOv.className="audiobook-overlay",resOv.innerHTML=`
@@ -756,10 +756,15 @@ This warms each voice so the engine caches its .pt and first playback is instant
Click the pencil to load waveform and tools.
- `;const vrLengthEl=wrap.querySelector(".vr-length");hydrateVoiceDuration(v,vrLengthEl);const photoCell=wrap.querySelector(".vr-photo"),photoInput=wrap.querySelector(".photo-input"),updatePhotoImg=()=>{const ts=Date.now(),imgSrc=`/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`,img=document.createElement("img");img.src=imgSrc,img.alt="",photoCell.innerHTML="",photoCell.appendChild(img),photoCell.appendChild(photoInput);const compactAvatar=wrap.querySelector(".vl-avatar");compactAvatar&&(compactAvatar.className="vl-avatar vl-avatar-photo",compactAvatar.style.background="",compactAvatar.innerHTML=`
`);const inspectorAvatar=document.querySelector(".inspector-avatar");inspectorAvatar&&wrap.classList.contains("vr-selected")&&(inspectorAvatar.classList.add("insp-avatar-photo"),inspectorAvatar.style.background="",inspectorAvatar.innerHTML=`
`),v.has_picture=!0};photoCell.addEventListener("update-photo",updatePhotoImg),photoCell.addEventListener("click",()=>photoInput.click()),photoInput.addEventListener("change",async()=>{if(!photoInput.files.length)return;const fd=new FormData;fd.append("voice_id",v.id),fd.append("file",photoInput.files[0]);try{const r=await fetch("/api/voice/picture",{method:"POST",body:fd});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo uploaded","success")}catch(e){toast("Photo upload failed: "+e.message,"error")}}),photoCell.addEventListener("dragenter",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragover",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragleave",()=>photoCell.classList.remove("drag-over")),photoCell.addEventListener("drop",async e=>{if(e.preventDefault(),photoCell.classList.remove("drag-over"),e.dataTransfer.files&&e.dataTransfer.files.length>0){photoInput.files=e.dataTransfer.files,photoInput.dispatchEvent(new Event("change"));return}let url=e.dataTransfer.getData("text/uri-list");if(!url){const html=e.dataTransfer.getData("text/html");if(html){const match=html.match(/src=["'](.*?)["']/);match&&(url=match[1])}}if(url||(url=e.dataTransfer.getData("text/plain")),url&&/^https?:\/\//i.test(url)){status("Downloading picture from URL...");try{const r=await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,image_url:url})});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo saved from URL","success"),status("Photo saved successfully")}catch(err){toast("Photo URL download failed: "+err.message,"error"),status("Photo URL download failed")}}});const normalizeBtn=wrap.querySelector(".normalize-voice-btn"),dbValue=wrap.querySelector(".vr-db-value"),dbCell=wrap.querySelector(".vr-db");normalizeBtn.addEventListener("click",async()=>{var _a3;const target=libraryTargetDb();if(confirm(`Normalize "${v.id}" to ${target} dBFS?`)){normalizeBtn.disabled=!0,status("Normalizing "+v.id+"\u2026");try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs2(v),dbCell.title=v.loudness?`avg ${fmtDbfs2(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),toast("Normalized: "+v.id,"success"),status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`)}catch(e){toast("Normalize failed: "+e.message,"error"),status("Normalize failed")}finally{normalizeBtn.disabled=!1}}});const flagEmojiEl=wrap.querySelector(".flag-emoji"),flagCodeEl=wrap.querySelector(".flag-code"),flagPicker=wrap.querySelector(".flag-picker");wrap.querySelector(".vr-flag").addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".flag-picker.open").forEach(fp=>{fp!==flagPicker&&fp.classList.remove("open")}),flagPicker.classList.toggle("open")}),flagPicker.querySelectorAll(".flag-opt").forEach(opt=>{opt.addEventListener("click",async e=>{e.stopPropagation();const cc=opt.dataset.cc;flagPicker.classList.remove("open"),flagEmojiEl.textContent=cc2flag(cc),flagCodeEl.textContent=ccDisplay(cc),flagPicker.querySelectorAll(".flag-opt").forEach(o=>o.classList.toggle("active",o.dataset.cc===cc)),v.flag=cc,await saveMeta2(v.id,{flag:cc})})});const gBadge=wrap.querySelector(".gender-badge");gBadge.addEventListener("click",async()=>{const cycle=["F","M","N"];v.gender=cycle[(cycle.indexOf(v.gender||"F")+1)%3],gBadge.innerHTML=`${genderMap[v.gender]||"?"}${genderLabel[v.gender]||"\u2014"}`,gBadge.className="gender-badge "+genderClass[v.gender],await saveMeta2(v.id,{gender:v.gender})});const nameText=wrap.querySelector(".vr-name-text"),renameConf=wrap.querySelector(".rename-confirm"),nameInput=wrap.querySelector(".vr-name-input"),renameOk=wrap.querySelector(".rename-ok"),renameCancel=wrap.querySelector(".rename-cancel"),startRename=()=>{nameText.style.display="none",renameConf.classList.add("show"),nameInput.focus(),nameInput.select()};nameText.addEventListener("dblclick",startRename);const cancelRename=()=>{nameText.style.display="",renameConf.classList.remove("show"),nameInput.value=v.id};renameCancel.addEventListener("click",cancelRename);const doRename=async()=>{const newId=nameInput.value.trim();if(!newId||newId===v.id){cancelRename();return}if(!/^[A-Za-z0-9_\-\.]+$/.test(newId)){toast("Invalid characters in name","error");return}try{const r=await fetch("/api/voice/rename",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({old_id:v.id,new_id:newId})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();v.id=newId,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),nameText.textContent=newId,nameText.title=newId,nameText.style.display="",renameConf.classList.remove("show"),wrap.dataset.id=newId,nameInput.value=newId,toast("Renamed to "+newId,"success")}catch(e){toast("Rename failed: "+e.message,"error")}};renameOk.addEventListener("click",doRename),nameInput.addEventListener("keydown",e=>{e.key==="Enter"&&doRename(),e.key==="Escape"&&cancelRename()}),wrap.querySelector(".vl-compact").addEventListener("click",function(e){e.target.closest("button")||selectVoice(wrap)});const editAudioBtn=wrap.querySelector(".edit-audio-btn"),optPanel=wrap.querySelector(".vr-optimizer"),vrTypeEl=wrap.querySelector(".vr-type"),optCanvas=wrap.querySelector(".opt-wave"),optStart=wrap.querySelector(".opt-start"),optEnd=wrap.querySelector(".opt-end"),optTranscript=wrap.querySelector(".opt-transcript"),optTargetDb=wrap.querySelector(".opt-target-db"),optStyleInstruct=wrap.querySelector(".opt-style-instruct"),optStyleBackend=wrap.querySelector(".opt-style-backend"),optStyleVoiceId=wrap.querySelector(".opt-style-voice-id"),optCompareBackend=wrap.querySelector(".opt-compare-backend"),optPlayReferenceBtn=wrap.querySelector(".opt-play-reference"),optSynthReferenceBtn=wrap.querySelector(".opt-synth-reference"),optCompareRefAudio=wrap.querySelector(".opt-compare-ref-audio"),optCompareSynthAudio=wrap.querySelector(".opt-compare-synth-audio"),optPreviewStyleBtn=wrap.querySelector(".opt-preview-style"),optSaveStyleBtn=wrap.querySelector(".opt-save-style"),optStyleAudio=wrap.querySelector(".opt-style-audio"),optStatus=wrap.querySelector(".opt-status"),optSaveTextBtn=wrap.querySelector(".opt-save-text"),optRestartTtsBtn=wrap.querySelector(".opt-restart-tts"),optRebenchmarkBtn=wrap.querySelector(".opt-rebenchmark"),optRestartNote=wrap.querySelector(".opt-restart-note");let optState={loaded:!1,id:null,duration:0,buffer:null,audio:null,compareSynthUrl:null};const setOptStatus=msg=>{optStatus.textContent=msg,status(msg)},setVoiceRestartState=(required,msg="")=>{v.needs_tts_restart=required,optPanel.classList.toggle("opt-restart-needed",required),optRestartNote.hidden=!required,optRestartNote.textContent=required?"Restart TTS before benchmarking; the backend may still have the old voice cached.":"",optRebenchmarkBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",benchmarkOneBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",msg&&setOptStatus(msg)},markTtsRestartRequired=msg=>setVoiceRestartState(!0,msg),refreshOptimizerFromVoice=async()=>{var _a3;markVoiceAudioChanged(v),optState.loaded=!1,optState.buffer=null,optState.id=null,await loadOptimizer(),vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),vrLengthEl&&(vrLengthEl.title=String((_a3=v.duration)!=null?_a3:"")),dbValue.textContent=fmtDbfs2(v),dbCell.title=v.loudness?`avg ${fmtDbfs2(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:""},saveOptimizerText=async()=>{const transcript=optTranscript.value.trim();setOptStatus("Saving reference text..."),v.transcript=transcript,refInput.value=transcript,refInput.title=transcript,refTranscribeBtn.style.display=transcript?"none":"",await saveMeta2(v.id,{transcript}),markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."),toast("Reference text saved: "+v.id,"success")},redrawOpt=()=>{!optState.buffer||optCanvas.clientWidth<4||drawOptimizerWave(optCanvas,optState.buffer,parseFloat(optStart.value)||0,parseFloat(optEnd.value)||optState.duration)};new ResizeObserver(()=>redrawOpt()).observe(optCanvas);const syncCompareReferenceAudio=()=>{if(!optState.id||!optCompareRefAudio)return;const src="/api/audio/"+optState.id;optCompareRefAudio.src.endsWith(src)||(optCompareRefAudio.src=src,optCompareRefAudio.load());const stopAtEnd=()=>{const end=parseFloat(optEnd.value)||optState.duration;optCompareRefAudio.currentTime>=end&&optCompareRefAudio.pause()};optCompareRefAudio.ontimeupdate=stopAtEnd},optWaveTimeFromEvent=e=>{const rect=optCanvas.getBoundingClientRect(),x=Math.max(0,Math.min(rect.width,e.clientX-rect.left));return optState.duration?x/Math.max(1,rect.width)*optState.duration:0},setOptCropRange=(start,end)=>{start=Math.max(0,Math.min(optState.duration||0,Number(start)||0)),end=Math.max(0,Math.min(optState.duration||0,Number(end)||0)),end{const rect=optCanvas.getBoundingClientRect(),duration=Math.max(.01,optState.duration||.01),sx=(parseFloat(optStart.value)||0)/duration*rect.width,ex=(parseFloat(optEnd.value)||optState.duration||0)/duration*rect.width;return{x:e.clientX-rect.left,sx,ex}},optWaveDragMode=e=>{const{x,sx,ex}=optWaveSelectionPixels(e),hit=18,nearStart=Math.abs(x-sx)<=hit,nearEnd=Math.abs(x-ex)<=hit;return nearStart&&nearEnd?Math.abs(x-sx)<=Math.abs(x-ex)?"start":"end":nearStart?"start":nearEnd?"end":x>sx&&x{let drag=null;optCanvas.addEventListener("pointerdown",e=>{var _a3;if(!optState.buffer||!optState.duration)return;e.preventDefault(),(_a3=optCanvas.setPointerCapture)==null||_a3.call(optCanvas,e.pointerId);const mode=optWaveDragMode(e),currentStart=parseFloat(optStart.value)||0,currentEnd=parseFloat(optEnd.value)||optState.duration;drag={mode,anchor:optWaveTimeFromEvent(e),start:currentStart,end:currentEnd,length:Math.max(.05,currentEnd-currentStart)},optCanvas.style.cursor=mode==="move"?"grabbing":"ew-resize",mode==="new"&&setOptCropRange(drag.anchor,drag.anchor)}),optCanvas.addEventListener("pointermove",e=>{if(!optState.buffer||!optState.duration)return;if(!drag){const mode=optWaveDragMode(e);optCanvas.style.cursor=mode==="move"?"grab":mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=optWaveTimeFromEvent(e);if(drag.mode==="start")setOptCropRange(Math.min(t,drag.end-.05),drag.end);else if(drag.mode==="end")setOptCropRange(drag.start,Math.max(t,drag.start+.05));else if(drag.mode==="move"){let start=t-(drag.anchor-drag.start);start=Math.max(0,Math.min((optState.duration||0)-drag.length,start)),setOptCropRange(start,start+drag.length)}else setOptCropRange(drag.anchor,t)});const finish=e=>{var _a3;drag&&((_a3=optCanvas.releasePointerCapture)==null||_a3.call(optCanvas,e.pointerId),drag=null,optCanvas.style.cursor="crosshair")};optCanvas.addEventListener("pointerup",finish),optCanvas.addEventListener("pointercancel",finish),optCanvas.addEventListener("pointerleave",()=>{drag||(optCanvas.style.cursor="crosshair")})})();const loadOptimizer=async()=>{if(optState.loaded)return;setOptStatus("Loading voice optimizer\u2026");const d=await loadLibraryVoiceAudio(v);optState.id=d.id,optState.duration=d.duration,v.duration=d.duration,optState.buffer=await decodeVoiceAudio(v),optState.loaded=!0,optStart.value="0.00",optEnd.value=d.duration.toFixed(2),optEnd.max=d.duration.toFixed(2),optTranscript.value=d.transcript||v.transcript||"",redrawOpt(),syncCompareReferenceAudio(),setVoiceRestartState(!!v.needs_tts_restart),setOptStatus(v.needs_tts_restart?"Optimizer ready. Restart TTS before benchmarking this edit.":"Optimizer ready")};if(wrap._loadOptimizer=loadOptimizer,wrap._redrawOpt=redrawOpt,editAudioBtn.addEventListener("click",async()=>{editAudioBtn.disabled=!0;try{const opening=!wrap.classList.contains("edit-open");document.querySelectorAll(".vl-row.edit-open").forEach(r=>{r!==wrap&&r.classList.remove("edit-open")}),wrap.classList.toggle("edit-open",opening),opening&&(await loadOptimizer(),wrap.scrollIntoView({behavior:"smooth",block:"nearest"}))}catch(e){toast("Edit load failed: "+e.message,"error"),status("Edit load failed")}finally{editAudioBtn.disabled=!1}}),[optStart,optEnd].forEach(inp=>inp.addEventListener("input",()=>{redrawOpt(),syncCompareReferenceAudio()})),optStyleInstruct==null||optStyleInstruct.addEventListener("input",()=>{optStyleVoiceId.value.trim()||(optStyleVoiceId.value=suggestedStyleVoiceId(v.id,optStyleInstruct.value))}),optStyleBackend==null||optStyleBackend.addEventListener("change",()=>updateStyleBackendHelp(wrap)),optCompareBackend.addEventListener("change",()=>{var _a3;return setOptStatus(`Comparison backend: ${((_a3=optCompareBackend.options[optCompareBackend.selectedIndex])==null?void 0:_a3.textContent)||optCompareBackend.value}`)}),optCompareBackend.value===""&&(optCompareBackend.innerHTML=styleBackendOptions("voice_clone"),optCompareBackend.disabled=!availableTtsBackends().length),optStyleBackend&&updateStyleBackendHelp(wrap),wrap.querySelector(".opt-db-minus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)-1).toFixed(1)}),wrap.querySelector(".opt-db-plus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)+1).toFixed(1)}),wrap.querySelector(".opt-db-auto").addEventListener("click",()=>{optTargetDb.value="-20.0"}),wrap.querySelector(".opt-play").addEventListener("click",async()=>{try{await loadOptimizer(),optState.audio&&optState.audio.pause(),optState.audio=new Audio("/api/audio/"+optState.id),optState.audio.currentTime=parseFloat(optStart.value)||0;const end=parseFloat(optEnd.value)||optState.duration;optState.audio.ontimeupdate=()=>{optState.audio.currentTime>=end&&optState.audio.pause()},optState.audio.play()}catch(e){toast("Preview failed: "+e.message,"error")}}),optPlayReferenceBtn.addEventListener("click",async()=>{try{await loadOptimizer(),syncCompareReferenceAudio(),optCompareRefAudio.currentTime=parseFloat(optStart.value)||0,await optCompareRefAudio.play().catch(()=>{}),setOptStatus("Playing reference WAV selection for comparison.")}catch(e){toast("Reference playback failed: "+e.message,"error")}}),optSynthReferenceBtn.addEventListener("click",async()=>{const text=optTranscript.value.trim();if(!text){toast("Enter reference text first","error"),optTranscript.focus();return}if(v.needs_tts_restart){if(!confirm("This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?")){setOptStatus("Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.");return}try{const d=await clearTtsRestartFlags2();setVoiceRestartState(!1,`Restart flags cleared (${d.cleared_restart_flags||0}). Synthesizing comparison...`),toast("Restart flags cleared","success")}catch(e){toast("Could not clear restart flags: "+e.message,"error"),setOptStatus("Could not clear restart flags");return}}optSynthReferenceBtn.disabled=!0;try{await loadOptimizer(),setOptStatus("Synthesizing reference text for comparison...");const source=await createTtsAudioSource(v.id,text,optCompareBackend.value,"settings","");optState.compareSynthUrl&&URL.revokeObjectURL(optState.compareSynthUrl),optCompareSynthAudio.src=source.url,optState.compareSynthUrl=source.streaming?null:source.url,await optCompareSynthAudio.play().catch(()=>{}),setOptStatus(source.streaming?"Streaming synthesized comparison.":"Synthesized comparison ready.")}catch(e){toast("Synthesis comparison failed: "+e.message,"error"),setOptStatus("Synthesis comparison failed")}finally{optSynthReferenceBtn.disabled=!1}}),wrap.querySelector(".opt-auto-trim").addEventListener("click",async()=>{try{await loadOptimizer();const d=await clientAutoTrimBounds(optState.id);optStart.value=Number(d.start).toFixed(2),optEnd.value=Number(d.end).toFixed(2),redrawOpt(),setOptStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error")}}),wrap.querySelector(".opt-recognize").addEventListener("click",async()=>{try{await loadOptimizer();const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id})});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();optTranscript.value=d.text||"",setOptStatus("Reference text recognised. Review it, then Save text.")}catch(e){toast("Recognition failed: "+e.message,"error")}}),optSaveTextBtn.addEventListener("click",async()=>{optSaveTextBtn.disabled=!0;try{await saveOptimizerText()}catch(e){toast("Save text failed: "+e.message,"error"),setOptStatus("Save text failed")}finally{optSaveTextBtn.disabled=!1}}),wrap.querySelector(".opt-save-crop").addEventListener("click",async()=>{var _a3;try{await loadOptimizer();const cropStart=Math.max(0,parseFloat(optStart.value)||0),cropEnd=Math.min(optState.duration,parseFloat(optEnd.value)||optState.duration);if(cropStart<=.01&&cropEnd>=optState.duration-.05){setOptStatus("No crop range selected. Adjust Start or End first, then Save crop."),toast("No crop range selected","error");return}if(cropEnd<=cropStart+.1){setOptStatus("Crop range is too short."),toast("Crop range is too short","error");return}setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`);const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id,start:cropStart,end:cropEnd})});if(!pr.ok){const e=await pr.json();throw new Error(e.detail||pr.statusText)}const p=await pr.json(),rr=await fetch("/api/voice-replace",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:v.id,path:v.path,transcript:optTranscript.value})});if(!rr.ok){const e=await rr.json().catch(()=>({}));throw new Error(e.detail||rr.statusText)}const saved=await rr.json();v.transcript=optTranscript.value,v.duration=(_a3=saved.duration)!=null?_a3:p.duration,saved.loudness&&(v.loudness=saved.loudness),saved.path&&(v.path=saved.path),saved.file_type&&(v.file_type=saved.file_type),markVoiceAudioChanged(v),refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Voice crop saved: "+v.id,"success"),markTtsRestartRequired(saved.backup?"Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.":"Crop saved and loaded. Restart TTS before rebenchmarking.")}catch(e){toast("Save crop failed: "+e.message,"error"),setOptStatus("Save crop failed")}}),optStyleInstruct){const styleVariationInput=()=>{const style=optStyleInstruct.value.trim(),text=optTranscript.value.trim()||benchmarkSampleText(),newId=optStyleVoiceId.value.trim()||suggestedStyleVoiceId(v.id,style);return style?text?/^[A-Za-z0-9_\-.]+$/.test(newId)?{style,text,newId,backend:optStyleBackend.value}:(toast("Invalid characters in new voice ID","error"),optStyleVoiceId.focus(),null):(toast("Enter reference text first","error"),optTranscript.focus(),null):(toast("Enter a style instruction first","error"),optStyleInstruct.focus(),null)};optPreviewStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optPreviewStyleBtn.disabled=!0;try{setOptStatus("Synthesizing style preview...");const blob=await fetchTtsPreviewBlob(v.id,input.text,"wav",input.style,input.backend);optStyleAudio.src&&URL.revokeObjectURL(optStyleAudio.src),optStyleAudio.src=URL.createObjectURL(blob),optStyleAudio.style.display="",await optStyleAudio.play().catch(()=>{}),setOptStatus("Style preview ready. If it sounds right, save it as a new voice.")}catch(e){toast("Style preview failed: "+e.message,"error"),setOptStatus("Style preview failed")}finally{optPreviewStyleBtn.disabled=!1}}}),optSaveStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optSaveStyleBtn.disabled=!0;try{setOptStatus(`Synthesizing style variation ${input.newId}...`);const r=await fetch("/api/tts-style-variation",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({source_voice:v.id,voice_id:input.newId,text:input.text,instruct:input.style,backend:input.backend})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();toast("Style variation saved: "+d.voice_id,"success"),setOptStatus("Style variation saved. Restart TTS so the backend scans the new voice."),await loadVoiceLibrary2(),renderIntegrationSnippets()}catch(e){toast("Style variation failed: "+e.message,"error"),setOptStatus("Style variation failed")}finally{optSaveStyleBtn.disabled=!1}}})}wrap.querySelector(".opt-undo").addEventListener("click",async()=>{var _a3;if(confirm(`Restore the original backup for "${v.id}"?`))try{setOptStatus("Restoring original\u2026");const r=await fetch("/api/voice/undo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.loudness=d.loudness||v.loudness,v.path=d.path||v.path,v.file_type=d.file_type||v.file_type,markVoiceAudioChanged(v),vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Original restored: "+v.id,"success"),markTtsRestartRequired("Original restored. Restart TTS before rebenchmarking.")}catch(e){toast("Undo failed: "+e.message,"error"),setOptStatus("Undo failed")}}),wrap.querySelector(".opt-save-volume").addEventListener("click",async()=>{var _a3;try{setOptStatus("Saving volume\u2026");const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:Number(optTargetDb.value||-20)})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs2(v),dbCell.title=v.loudness?`avg ${fmtDbfs2(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",await refreshOptimizerFromVoice(),toast("Volume saved: "+v.id,"success"),markTtsRestartRequired("Volume saved. Restart TTS before rebenchmarking this voice.")}catch(e){toast("Volume save failed: "+e.message,"error"),setOptStatus("Volume save failed")}}),optRestartTtsBtn.addEventListener("click",async()=>{optRestartTtsBtn.disabled=!0;try{setOptStatus("Restarting WAV backends (Voice Clone + Streaming)\u2026");const r=await fetch("/api/tts/restart",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_voices2.forEach(voice=>{voice.needs_tts_restart=!1}),updateLibraryInsights();const names=(d.restarted||[]).join(", ")||"containers",errTxt=(d.errors||[]).length?` (errors: ${d.errors.join("; ")})`:"";setVoiceRestartState(!1,`Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`),toast(`TTS restarted: ${names}`,"success")}catch(e){toast("Restart TTS failed: "+e.message,"error"),setOptStatus("Restart TTS failed: "+e.message)}finally{optRestartTtsBtn.disabled=!1}});const refInput=wrap.querySelector(".vr-ref input"),refTranscribeBtn=wrap.querySelector(".ref-transcribe-btn");refInput.addEventListener("input",debounce(async()=>{v.transcript=refInput.value,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta2(v.id,{transcript:v.transcript}),wrap.classList.contains("edit-open")?markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."):v.needs_tts_restart=!0},800)),refTranscribeBtn.addEventListener("click",async()=>{refTranscribeBtn.disabled=!0;try{const d=await loadLibraryVoiceAudio(v),tr=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:d.id})});if(!tr.ok){const e=await tr.json();throw new Error(e.detail)}const text=await tr.json();v.transcript=text.text||"",refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta2(v.id,{transcript:v.transcript}),v.needs_tts_restart=!0,toast("Reference text recognised; restart TTS before benchmarking","success")}catch(e){toast("Recognition failed: "+e.message,"error")}finally{refTranscribeBtn.disabled=!1}});const noteInput=wrap.querySelector(".vr-note input");noteInput.addEventListener("input",debounce(async()=>{v.note=noteInput.value,await saveMeta2(v.id,{note:v.note})},800));const sourceInput=wrap.querySelector(".vr-source input");sourceInput.addEventListener("input",debounce(async()=>{v.origin=sourceInput.value.trim(),await saveMeta2(v.id,{origin:v.origin});const tblCell=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"] .vl-tbl-source`);tblCell&&(tblCell.textContent=_displaySource(v)||"-")},800));const starSpans=wrap.querySelectorAll(".star");starSpans.forEach(s=>{s.addEventListener("click",async()=>{const val=parseInt(s.dataset.val),newRating=val===v.rating?0:val;v.rating=newRating,starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{const val=parseInt(s.dataset.val);starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{starSpans.forEach((ss,i)=>ss.classList.toggle("on",i<(v.rating||0)))})});const benchmarkOneBtn=wrap.querySelector(".benchmark-one-btn"),benchmarkThisVoice=async triggerBtn=>{if(v.needs_tts_restart&&!confirm("This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?")){setOptStatus("Restart TTS first, then rebenchmark this voice.");return}triggerBtn.disabled=!0,status("Benchmarking "+v.id+"...");try{setBenchmarkProgress(0,1,`Benchmarking ${v.id}`);const d=await runVoiceBenchmark(v.id);mergeBenchmarkResults(d);const hit=(d.voices||[]).find(x=>x.voice_id===v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark);const benchCell=wrap.querySelector(".vr-bench")||document.querySelector("#voices-inspector .vr-bench");if(benchCell){benchCell.className="vr-bench "+benchmarkClass2(v),benchCell.title=benchmarkTitle2(v);const benchValue=benchCell.querySelector(".vr-bench-value");benchValue&&(benchValue.textContent=fmtBenchmark2(v))}const tblRow=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);if(tblRow){const bc=benchmarkClass2(v),btitle=benchmarkTitle2(v),tblDur=tblRow.querySelector(".vl-tbl-dur"),tblFactor=tblRow.querySelector(".vl-tbl-factor"),tblTime=tblRow.querySelector(".vl-tbl-time"),tblWpm=tblRow.querySelector(".vl-tbl-wpm");if(tblDur&&(tblDur.textContent=fmtBenchmarkAudio(v),tblDur.title=`${fmtBenchmarkAudio(v)} \u2014 length of synthesised benchmark audio`),tblFactor&&(tblFactor.textContent=fmtFactor(v),tblFactor.className=`vl-tbl-factor ${bc}`,tblFactor.title=btitle),tblTime&&(tblTime.textContent=fmtElapsed(v),tblTime.className=`vl-tbl-time ${bc}`,tblTime.title=btitle),tblWpm){const wpm=voiceWpm(v);tblWpm.textContent=fmtWpm(v),tblWpm.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}setBenchmarkProgress(1,1,`Finished ${v.id}`),toast("Benchmarked "+v.id,"success"),setVoiceRestartState(!1,"Benchmark saved for "+v.id)}catch(e){toast("Benchmark failed: "+e.message,"error"),setOptStatus("Benchmark failed")}finally{triggerBtn.disabled=!1}};benchmarkOneBtn.addEventListener("click",()=>benchmarkThisVoice(benchmarkOneBtn)),optRebenchmarkBtn.addEventListener("click",()=>benchmarkThisVoice(optRebenchmarkBtn));const originalPlayBtn=wrap.querySelector(".vr-play-original button"),synthPlayBtn=wrap.querySelector(".vr-play-synth button"),playIcon='',pauseIcon='',generatingIcon='';function setLibraryPlayButtonState(btn,state){btn.classList.toggle("is-generating",state==="generating"),btn.innerHTML=state==="playing"?pauseIcon:state==="generating"?generatingIcon:playIcon,btn.title=state==="generating"?"Generating synthesized sample...":state==="playing"?"Pause playback":btn.dataset.playKind==="synth"?"Generate and play synthesized sample":"Play original recording"}async function playLibraryVoice(kind,playBtn){var _a3,_b2,_c2;const bar=$("lib-audio-bar"),audio=$("lib-audio"),playKey=v.id+":"+kind;if(playBtn.dataset.playKind=kind,_activePlayVoiceId===playKey&&!audio.paused){audio.pause(),setLibraryPlayButtonState(playBtn,"idle");return}if(_activePlayVoiceId===playKey&&audio.paused&&audio.src){_activePlayButton=playBtn;try{await audio.play()}catch(e){toast("Play failed: "+e.message,"error")}return}_activePlayButton&&_activePlayButton!==playBtn&&setLibraryPlayButtonState(_activePlayButton,"idle"),_activePlayButton=playBtn,_activePlayVoiceId=playKey,_activePlayUrl&&(URL.revokeObjectURL(_activePlayUrl),_activePlayUrl=null),kind==="synth"&&setLibraryPlayButtonState(playBtn,"generating"),playBtn.disabled=!0;try{if(kind==="synth"){v.needs_tts_restart&&toast("This voice changed since backend refresh; synthesized playback may use a cached voice.","error");const synthMode=((_a3=document.querySelector("#vl-synth-mode-seg .vl-synth-seg-btn.active"))==null?void 0:_a3.dataset.mode)||"preview",text=synthMode==="transcript"&&((_b2=v.transcript)==null?void 0:_b2.trim())||benchmarkSampleText(),textLabel=synthMode==="transcript"?"reference transcript":"preview text",backend=isClone?libraryTtsBackend():"voice_design",source=await createTtsAudioSource(v.id,text,backend,"settings","");audio.src=source.url,source.streaming||(_activePlayUrl=source.url),$("lib-audio-label").textContent=v.id+" \xB7 synthesized "+textLabel+" \xB7 "+(((_c2=backendById(backend))==null?void 0:_c2.label)||backend)}else audio.src=voiceFileUrl2(v),$("lib-audio-label").textContent=v.id+" \xB7 original recording";bar.style.display="",audio.onended=()=>{setLibraryPlayButtonState(playBtn,"idle"),_activePlayVoiceId=null,_activePlayUrl&&(URL.revokeObjectURL(_activePlayUrl),_activePlayUrl=null)},audio.onpause=()=>{_activePlayButton===playBtn&&setLibraryPlayButtonState(playBtn,"idle")},audio.onplay=()=>{setLibraryPlayButtonState(playBtn,"playing")},await audio.play()}catch(e){setLibraryPlayButtonState(playBtn,"idle"),toast("Play failed: "+e.message,"error")}finally{playBtn.disabled=!1}}originalPlayBtn.dataset.playKind="original",synthPlayBtn.dataset.playKind="synth",setLibraryPlayButtonState(originalPlayBtn,"idle"),setLibraryPlayButtonState(synthPlayBtn,"idle"),originalPlayBtn.addEventListener("click",()=>playLibraryVoice("original",originalPlayBtn)),synthPlayBtn.addEventListener("click",()=>playLibraryVoice("synth",synthPlayBtn));const toggleCb=wrap.querySelector(".toggle input");toggleCb.addEventListener("change",async()=>{const nextEnabled=toggleCb.checked,previousEnabled=v.enabled!==!1;toggleCb.disabled=!0;try{const saved=await saveMeta2(v.id,{enabled:nextEnabled});v.enabled=nextEnabled,saved&&saved.path&&(v.path=saved.path),wrap.classList.toggle("vr-disabled",!v.enabled),toast(nextEnabled?"Moved to active_voices":"Moved to hidden_voices","success"),!v.enabled&&!$("show-disabled-cb").checked&&(wrap.style.transition="opacity .4s",wrap.style.opacity="0",setTimeout(()=>wrap.remove(),400))}catch(e){toggleCb.checked=previousEnabled,v.enabled=previousEnabled,wrap.classList.toggle("vr-disabled",!v.enabled),toast("Move failed: "+e.message,"error")}finally{toggleCb.disabled=!1}});const deleteBtn=wrap.querySelector(".delete-btn"),deleteConfirm=wrap.querySelector(".delete-confirm"),deleteCancelBtn=wrap.querySelector(".delete-confirm-cancel"),deleteGoBtn=wrap.querySelector(".delete-confirm-go"),closeDeleteConfirm=()=>wrap.classList.remove("delete-pending");return deleteBtn.addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>{row!==wrap&&row.classList.remove("delete-pending")}),wrap.classList.add("delete-pending"),deleteGoBtn.focus()}),deleteCancelBtn.addEventListener("click",e=>{e.stopPropagation(),closeDeleteConfirm()}),deleteConfirm.addEventListener("click",e=>e.stopPropagation()),deleteGoBtn.addEventListener("click",async e=>{e.stopPropagation(),deleteGoBtn.disabled=!0,deleteCancelBtn.disabled=!0;try{const r=await fetch(`/api/voice/${encodeURIComponent(v.id)}`,{method:"DELETE"});if(!r.ok){const e2=await r.json();throw new Error(e2.detail)}_voices2=_voices2.filter(x=>x.id!==v.id),wrap.style.transition="opacity .3s",wrap.style.opacity="0",setTimeout(()=>{wrap.remove(),$("voice-count").textContent=_voices2.filter(x=>$("show-disabled-cb").checked||x.enabled!==!1).length+" / "+_voices2.length+" voices"},300),toast(`Moved to Voice Trash: ${v.id}`,"success")}catch(e2){toast("Delete failed: "+e2.message,"error"),deleteGoBtn.disabled=!1,deleteCancelBtn.disabled=!1,closeDeleteConfirm()}}),wrap}function renderVoiceGroupsBar(){var _a2;const bar=$("voice-groups-bar");if(!bar)return;const groups={};(_voices2||[]).forEach(v=>{const g=(v.group||"").trim();g&&(groups[g]=(groups[g]||0)+1)});const names=Object.keys(groups).sort((a,b)=>a.localeCompare(b));if(!names.length){bar.hidden=!0,bar.innerHTML="";return}bar.hidden=!1;const active=window._voiceGroupFilter||"";bar.innerHTML=' Groups'+names.map(g=>`
+ `;const vrLengthEl=wrap.querySelector(".vr-length");hydrateVoiceDuration(v,vrLengthEl);const photoCell=wrap.querySelector(".vr-photo"),photoInput=wrap.querySelector(".photo-input"),updatePhotoImg=()=>{const ts=Date.now(),imgSrc=`/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`,img=document.createElement("img");img.src=imgSrc,img.alt="",photoCell.innerHTML="",photoCell.appendChild(img),photoCell.appendChild(photoInput);const compactAvatar=wrap.querySelector(".vl-avatar");compactAvatar&&(compactAvatar.className="vl-avatar vl-avatar-photo",compactAvatar.style.background="",compactAvatar.innerHTML=`
`);const inspectorAvatar=document.querySelector(".inspector-avatar");inspectorAvatar&&wrap.classList.contains("vr-selected")&&(inspectorAvatar.classList.add("insp-avatar-photo"),inspectorAvatar.style.background="",inspectorAvatar.innerHTML=`
`),v.has_picture=!0};photoCell.addEventListener("update-photo",updatePhotoImg),photoCell.addEventListener("click",()=>photoInput.click()),photoInput.addEventListener("change",async()=>{if(!photoInput.files.length)return;const fd=new FormData;fd.append("voice_id",v.id),fd.append("file",photoInput.files[0]);try{const r=await fetch("/api/voice/picture",{method:"POST",body:fd});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo uploaded","success")}catch(e){toast("Photo upload failed: "+e.message,"error")}}),photoCell.addEventListener("dragenter",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragover",e=>{e.preventDefault(),photoCell.classList.add("drag-over")}),photoCell.addEventListener("dragleave",()=>photoCell.classList.remove("drag-over")),photoCell.addEventListener("drop",async e=>{if(e.preventDefault(),photoCell.classList.remove("drag-over"),e.dataTransfer.files&&e.dataTransfer.files.length>0){photoInput.files=e.dataTransfer.files,photoInput.dispatchEvent(new Event("change"));return}let url=e.dataTransfer.getData("text/uri-list");if(!url){const html=e.dataTransfer.getData("text/html");if(html){const match=html.match(/src=["'](.*?)["']/);match&&(url=match[1])}}if(url||(url=e.dataTransfer.getData("text/plain")),url&&/^https?:\/\//i.test(url)){status("Downloading picture from URL...");try{const r=await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,image_url:url})});if(!r.ok)throw new Error((await r.json()).detail);updatePhotoImg(),toast("Photo saved from URL","success"),status("Photo saved successfully")}catch(err){toast("Photo URL download failed: "+err.message,"error"),status("Photo URL download failed")}}});const normalizeBtn=wrap.querySelector(".normalize-voice-btn"),dbValue=wrap.querySelector(".vr-db-value"),dbCell=wrap.querySelector(".vr-db");normalizeBtn.addEventListener("click",async()=>{var _a3;const target=libraryTargetDb();if(confirm(`Normalize "${v.id}" to ${target} dBFS?`)){normalizeBtn.disabled=!0,status("Normalizing "+v.id+"\u2026");try{const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:target})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.file_type=d.file_type||v.file_type,v.path=d.path||v.path,v.needs_tts_restart=!0,markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs2(v),dbCell.title=v.loudness?`avg ${fmtDbfs2(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),toast("Normalized: "+v.id,"success"),status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`)}catch(e){toast("Normalize failed: "+e.message,"error"),status("Normalize failed")}finally{normalizeBtn.disabled=!1}}});const flagEmojiEl=wrap.querySelector(".flag-emoji"),flagCodeEl=wrap.querySelector(".flag-code"),flagPicker=wrap.querySelector(".flag-picker");wrap.querySelector(".vr-flag").addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".flag-picker.open").forEach(fp=>{fp!==flagPicker&&fp.classList.remove("open")}),flagPicker.classList.toggle("open")}),flagPicker.querySelectorAll(".flag-opt").forEach(opt=>{opt.addEventListener("click",async e=>{e.stopPropagation();const cc=opt.dataset.cc;flagPicker.classList.remove("open"),flagEmojiEl.textContent=cc2flag(cc),flagCodeEl.textContent=ccDisplay(cc),flagPicker.querySelectorAll(".flag-opt").forEach(o=>o.classList.toggle("active",o.dataset.cc===cc)),v.flag=cc,await saveMeta2(v.id,{flag:cc})})});const gBadge=wrap.querySelector(".gender-badge");gBadge.addEventListener("click",async()=>{const cycle=["F","M","N"];v.gender=cycle[(cycle.indexOf(v.gender||"F")+1)%3],gBadge.innerHTML=`${genderMap[v.gender]||"?"}${genderLabel[v.gender]||"\u2014"}`,gBadge.className="gender-badge "+genderClass[v.gender],await saveMeta2(v.id,{gender:v.gender})});const nameText=wrap.querySelector(".vr-name-text"),renameConf=wrap.querySelector(".rename-confirm"),nameInput=wrap.querySelector(".vr-name-input"),renameOk=wrap.querySelector(".rename-ok"),renameCancel=wrap.querySelector(".rename-cancel"),startRename=()=>{nameText.style.display="none",renameConf.classList.add("show"),nameInput.focus(),nameInput.select()};nameText.addEventListener("dblclick",startRename);const cancelRename=()=>{nameText.style.display="",renameConf.classList.remove("show"),nameInput.value=v.id};renameCancel.addEventListener("click",cancelRename);const doRename=async()=>{const newId=nameInput.value.trim();if(!newId||newId===v.id){cancelRename();return}if(!/^[A-Za-z0-9_\-\.]+$/.test(newId)){toast("Invalid characters in name","error");return}try{const r=await fetch("/api/voice/rename",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({old_id:v.id,new_id:newId})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();v.id=newId,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),nameText.textContent=newId,nameText.title=newId,nameText.style.display="",renameConf.classList.remove("show"),wrap.dataset.id=newId,nameInput.value=newId,toast("Renamed to "+newId,"success")}catch(e){toast("Rename failed: "+e.message,"error")}};renameOk.addEventListener("click",doRename),nameInput.addEventListener("keydown",e=>{e.key==="Enter"&&doRename(),e.key==="Escape"&&cancelRename()}),wrap.querySelector(".vl-compact").addEventListener("click",function(e){e.target.closest("button")||selectVoice(wrap)});const editAudioBtn=wrap.querySelector(".edit-audio-btn"),optPanel=wrap.querySelector(".vr-optimizer"),vrTypeEl=wrap.querySelector(".vr-type"),optCanvas=wrap.querySelector(".opt-wave"),optStart=wrap.querySelector(".opt-start"),optEnd=wrap.querySelector(".opt-end"),optTranscript=wrap.querySelector(".opt-transcript"),optTargetDb=wrap.querySelector(".opt-target-db"),optStyleInstruct=wrap.querySelector(".opt-style-instruct"),optStyleBackend=wrap.querySelector(".opt-style-backend"),optStyleVoiceId=wrap.querySelector(".opt-style-voice-id"),optCompareBackend=wrap.querySelector(".opt-compare-backend"),optPlayReferenceBtn=wrap.querySelector(".opt-play-reference"),optSynthReferenceBtn=wrap.querySelector(".opt-synth-reference"),optCompareRefAudio=wrap.querySelector(".opt-compare-ref-audio"),optCompareSynthAudio=wrap.querySelector(".opt-compare-synth-audio"),optPreviewStyleBtn=wrap.querySelector(".opt-preview-style"),optSaveStyleBtn=wrap.querySelector(".opt-save-style"),optStyleAudio=wrap.querySelector(".opt-style-audio"),optStatus=wrap.querySelector(".opt-status"),optSaveTextBtn=wrap.querySelector(".opt-save-text"),optRestartTtsBtn=wrap.querySelector(".opt-restart-tts"),optRebenchmarkBtn=wrap.querySelector(".opt-rebenchmark"),optRestartNote=wrap.querySelector(".opt-restart-note");let optState={loaded:!1,id:null,duration:0,buffer:null,audio:null,compareSynthUrl:null};const setOptStatus=msg=>{optStatus.textContent=msg,status(msg)},setVoiceRestartState=(required,msg="")=>{v.needs_tts_restart=required,optPanel.classList.toggle("opt-restart-needed",required),optRestartNote.hidden=!required,optRestartNote.textContent=required?"Restart TTS before benchmarking; the backend may still have the old voice cached.":"",optRebenchmarkBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",benchmarkOneBtn.title=required?"Restart TTS first, otherwise the benchmark may use a cached voice":"Benchmark this voice",msg&&setOptStatus(msg)},markTtsRestartRequired=msg=>setVoiceRestartState(!0,msg),refreshOptimizerFromVoice=async()=>{var _a3;markVoiceAudioChanged(v),optState.loaded=!1,optState.buffer=null,optState.id=null,await loadOptimizer(),vrLengthEl&&(vrLengthEl.textContent=fmtDuration(v.duration)),vrLengthEl&&(vrLengthEl.title=String((_a3=v.duration)!=null?_a3:"")),dbValue.textContent=fmtDbfs2(v),dbCell.title=v.loudness?`avg ${fmtDbfs2(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:""},saveOptimizerText=async()=>{const transcript=optTranscript.value.trim();setOptStatus("Saving reference text..."),v.transcript=transcript,refInput.value=transcript,refInput.title=transcript,refTranscribeBtn.style.display=transcript?"none":"",await saveMeta2(v.id,{transcript}),markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."),toast("Reference text saved: "+v.id,"success")},redrawOpt=()=>{!optState.buffer||optCanvas.clientWidth<4||drawOptimizerWave(optCanvas,optState.buffer,parseFloat(optStart.value)||0,parseFloat(optEnd.value)||optState.duration)};new ResizeObserver(()=>redrawOpt()).observe(optCanvas);const syncCompareReferenceAudio=()=>{if(!optState.id||!optCompareRefAudio)return;const src="/api/audio/"+optState.id;optCompareRefAudio.src.endsWith(src)||(optCompareRefAudio.src=src,optCompareRefAudio.load());const stopAtEnd=()=>{const end=parseFloat(optEnd.value)||optState.duration;optCompareRefAudio.currentTime>=end&&optCompareRefAudio.pause()};optCompareRefAudio.ontimeupdate=stopAtEnd},optWaveTimeFromEvent=e=>{const rect=optCanvas.getBoundingClientRect(),x=Math.max(0,Math.min(rect.width,e.clientX-rect.left));return optState.duration?x/Math.max(1,rect.width)*optState.duration:0},setOptCropRange=(start,end)=>{start=Math.max(0,Math.min(optState.duration||0,Number(start)||0)),end=Math.max(0,Math.min(optState.duration||0,Number(end)||0)),end{const rect=optCanvas.getBoundingClientRect(),duration=Math.max(.01,optState.duration||.01),sx=(parseFloat(optStart.value)||0)/duration*rect.width,ex=(parseFloat(optEnd.value)||optState.duration||0)/duration*rect.width;return{x:e.clientX-rect.left,sx,ex}},optWaveDragMode=e=>{const{x,sx,ex}=optWaveSelectionPixels(e),hit=18,nearStart=Math.abs(x-sx)<=hit,nearEnd=Math.abs(x-ex)<=hit;return nearStart&&nearEnd?Math.abs(x-sx)<=Math.abs(x-ex)?"start":"end":nearStart?"start":nearEnd?"end":x>sx&&x{let drag=null;optCanvas.addEventListener("pointerdown",e=>{var _a3;if(!optState.buffer||!optState.duration)return;e.preventDefault(),(_a3=optCanvas.setPointerCapture)==null||_a3.call(optCanvas,e.pointerId);const mode=optWaveDragMode(e),currentStart=parseFloat(optStart.value)||0,currentEnd=parseFloat(optEnd.value)||optState.duration;drag={mode,anchor:optWaveTimeFromEvent(e),start:currentStart,end:currentEnd,length:Math.max(.05,currentEnd-currentStart)},optCanvas.style.cursor=mode==="move"?"grabbing":"ew-resize",mode==="new"&&setOptCropRange(drag.anchor,drag.anchor)}),optCanvas.addEventListener("pointermove",e=>{if(!optState.buffer||!optState.duration)return;if(!drag){const mode=optWaveDragMode(e);optCanvas.style.cursor=mode==="move"?"grab":mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=optWaveTimeFromEvent(e);if(drag.mode==="start")setOptCropRange(Math.min(t,drag.end-.05),drag.end);else if(drag.mode==="end")setOptCropRange(drag.start,Math.max(t,drag.start+.05));else if(drag.mode==="move"){let start=t-(drag.anchor-drag.start);start=Math.max(0,Math.min((optState.duration||0)-drag.length,start)),setOptCropRange(start,start+drag.length)}else setOptCropRange(drag.anchor,t)});const finish=e=>{var _a3;drag&&((_a3=optCanvas.releasePointerCapture)==null||_a3.call(optCanvas,e.pointerId),drag=null,optCanvas.style.cursor="crosshair")};optCanvas.addEventListener("pointerup",finish),optCanvas.addEventListener("pointercancel",finish),optCanvas.addEventListener("pointerleave",()=>{drag||(optCanvas.style.cursor="crosshair")})})();const loadOptimizer=async()=>{if(optState.loaded)return;setOptStatus("Loading voice optimizer\u2026");const d=await loadLibraryVoiceAudio(v);optState.id=d.id,optState.duration=d.duration,v.duration=d.duration,optState.buffer=await decodeVoiceAudio(v),optState.loaded=!0,optStart.value="0.00",optEnd.value=d.duration.toFixed(2),optEnd.max=d.duration.toFixed(2),optTranscript.value=d.transcript||v.transcript||"",redrawOpt(),syncCompareReferenceAudio(),setVoiceRestartState(!!v.needs_tts_restart),setOptStatus(v.needs_tts_restart?"Optimizer ready. Restart TTS before benchmarking this edit.":"Optimizer ready")};if(wrap._loadOptimizer=loadOptimizer,wrap._redrawOpt=redrawOpt,editAudioBtn.addEventListener("click",async()=>{editAudioBtn.disabled=!0;try{const opening=!wrap.classList.contains("edit-open");document.querySelectorAll(".vl-row.edit-open").forEach(r=>{r!==wrap&&r.classList.remove("edit-open")}),wrap.classList.toggle("edit-open",opening),opening&&(await loadOptimizer(),wrap.scrollIntoView({behavior:"smooth",block:"nearest"}))}catch(e){toast("Edit load failed: "+e.message,"error"),status("Edit load failed")}finally{editAudioBtn.disabled=!1}}),[optStart,optEnd].forEach(inp=>inp.addEventListener("input",()=>{redrawOpt(),syncCompareReferenceAudio()})),optStyleInstruct==null||optStyleInstruct.addEventListener("input",()=>{optStyleVoiceId.value.trim()||(optStyleVoiceId.value=suggestedStyleVoiceId(v.id,optStyleInstruct.value))}),optStyleBackend==null||optStyleBackend.addEventListener("change",()=>updateStyleBackendHelp(wrap)),optCompareBackend.addEventListener("change",()=>{var _a3;return setOptStatus(`Comparison backend: ${((_a3=optCompareBackend.options[optCompareBackend.selectedIndex])==null?void 0:_a3.textContent)||optCompareBackend.value}`)}),optCompareBackend.value===""&&(optCompareBackend.innerHTML=styleBackendOptions("voice_clone"),optCompareBackend.disabled=!availableTtsBackends().length),optStyleBackend&&updateStyleBackendHelp(wrap),wrap.querySelector(".opt-db-minus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)-1).toFixed(1)}),wrap.querySelector(".opt-db-plus").addEventListener("click",()=>{optTargetDb.value=(Number(optTargetDb.value||-20)+1).toFixed(1)}),wrap.querySelector(".opt-db-auto").addEventListener("click",()=>{optTargetDb.value="-20.0"}),wrap.querySelector(".opt-play").addEventListener("click",async()=>{try{await loadOptimizer(),optState.audio&&optState.audio.pause(),optState.audio=new Audio("/api/audio/"+optState.id),optState.audio.currentTime=parseFloat(optStart.value)||0;const end=parseFloat(optEnd.value)||optState.duration;optState.audio.ontimeupdate=()=>{optState.audio.currentTime>=end&&optState.audio.pause()},optState.audio.play()}catch(e){toast("Preview failed: "+e.message,"error")}}),optPlayReferenceBtn.addEventListener("click",async()=>{try{await loadOptimizer(),syncCompareReferenceAudio(),optCompareRefAudio.currentTime=parseFloat(optStart.value)||0,await optCompareRefAudio.play().catch(()=>{}),setOptStatus("Playing reference WAV selection for comparison.")}catch(e){toast("Reference playback failed: "+e.message,"error")}}),optSynthReferenceBtn.addEventListener("click",async()=>{const text=optTranscript.value.trim();if(!text){toast("Enter reference text first","error"),optTranscript.focus();return}if(v.needs_tts_restart){if(!confirm("This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?")){setOptStatus("Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.");return}try{const d=await clearTtsRestartFlags2();setVoiceRestartState(!1,`Restart flags cleared (${d.cleared_restart_flags||0}). Synthesizing comparison...`),toast("Restart flags cleared","success")}catch(e){toast("Could not clear restart flags: "+e.message,"error"),setOptStatus("Could not clear restart flags");return}}optSynthReferenceBtn.disabled=!0;try{await loadOptimizer(),setOptStatus("Synthesizing reference text for comparison...");const source=await createTtsAudioSource(v.id,text,optCompareBackend.value,"settings","");optState.compareSynthUrl&&URL.revokeObjectURL(optState.compareSynthUrl),optCompareSynthAudio.src=source.url,optState.compareSynthUrl=source.streaming?null:source.url,await optCompareSynthAudio.play().catch(()=>{}),setOptStatus(source.streaming?"Streaming synthesized comparison.":"Synthesized comparison ready.")}catch(e){toast("Synthesis comparison failed: "+e.message,"error"),setOptStatus("Synthesis comparison failed")}finally{optSynthReferenceBtn.disabled=!1}}),wrap.querySelector(".opt-auto-trim").addEventListener("click",async()=>{try{await loadOptimizer();const d=await clientAutoTrimBounds(optState.id);optStart.value=Number(d.start).toFixed(2),optEnd.value=Number(d.end).toFixed(2),redrawOpt(),setOptStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error")}}),wrap.querySelector(".opt-recognize").addEventListener("click",async()=>{try{await loadOptimizer();const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id})});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();optTranscript.value=d.text||"",setOptStatus("Reference text recognised. Review it, then Save text.")}catch(e){toast("Recognition failed: "+e.message,"error")}}),optSaveTextBtn.addEventListener("click",async()=>{optSaveTextBtn.disabled=!0;try{await saveOptimizerText()}catch(e){toast("Save text failed: "+e.message,"error"),setOptStatus("Save text failed")}finally{optSaveTextBtn.disabled=!1}}),wrap.querySelector(".opt-save-crop").addEventListener("click",async()=>{var _a3;try{await loadOptimizer();const cropStart=Math.max(0,parseFloat(optStart.value)||0),cropEnd=Math.min(optState.duration,parseFloat(optEnd.value)||optState.duration);if(cropStart<=.01&&cropEnd>=optState.duration-.05){setOptStatus("No crop range selected. Adjust Start or End first, then Save crop."),toast("No crop range selected","error");return}if(cropEnd<=cropStart+.1){setOptStatus("Crop range is too short."),toast("Crop range is too short","error");return}setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`);const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:optState.id,start:cropStart,end:cropEnd})});if(!pr.ok){const e=await pr.json();throw new Error(e.detail||pr.statusText)}const p=await pr.json(),rr=await fetch("/api/voice-replace",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:v.id,path:v.path,transcript:optTranscript.value})});if(!rr.ok){const e=await rr.json().catch(()=>({}));throw new Error(e.detail||rr.statusText)}const saved=await rr.json();v.transcript=optTranscript.value,v.duration=(_a3=saved.duration)!=null?_a3:p.duration,saved.loudness&&(v.loudness=saved.loudness),saved.path&&(v.path=saved.path),saved.file_type&&(v.file_type=saved.file_type),markVoiceAudioChanged(v),refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Voice crop saved: "+v.id,"success"),markTtsRestartRequired(saved.backup?"Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.":"Crop saved and loaded. Restart TTS before rebenchmarking.")}catch(e){toast("Save crop failed: "+e.message,"error"),setOptStatus("Save crop failed")}}),optStyleInstruct){const styleVariationInput=()=>{const style=optStyleInstruct.value.trim(),text=optTranscript.value.trim()||benchmarkSampleText(),newId=optStyleVoiceId.value.trim()||suggestedStyleVoiceId(v.id,style);return style?text?/^[A-Za-z0-9_\-.]+$/.test(newId)?{style,text,newId,backend:optStyleBackend.value}:(toast("Invalid characters in new voice ID","error"),optStyleVoiceId.focus(),null):(toast("Enter reference text first","error"),optTranscript.focus(),null):(toast("Enter a style instruction first","error"),optStyleInstruct.focus(),null)};optPreviewStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optPreviewStyleBtn.disabled=!0;try{setOptStatus("Synthesizing style preview...");const blob=await fetchTtsPreviewBlob(v.id,input.text,"wav",input.style,input.backend);optStyleAudio.src&&URL.revokeObjectURL(optStyleAudio.src),optStyleAudio.src=URL.createObjectURL(blob),optStyleAudio.style.display="",await optStyleAudio.play().catch(()=>{}),setOptStatus("Style preview ready. If it sounds right, save it as a new voice.")}catch(e){toast("Style preview failed: "+e.message,"error"),setOptStatus("Style preview failed")}finally{optPreviewStyleBtn.disabled=!1}}}),optSaveStyleBtn.addEventListener("click",async()=>{const input=styleVariationInput();if(input){optSaveStyleBtn.disabled=!0;try{setOptStatus(`Synthesizing style variation ${input.newId}...`);const r=await fetch("/api/tts-style-variation",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({source_voice:v.id,voice_id:input.newId,text:input.text,instruct:input.style,backend:input.backend})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();toast("Style variation saved: "+d.voice_id,"success"),setOptStatus("Style variation saved. Restart TTS so the backend scans the new voice."),await loadVoiceLibrary2(),renderIntegrationSnippets()}catch(e){toast("Style variation failed: "+e.message,"error"),setOptStatus("Style variation failed")}finally{optSaveStyleBtn.disabled=!1}}})}wrap.querySelector(".opt-undo").addEventListener("click",async()=>{var _a3;if(confirm(`Restore the original backup for "${v.id}"?`))try{setOptStatus("Restoring original\u2026");const r=await fetch("/api/voice/undo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.duration=(_a3=d.duration)!=null?_a3:v.duration,v.loudness=d.loudness||v.loudness,v.path=d.path||v.path,v.file_type=d.file_type||v.file_type,markVoiceAudioChanged(v),vrTypeEl&&(vrTypeEl.textContent=voiceFileType(v).toUpperCase(),vrTypeEl.title=voiceFileType(v)),await refreshOptimizerFromVoice(),toast("Original restored: "+v.id,"success"),markTtsRestartRequired("Original restored. Restart TTS before rebenchmarking.")}catch(e){toast("Undo failed: "+e.message,"error"),setOptStatus("Undo failed")}}),wrap.querySelector(".opt-save-volume").addEventListener("click",async()=>{var _a3;try{setOptStatus("Saving volume\u2026");const r=await fetch("/api/voice/normalize",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:v.id,path:v.path,target_dbfs:Number(optTargetDb.value||-20)})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();v.loudness=d.loudness||v.loudness,v.duration=(_a3=d.duration)!=null?_a3:v.duration,d.path&&(v.path=d.path),d.file_type&&(v.file_type=d.file_type),markVoiceAudioChanged(v),dbValue.textContent=fmtDbfs2(v),dbCell.title=v.loudness?`avg ${fmtDbfs2(v)} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",await refreshOptimizerFromVoice(),toast("Volume saved: "+v.id,"success"),markTtsRestartRequired("Volume saved. Restart TTS before rebenchmarking this voice.")}catch(e){toast("Volume save failed: "+e.message,"error"),setOptStatus("Volume save failed")}}),optRestartTtsBtn.addEventListener("click",async()=>{optRestartTtsBtn.disabled=!0;try{setOptStatus("Restarting WAV backends (Voice Clone + Streaming)\u2026");const r=await fetch("/api/tts/restart",{method:"POST"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_voices2.forEach(voice=>{voice.needs_tts_restart=!1}),updateLibraryInsights();const names=(d.restarted||[]).join(", ")||"containers",errTxt=(d.errors||[]).length?` (errors: ${d.errors.join("; ")})`:"";setVoiceRestartState(!1,`Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`),toast(`TTS restarted: ${names}`,"success")}catch(e){toast("Restart TTS failed: "+e.message,"error"),setOptStatus("Restart TTS failed: "+e.message)}finally{optRestartTtsBtn.disabled=!1}});const refInput=wrap.querySelector(".vr-ref input"),refTranscribeBtn=wrap.querySelector(".ref-transcribe-btn");refInput.addEventListener("input",debounce(async()=>{v.transcript=refInput.value,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta2(v.id,{transcript:v.transcript}),wrap.classList.contains("edit-open")?markTtsRestartRequired("Reference text saved. Restart TTS before rebenchmarking."):v.needs_tts_restart=!0},800)),refTranscribeBtn.addEventListener("click",async()=>{refTranscribeBtn.disabled=!0;try{const d=await loadLibraryVoiceAudio(v),tr=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:d.id})});if(!tr.ok){const e=await tr.json();throw new Error(e.detail)}const text=await tr.json();v.transcript=text.text||"",refInput.value=v.transcript,refInput.title=v.transcript,refTranscribeBtn.style.display=v.transcript?"none":"",await saveMeta2(v.id,{transcript:v.transcript}),v.needs_tts_restart=!0,toast("Reference text recognised; restart TTS before benchmarking","success")}catch(e){toast("Recognition failed: "+e.message,"error")}finally{refTranscribeBtn.disabled=!1}});const noteInput=wrap.querySelector(".vr-note input");noteInput.addEventListener("input",debounce(async()=>{v.note=noteInput.value,await saveMeta2(v.id,{note:v.note})},800));const sourceInput=wrap.querySelector(".vr-source input");sourceInput.addEventListener("input",debounce(async()=>{v.origin=sourceInput.value.trim(),await saveMeta2(v.id,{origin:v.origin});const tblCell=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"] .vl-tbl-source`);tblCell&&(tblCell.textContent=_displaySource(v)||"-")},800));const starSpans=wrap.querySelectorAll(".star");starSpans.forEach(s=>{s.addEventListener("click",async()=>{const val=parseInt(s.dataset.val),newRating=val===v.rating?0:val;v.rating=newRating,starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{const val=parseInt(s.dataset.val);starSpans.forEach((ss,i)=>ss.classList.toggle("on",i{starSpans.forEach((ss,i)=>ss.classList.toggle("on",i<(v.rating||0)))})});const benchmarkOneBtn=wrap.querySelector(".benchmark-one-btn"),benchmarkThisVoice=async triggerBtn=>{if(v.needs_tts_restart&&!confirm("This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?")){setOptStatus("Restart TTS first, then rebenchmark this voice.");return}triggerBtn.disabled=!0,status("Benchmarking "+v.id+"...");try{setBenchmarkProgress(0,1,`Benchmarking ${v.id}`);const d=await runVoiceBenchmark(v.id);mergeBenchmarkResults(d);const hit=(d.voices||[]).find(x=>x.voice_id===v.id);hit&&hit.benchmark&&(v.benchmark=hit.benchmark);const benchCell=wrap.querySelector(".vr-bench")||document.querySelector("#voices-inspector .vr-bench");if(benchCell){benchCell.className="vr-bench "+benchmarkClass2(v),benchCell.title=benchmarkTitle2(v);const benchValue=benchCell.querySelector(".vr-bench-value");benchValue&&(benchValue.textContent=fmtBenchmark2(v))}const tblRow=document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);if(tblRow){const bc=benchmarkClass2(v),btitle=benchmarkTitle2(v),tblDur=tblRow.querySelector(".vl-tbl-dur"),tblFactor=tblRow.querySelector(".vl-tbl-factor"),tblTime=tblRow.querySelector(".vl-tbl-time"),tblWpm=tblRow.querySelector(".vl-tbl-wpm");if(tblDur&&(tblDur.textContent=fmtBenchmarkAudio(v),tblDur.title=`${fmtBenchmarkAudio(v)} \u2014 length of synthesised benchmark audio`),tblFactor&&(tblFactor.textContent=fmtFactor(v),tblFactor.className=`vl-tbl-factor ${bc}`,tblFactor.title=btitle),tblTime&&(tblTime.textContent=fmtElapsed(v),tblTime.className=`vl-tbl-time ${bc}`,tblTime.title=btitle),tblWpm){const wpm=voiceWpm(v);tblWpm.textContent=fmtWpm(v),tblWpm.title=wpm!=null?`${wpm} wpm \u2014 130\u2013180 wpm is natural for long listening`:""}}setBenchmarkProgress(1,1,`Finished ${v.id}`),toast("Benchmarked "+v.id,"success"),setVoiceRestartState(!1,"Benchmark saved for "+v.id)}catch(e){toast("Benchmark failed: "+e.message,"error"),setOptStatus("Benchmark failed")}finally{triggerBtn.disabled=!1}};benchmarkOneBtn.addEventListener("click",()=>benchmarkThisVoice(benchmarkOneBtn)),optRebenchmarkBtn.addEventListener("click",()=>benchmarkThisVoice(optRebenchmarkBtn));const originalPlayBtn=wrap.querySelector(".vr-play-original button"),synthPlayBtn=wrap.querySelector(".vr-play-synth button"),playIcon='',pauseIcon='',generatingIcon='';function setLibraryPlayButtonState(btn,state){btn.classList.toggle("is-generating",state==="generating"),btn.innerHTML=state==="playing"?pauseIcon:state==="generating"?generatingIcon:playIcon,btn.title=state==="generating"?"Generating synthesized sample...":state==="playing"?"Pause playback":btn.dataset.playKind==="synth"?"Generate and play synthesized sample":"Play original recording"}async function playLibraryVoice(kind,playBtn){var _a3,_b2,_c2;const bar=$("lib-audio-bar"),audio=$("lib-audio"),playKey=v.id+":"+kind;if(playBtn.dataset.playKind=kind,_activePlayVoiceId===playKey&&!audio.paused){audio.pause(),setLibraryPlayButtonState(playBtn,"idle");return}if(_activePlayVoiceId===playKey&&audio.paused&&audio.src){_activePlayButton=playBtn;try{await audio.play()}catch(e){toast("Play failed: "+e.message,"error")}return}_activePlayButton&&_activePlayButton!==playBtn&&setLibraryPlayButtonState(_activePlayButton,"idle"),_activePlayButton=playBtn,_activePlayVoiceId=playKey,_activePlayUrl&&(URL.revokeObjectURL(_activePlayUrl),_activePlayUrl=null),kind==="synth"&&setLibraryPlayButtonState(playBtn,"generating"),playBtn.disabled=!0;try{if(kind==="synth"){v.needs_tts_restart&&toast("This voice changed since backend refresh; synthesized playback may use a cached voice.","error");const synthMode=((_a3=document.querySelector("#vl-synth-mode-seg .vl-synth-seg-btn.active"))==null?void 0:_a3.dataset.mode)||"preview",text=synthMode==="transcript"&&((_b2=v.transcript)==null?void 0:_b2.trim())||benchmarkSampleText(),textLabel=synthMode==="transcript"?"reference transcript":"preview text",backend=isClone?libraryTtsBackend():"voice_design",source=await createTtsAudioSource(v.id,text,backend,"settings","");audio.src=source.url,source.streaming||(_activePlayUrl=source.url),$("lib-audio-label").textContent=v.id+" \xB7 synthesized "+textLabel+" \xB7 "+(((_c2=backendById(backend))==null?void 0:_c2.label)||backend)}else audio.src=voiceFileUrl2(v),$("lib-audio-label").textContent=v.id+" \xB7 original recording";bar.style.display="",audio.onended=()=>{setLibraryPlayButtonState(playBtn,"idle"),_activePlayVoiceId=null,_activePlayUrl&&(URL.revokeObjectURL(_activePlayUrl),_activePlayUrl=null)},audio.onpause=()=>{_activePlayButton===playBtn&&setLibraryPlayButtonState(playBtn,"idle")},audio.onplay=()=>{setLibraryPlayButtonState(playBtn,"playing")},await audio.play()}catch(e){setLibraryPlayButtonState(playBtn,"idle"),toast("Play failed: "+e.message,"error")}finally{playBtn.disabled=!1}}originalPlayBtn.dataset.playKind="original",synthPlayBtn.dataset.playKind="synth",setLibraryPlayButtonState(originalPlayBtn,"idle"),setLibraryPlayButtonState(synthPlayBtn,"idle"),originalPlayBtn.addEventListener("click",()=>playLibraryVoice("original",originalPlayBtn)),synthPlayBtn.addEventListener("click",()=>playLibraryVoice("synth",synthPlayBtn));const toggleCb=wrap.querySelector(".toggle input");toggleCb.addEventListener("change",async()=>{const nextEnabled=toggleCb.checked,previousEnabled=v.enabled!==!1;toggleCb.disabled=!0;try{const saved=await saveMeta2(v.id,{enabled:nextEnabled});v.enabled=nextEnabled,saved&&saved.path&&(v.path=saved.path),wrap.classList.toggle("vr-disabled",!v.enabled),toast(nextEnabled?"Moved to active_voices":"Moved to hidden_voices","success"),!v.enabled&&!$("show-disabled-cb").checked&&(wrap.style.transition="opacity .4s",wrap.style.opacity="0",setTimeout(()=>wrap.remove(),400))}catch(e){toggleCb.checked=previousEnabled,v.enabled=previousEnabled,wrap.classList.toggle("vr-disabled",!v.enabled),toast("Move failed: "+e.message,"error")}finally{toggleCb.disabled=!1}});const deleteBtn=wrap.querySelector(".delete-btn"),deleteConfirm=wrap.querySelector(".delete-confirm"),deleteCancelBtn=wrap.querySelector(".delete-confirm-cancel"),deleteGoBtn=wrap.querySelector(".delete-confirm-go"),closeDeleteConfirm=()=>wrap.classList.remove("delete-pending");return deleteBtn.addEventListener("click",e=>{e.stopPropagation(),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>{row!==wrap&&row.classList.remove("delete-pending")}),wrap.classList.add("delete-pending"),deleteGoBtn.focus()}),deleteCancelBtn.addEventListener("click",e=>{e.stopPropagation(),closeDeleteConfirm()}),deleteConfirm.addEventListener("click",e=>e.stopPropagation()),deleteGoBtn.addEventListener("click",async e=>{e.stopPropagation(),deleteGoBtn.disabled=!0,deleteCancelBtn.disabled=!0;try{const r=await fetch(`/api/voice/${encodeURIComponent(v.id)}`,{method:"DELETE"});if(!r.ok){const e2=await r.json();throw new Error(e2.detail)}_voices2=_voices2.filter(x=>x.id!==v.id),wrap.style.transition="opacity .3s",wrap.style.opacity="0",setTimeout(()=>{wrap.remove(),$("voice-count").textContent=_voices2.filter(x=>$("show-disabled-cb").checked||x.enabled!==!1).length+" / "+_voices2.length+" voices"},300),toast(`Moved to Voice Trash: ${v.id}`,"success")}catch(e2){toast("Delete failed: "+e2.message,"error"),deleteGoBtn.disabled=!1,deleteCancelBtn.disabled=!1,closeDeleteConfirm()}}),wrap}function renderVoiceGroupsBar(){var _a2;const bar=$("voice-groups-bar");if(!bar)return;const groups={};(_voices2||[]).forEach(v=>{const g=(v.group||"").trim();g&&(groups[g]=(groups[g]||0)+1)});const names=Object.keys(groups).sort((a,b)=>a.localeCompare(b));if(!names.length){bar.hidden=!0,bar.innerHTML="";return}bar.hidden=!1;const active=window._voiceGroupFilter||"";bar.innerHTML=`
+ Groups
+ ${names.length}
+ ${active?`${escHtml(active)}`:""}
+
+ `+names.map(g=>`
${escHtml(g)} ${groups[g]}
- `).join("")+(active?'':""),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;const groupSel=$("library-filter-group");groupSel&&(groupSel.value=window._voiceGroupFilter||""),renderVoiceList2()})}),bar.querySelectorAll(".vg-del").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),deleteVoiceGroup(btn.dataset.group)})}),(_a2=bar.querySelector(".vg-clear"))==null||_a2.addEventListener("click",()=>{window._voiceGroupFilter="";const groupSel=$("library-filter-group");groupSel&&(groupSel.value=""),renderVoiceList2()})}async function deleteVoiceGroup(group){const count=(_voices2||[]).filter(v=>(v.group||"").trim()===group).length;if(confirm(`Move all ${count} voice${count!==1?"s":""} in group "${group}" to Voice Trash? You can restore the complete packages from the trash folder later.`))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();window._voiceGroupFilter===group&&(window._voiceGroupFilter=""),typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),toast(`Moved ${d.count} voice${d.count!==1?"s":""} from "${group}" to Voice Trash`,"success"),await loadVoiceLibrary2()}catch(e){toast("Delete failed: "+e.message,"error")}}async function saveMeta2(voiceId,patch){const r=await fetch("/api/voice/meta",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,...patch})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),r.json()}document.addEventListener("click",()=>{document.querySelectorAll(".flag-picker.open").forEach(fp=>fp.classList.remove("open")),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>row.classList.remove("delete-pending"))},{passive:!0});const _bulkSelected2=new Set;function _bulkUpdate(){const bar=$("vl-bulk-bar"),count=$("vl-bulk-count"),n=_bulkSelected2.size;bar&&(bar.hidden=n===0),count&&(count.textContent=`${n} selected`),document.querySelectorAll(".vl-bulk-cb").forEach(cb=>{cb.checked=_bulkSelected2.has(cb.dataset.id)});const headerCb=document.getElementById("vl-select-all-header");if(headerCb){const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id),allSelected=rows.length>0&&rows.every(r=>_bulkSelected2.has(r.dataset.id));headerCb.checked=allSelected}}function _bulkToggle(id,checked){checked?_bulkSelected2.add(id):_bulkSelected2.delete(id),_bulkUpdate()}const _origRenderVoiceList=renderVoiceList2;renderVoiceList2=function(){_origRenderVoiceList.apply(this,arguments),_bulkInjectCheckboxes()};let _bulkLastClickedId=null;function _bulkInjectCheckboxes(){document.querySelectorAll("#voice-list .vl-row").forEach(row=>{if(row.querySelector(".vl-bulk-cb"))return;const id=row.dataset.id,cb=document.createElement("input");cb.type="checkbox",cb.className="vl-bulk-cb",cb.dataset.id=id,cb.checked=_bulkSelected2.has(id),cb.title="Select for bulk edit (shift-click to select a range)",cb.addEventListener("click",e=>{if(e.stopPropagation(),e.shiftKey&&_bulkLastClickedId){const ids=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id).map(r=>r.dataset.id),from=ids.indexOf(_bulkLastClickedId),to=ids.indexOf(id);if(from!==-1&&to!==-1){const[lo,hi]=from{document.querySelectorAll("#voice-list .vl-row").forEach(row=>{row.dataset.id&&_bulkSelected2.add(row.dataset.id)}),_bulkUpdate()}),(_k=$("vl-select-all-header"))==null||_k.addEventListener("change",e=>{const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id);e.target.checked?rows.forEach(r=>_bulkSelected2.add(r.dataset.id)):rows.forEach(r=>_bulkSelected2.delete(r.dataset.id)),_bulkUpdate()}),(_l=$("vl-bulk-deselect"))==null||_l.addEventListener("click",()=>{_bulkSelected2.clear(),_bulkUpdate()}),(_m=$("vl-bulk-hide"))==null||_m.addEventListener("click",async()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],r=await _bulkSetEnabled(ids,!1);toast(`Hidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected2.clear(),await loadVoiceLibrary2()}),(_n=$("vl-bulk-unhide"))==null||_n.addEventListener("click",async()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],r=await _bulkSetEnabled(ids,!0);toast(`Unhidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected2.clear(),await loadVoiceLibrary2()}),(_o=$("vl-bulk-tag"))==null||_o.addEventListener("click",()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],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\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const newTags=inp.value.split(",").map(t=>t.trim()).filter(Boolean);let done=0;for(const id of ids){const v=(window._voices||[]).find(vv=>vv.id===id),merged=String((v==null?void 0:v.tag)||"").split(",").map(t=>t.trim()).filter(Boolean).slice();for(const t of newTags)merged.some(e=>e.toLowerCase()===t.toLowerCase())||merged.push(t);await saveMeta2(id,{tag:merged.join(", ")}).catch(()=>{}),done++}toast(`Tag added on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected2.clear(),await loadVoiceLibrary2()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_p=$("vl-bulk-source"))==null||_p.addEventListener("click",()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],bar=$("vl-bulk-bar");let inp=bar.querySelector(".vl-bulk-source-inp");if(!inp){inp=document.createElement("input"),inp.type="text",inp.className="vl-bulk-source-inp",inp.placeholder="e.g. fish-audio, cloned\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const val=inp.value.trim();let done=0;for(const id of ids)await saveMeta2(id,{origin:val}).catch(()=>{}),done++;toast(`Source set on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected2.clear(),await loadVoiceLibrary2()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_q=$("vl-bulk-rating"))==null||_q.addEventListener("click",()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],bar=$("vl-bulk-bar");let sel=bar.querySelector(".vl-bulk-rating-sel");sel||(sel=document.createElement("select"),sel.className="vl-bulk-rating-sel",sel.innerHTML=''+[1,2,3,4,5].map(n=>``).join(""),sel.addEventListener("change",async()=>{const rating=Number(sel.value);if(!rating)return;let done=0;for(const id of ids)await saveMeta2(id,{rating}).catch(()=>{}),done++;toast(`Rated ${done} voice${done!==1?"s":""}`,"success"),sel.remove(),_bulkSelected2.clear(),await loadVoiceLibrary2()}),bar.appendChild(sel),sel.focus())}),(_r=$("vl-bulk-delete"))==null||_r.addEventListener("click",()=>{_bulkSelected2.size&&_showBulkDeleteConfirm([..._bulkSelected2])});function _showBulkDeleteConfirm(ids){var _a2;(_a2=document.querySelector(".vl-bdc-overlay"))==null||_a2.remove();const plural=ids.length!==1?"s":"",names=ids.slice(0,12).map(id=>`${escHtml(id)}`).join(""),more=ids.length>12?`+${ids.length-12} more`:"",ov=document.createElement("div");ov.className="vl-bdc-overlay",ov.innerHTML=`
+ `).join("")+"
",bar.querySelector("details").addEventListener("toggle",e=>{window._vgOpen=e.target.open}),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;const groupSel=$("library-filter-group");groupSel&&(groupSel.value=window._voiceGroupFilter||""),renderVoiceList2()})}),bar.querySelectorAll(".vg-del").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),deleteVoiceGroup(btn.dataset.group)})}),(_a2=bar.querySelector(".vg-clear"))==null||_a2.addEventListener("click",e=>{e.preventDefault(),e.stopPropagation(),window._voiceGroupFilter="";const groupSel=$("library-filter-group");groupSel&&(groupSel.value=""),renderVoiceList2()})}async function deleteVoiceGroup(group){const count=(_voices2||[]).filter(v=>(v.group||"").trim()===group).length;if(confirm(`Move all ${count} voice${count!==1?"s":""} in group "${group}" to Voice Trash? You can restore the complete packages from the trash folder later.`))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();window._voiceGroupFilter===group&&(window._voiceGroupFilter=""),typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),toast(`Moved ${d.count} voice${d.count!==1?"s":""} from "${group}" to Voice Trash`,"success"),await loadVoiceLibrary2()}catch(e){toast("Delete failed: "+e.message,"error")}}async function saveMeta2(voiceId,patch){const r=await fetch("/api/voice/meta",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,...patch})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),r.json()}document.addEventListener("click",()=>{document.querySelectorAll(".flag-picker.open").forEach(fp=>fp.classList.remove("open")),document.querySelectorAll(".vl-row.delete-pending").forEach(row=>row.classList.remove("delete-pending"))},{passive:!0});const _bulkSelected2=new Set;function _bulkUpdate(){const bar=$("vl-bulk-bar"),count=$("vl-bulk-count"),n=_bulkSelected2.size;bar&&(bar.hidden=n===0);const ab=$("vl-actions-btn");if(ab){ab.disabled=n===0,ab.title=n===0?"Check one or more voices to enable actions":`Actions on ${n} checked voice${n===1?"":"s"}`;const cnt=$("vl-actions-n");cnt&&(cnt.textContent=n?`(${n})`:""),n===0&&_setActionsMenu(!1)}count&&(count.textContent=`${n} selected`),document.querySelectorAll(".vl-bulk-cb").forEach(cb=>{cb.checked=_bulkSelected2.has(cb.dataset.id)});const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id),nSel=rows.filter(r=>_bulkSelected2.has(r.dataset.id)).length;document.querySelectorAll(".vl-select-all-cb").forEach(cb=>{cb.checked=rows.length>0&&nSel===rows.length,cb.indeterminate=nSel>0&&nSel_setActionsMenu($("vl-actions-menu").hidden)),(_k=$("vl-actions-menu"))==null||_k.addEventListener("click",e=>{e.target.closest("button")&&_setActionsMenu(!1)}),document.addEventListener("click",e=>{e.target.closest(".vl-actions")||_setActionsMenu(!1)}),document.addEventListener("keydown",e=>{var _a2,_b2;e.key==="Escape"&&!((_a2=$("vl-actions-menu"))!=null&&_a2.hidden)&&(_setActionsMenu(!1),(_b2=$("vl-actions-btn"))==null||_b2.focus())});function _synthModeLabel(){var _a2;return((_a2=document.querySelector("#vl-synth-mode-seg .vl-synth-seg-btn.active"))==null?void 0:_a2.textContent.trim())||"Preview text"}function _refreshSynthToggle(){var _a2;const t=$("vl-synth-toggle");t&&(t.title=`Synth uses: ${_synthModeLabel()} \u2014 click to ${(_a2=$("vl-synth-drawer"))!=null&&_a2.hidden?"edit":"hide"}`)}function _setSynthDrawer(open){const d=$("vl-synth-drawer"),t=$("vl-synth-toggle");if(!(!d||!t)){d.hidden=!open,t.setAttribute("aria-expanded",String(open)),t.classList.toggle("active",open),_refreshSynthToggle();try{localStorage.setItem("vl-synth-open",open?"1":"0")}catch{}}}(_l=$("vl-synth-toggle"))==null||_l.addEventListener("click",()=>_setSynthDrawer($("vl-synth-drawer").hidden)),document.addEventListener("click",e=>{e.target.closest("#vl-synth-mode-seg")&&setTimeout(_refreshSynthToggle,0)});try{_setSynthDrawer(localStorage.getItem("vl-synth-open")==="1")}catch{}function _updateFilterBadge(){var _a2;const badge=$("vl-filter-badge");if(!badge)return;const val=id=>{var _a3;return(((_a3=$(id))==null?void 0:_a3.value)||"").trim()},n=["library-filter-lang","library-filter-sex","library-filter-type","library-filter-tag","library-filter-rating","library-filter-group"].filter(id=>val(id)).length+((_a2=$("show-disabled-cb"))!=null&&_a2.checked?1:0)+(val("library-filter-text")?1:0);badge.textContent=String(n),badge.hidden=n===0;const t=$("vl-filter-toggle");t&&(t.title=n?`Filters (${n} active)`:"Show filters and sorting")}function _setFilterPanel(open){const panel=$("vl-filter-panel"),t=$("vl-filter-toggle");if(!(!panel||!t)){panel.hidden=!open,t.setAttribute("aria-expanded",String(open)),t.classList.toggle("active",open);try{localStorage.setItem("vl-filter-open",open?"1":"0")}catch{}}}(_m=$("vl-filter-toggle"))==null||_m.addEventListener("click",()=>_setFilterPanel($("vl-filter-panel").hidden));try{_setFilterPanel(localStorage.getItem("vl-filter-open")==="1")}catch{}let _bulkLastClickedId=null;function _bulkInjectCheckboxes(){document.querySelectorAll("#voice-list .vl-row").forEach(row=>{if(row.querySelector(".vl-bulk-cb"))return;const id=row.dataset.id,cb=document.createElement("input");cb.type="checkbox",cb.className="vl-bulk-cb",cb.dataset.id=id,cb.checked=_bulkSelected2.has(id),cb.title="Select for bulk edit (shift-click to select a range)",cb.addEventListener("click",e=>{if(e.stopPropagation(),e.shiftKey&&_bulkLastClickedId){const ids=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id).map(r=>r.dataset.id),from=ids.indexOf(_bulkLastClickedId),to=ids.indexOf(id);if(from!==-1&&to!==-1){const[lo,hi]=from{if(!e.target.classList.contains("vl-select-all-cb"))return;const rows=[...document.querySelectorAll("#voice-list .vl-row")].filter(r=>r.dataset.id);e.target.checked?rows.forEach(r=>_bulkSelected2.add(r.dataset.id)):rows.forEach(r=>_bulkSelected2.delete(r.dataset.id)),_bulkUpdate()}),(_n=$("vl-bulk-hide"))==null||_n.addEventListener("click",async()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],r=await _bulkSetEnabled(ids,!1);toast(`Hidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected2.clear(),await loadVoiceLibrary2()}),(_o=$("vl-bulk-unhide"))==null||_o.addEventListener("click",async()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],r=await _bulkSetEnabled(ids,!0);toast(`Unhidden ${r} voice${r!==1?"s":""}`,"success"),_bulkSelected2.clear(),await loadVoiceLibrary2()}),(_p=$("vl-bulk-tag"))==null||_p.addEventListener("click",()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],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\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const newTags=inp.value.split(",").map(t=>t.trim()).filter(Boolean);let done=0;for(const id of ids){const v=(window._voices||[]).find(vv=>vv.id===id),merged=String((v==null?void 0:v.tag)||"").split(",").map(t=>t.trim()).filter(Boolean).slice();for(const t of newTags)merged.some(e=>e.toLowerCase()===t.toLowerCase())||merged.push(t);await saveMeta2(id,{tag:merged.join(", ")}).catch(()=>{}),done++}toast(`Tag added on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected2.clear(),await loadVoiceLibrary2()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_q=$("vl-bulk-source"))==null||_q.addEventListener("click",()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],bar=$("vl-bulk-bar");let inp=bar.querySelector(".vl-bulk-source-inp");if(!inp){inp=document.createElement("input"),inp.type="text",inp.className="vl-bulk-source-inp",inp.placeholder="e.g. fish-audio, cloned\u2026",inp.autocomplete="off";const applyBtn=document.createElement("button");applyBtn.className="vl-bulk-btn",applyBtn.innerHTML=' Apply',applyBtn.addEventListener("click",async()=>{const val=inp.value.trim();let done=0;for(const id of ids)await saveMeta2(id,{origin:val}).catch(()=>{}),done++;toast(`Source set on ${done} voice${done!==1?"s":""}`,"success"),inp.remove(),applyBtn.remove(),_bulkSelected2.clear(),await loadVoiceLibrary2()}),bar.appendChild(inp),bar.appendChild(applyBtn)}inp.focus()}),(_r=$("vl-bulk-rating"))==null||_r.addEventListener("click",()=>{if(!_bulkSelected2.size)return;const ids=[..._bulkSelected2],bar=$("vl-bulk-bar");let sel=bar.querySelector(".vl-bulk-rating-sel");sel||(sel=document.createElement("select"),sel.className="vl-bulk-rating-sel",sel.innerHTML=''+[1,2,3,4,5].map(n=>``).join(""),sel.addEventListener("change",async()=>{const rating=Number(sel.value);if(!rating)return;let done=0;for(const id of ids)await saveMeta2(id,{rating}).catch(()=>{}),done++;toast(`Rated ${done} voice${done!==1?"s":""}`,"success"),sel.remove(),_bulkSelected2.clear(),await loadVoiceLibrary2()}),bar.appendChild(sel),sel.focus())}),(_s=$("vl-bulk-delete"))==null||_s.addEventListener("click",()=>{_bulkSelected2.size&&_showBulkDeleteConfirm([..._bulkSelected2])});function _showBulkDeleteConfirm(ids){var _a2;(_a2=document.querySelector(".vl-bdc-overlay"))==null||_a2.remove();const plural=ids.length!==1?"s":"",names=ids.slice(0,12).map(id=>`${escHtml(id)}`).join(""),more=ids.length>12?`+${ids.length-12} more`:"",ov=document.createElement("div");ov.className="vl-bdc-overlay",ov.innerHTML=`
Move ${ids.length} voice${plural} to Voice Trash?
This removes the selected voice${plural} from the library while preserving each complete package in the Voice Trash folder.
@@ -905,4 +910,4 @@ This warms each voice so the engine caches its .pt and first playback is instant
${b.speed==null?"\u2014":Number(b.speed).toFixed(2)+"x"} |
${esc(status2)} |
`}).join(""):'
| No voices benchmarked. |
')}async function initTurnControls(){try{const settings=await fetchJson("/api/settings");q("bench-turn-llm-url")&&(q("bench-turn-llm-url").value=settings.conv_llm_url||settings.llm_url||"")}catch(_){logErr("bench-turn-settings",_)}try{const d=await fetchJson("/api/stt-backends"),sel=q("bench-turn-stt");sel&&(sel.innerHTML=(d.backends||[]).map(b=>`
`).join("")||'
')}catch{}try{typeof refreshTtsBackendAvailability=="function"&&await refreshTtsBackendAvailability();const sel=q("bench-turn-tts-backend"),backends=(window._ttsBackends||(typeof _ttsBackends!="undefined"?_ttsBackends:[])||[]).filter(Boolean);sel&&(sel.innerHTML=backends.map(b=>`
`).join("")||'
')}catch{}}async function fetchTurnModels(){var _a;const btn=q("bench-turn-fetch-llm"),sel=q("bench-turn-llm-model"),url=((_a=q("bench-turn-llm-url"))==null?void 0:_a.value.trim())||"";btn&&(btn.disabled=!0);try{const models=(await fetchJson("/api/conversation/llm-models"+(url?"?url="+encodeURIComponent(url):""))).models||[];sel&&(sel.innerHTML=models.length?models.map(m=>`
`).join(""):'
')}catch(e){sel&&(sel.innerHTML='
'),say("Model fetch failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function fetchTurnVoices(){var _a;const btn=q("bench-turn-fetch-voices"),sel=q("bench-turn-voice"),backend=((_a=q("bench-turn-tts-backend"))==null?void 0:_a.value)||"voice_clone",picker=window.BenchmarkVoicePicker;btn&&(btn.disabled=!0);try{const raw=await fetchJson("/api/tts-voices?backend="+encodeURIComponent(backend)),items=(Array.isArray(raw)?raw:[]).map(v=>{const id=typeof backendVoiceId=="function"?backendVoiceId(v):typeof v=="string"?v:v.id||v.voice||v.name;return id?{id,label:id,meta:(window._voices||[]).find(x=>x&&x.id===id)||(typeof v=="object"?v:null)}:null}).filter(Boolean);picker?picker.populate("bench-turn-voice",items,{placeholder:"Fetch voices",empty:"No voices"}):sel&&(sel.innerHTML=items.length?items.map(v=>`
`).join(""):'
')}catch(e){picker?picker.populate("bench-turn-voice",[],{placeholder:"Fetch failed",empty:"Fetch failed"}):sel&&(sel.innerHTML='
'),say("Voice fetch failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function updateTurnStats(stats){const max=stats.total_ms||1;[["stt",stats.stt_ms],["ttft",stats.llm_ttft_ms],["llm",stats.llm_total_ms],["tts",stats.tts_ms],["total",stats.total_ms]].forEach(([key,ms])=>{const val=q("bench-turn-val-"+key),fill=q("bench-turn-fill-"+key);val&&(val.textContent=fmtMs(ms)),fill&&(fill.style.width=max>0?Math.min(100,(ms||0)/max*100)+"%":"0%")})}function addTurnLog(role,text){var _a;const log=q("bench-turn-log");if(!log)return null;(_a=log.querySelector(".conv-chat-welcome"))==null||_a.remove();const wrap=document.createElement("div");wrap.className=`conv-bubble-wrap conv-bubble-wrap--${role}`;const bubble=document.createElement("div");return bubble.className=`conv-bubble conv-bubble--${role}`,bubble.textContent=text||"",wrap.appendChild(bubble),log.appendChild(wrap),log.scrollTop=log.scrollHeight,bubble}function addTurnHistory(total,ok){var _a;const hist=q("bench-turn-history");if(!hist)return;(_a=hist.querySelector(".conv-history-empty"))==null||_a.remove(),turnHistoryCount++;const item=document.createElement("div");item.className="conv-hist-item",item.innerHTML=`
#${turnHistoryCount}${fmtMs(total)}`,hist.prepend(item)}async function runTurnBenchmark(){var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k;const audio=(_b=(_a=q("bench-turn-audio"))==null?void 0:_a.files)==null?void 0:_b[0],text=((_c=q("bench-turn-text"))==null?void 0:_c.value.trim())||"";if(!audio&&!text){say("Choose turn audio or enter fallback text","error");return}const btn=q("bench-turn-run"),st=q("bench-turn-status");btn&&(btn.disabled=!0),st&&(st.textContent="Running conversation turn...");const t0=Date.now(),userBubble=addTurnLog("user",text||"Transcribing audio..."),assistantBubble=addTurnLog("assistant","...");let assistantText="",lastStats=null;try{const fd=new FormData;audio&&fd.append("audio",audio,audio.name),text&&fd.append("text",text),fd.append("stt_backend",((_d=q("bench-turn-stt"))==null?void 0:_d.value)||"configured"),fd.append("llm_url",((_e=q("bench-turn-llm-url"))==null?void 0:_e.value.trim())||""),fd.append("llm_model",((_f=q("bench-turn-llm-model"))==null?void 0:_f.value)||""),fd.append("tts_backend",((_g=q("bench-turn-tts-backend"))==null?void 0:_g.value)||"voice_clone"),fd.append("tts_voice",((_h=q("bench-turn-voice"))==null?void 0:_h.value)||""),fd.append("system_prompt",((_i=q("bench-turn-system"))==null?void 0:_i.value.trim())||"You are a helpful voice assistant."),fd.append("history","[]");const resp=await fetch("/api/conversation/turn",{method:"POST",body:fd});if(!resp.ok)throw new Error("Server error "+resp.status);const reader=resp.body.getReader(),dec=new TextDecoder;let buf="";for(;;){const{done,value}=await reader.read();if(done)break;buf+=dec.decode(value,{stream:!0});const lines=buf.split(`
-`);buf=lines.pop();for(const line of lines){if(!line.startsWith("data:"))continue;let evt;try{evt=JSON.parse(line.slice(5).trim())}catch{continue}if(evt.type==="transcript"&&userBubble&&(userBubble.textContent=evt.text||"(empty)"),evt.type==="token"&&(assistantText+=evt.delta||"",assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="llm_done"&&(assistantText=evt.text||assistantText,assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="audio"&&evt.b64){const bytes=Uint8Array.from(atob(evt.b64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:evt.mime||"audio/wav"})),audioEl=document.createElement("audio");audioEl.controls=!0,audioEl.src=url,audioEl.addEventListener("ended",()=>URL.revokeObjectURL(url),{once:!0}),(_j=q("bench-turn-log"))==null||_j.appendChild(audioEl)}if(evt.type==="stats"&&(lastStats=evt,updateTurnStats(evt)),evt.type==="error")throw new Error(`[${evt.stage||"turn"}] ${evt.message||"Unknown error"}`)}}const total=(_k=lastStats==null?void 0:lastStats.total_ms)!=null?_k:Date.now()-t0;addTurnHistory(total,!0),st&&(st.textContent=`Finished in ${fmtMs(total)}.`),say("Turn benchmark complete","success")}catch(e){assistantBubble&&(assistantBubble.textContent=e.message),addTurnHistory(Date.now()-t0,!1),st&&(st.textContent="Turn benchmark failed"),say("Turn benchmark failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function bindBenchmarkSection(){var _a,_b,_c,_d,_e,_f,_g,_h,_i;initialized||!q("bench-stt-run")||(initialized=!0,document.querySelectorAll(".bench-tab").forEach(btn=>btn.addEventListener("click",()=>setBenchTab(btn.dataset.benchTab))),window.BenchmarkVoicePicker&&(BenchmarkVoicePicker.upgrade("bench-stt-library-voice",{placeholder:"-- choose from voice library --",empty:"No library voices with reference transcripts found"}),BenchmarkVoicePicker.upgrade("perf-voice-select",{placeholder:"-- select after fetch --",empty:"No voices"}),BenchmarkVoicePicker.upgrade("bench-turn-voice",{placeholder:"Fetch voices",empty:"No voices"})),document.addEventListener("click",()=>closeBenchmarkModelPickers()),(_a=q("bench-stt-refresh"))==null||_a.addEventListener("click",loadBenchmarkSttEngines),(_b=q("bench-stt-load-voice"))==null||_b.addEventListener("click",useBenchmarkLibraryVoice),(_c=q("bench-stt-audio"))==null||_c.addEventListener("change",()=>{q("bench-stt-source-id")&&(q("bench-stt-source-id").value="")}),(_d=q("bench-stt-run"))==null||_d.addEventListener("click",runSttBenchmark),(_e=q("bench-tts-run"))==null||_e.addEventListener("click",runTtsBenchmark),(_f=q("bench-turn-fetch-llm"))==null||_f.addEventListener("click",fetchTurnModels),(_g=q("bench-turn-fetch-voices"))==null||_g.addEventListener("click",fetchTurnVoices),(_h=q("bench-turn-run"))==null||_h.addEventListener("click",runTurnBenchmark),(_i=q("bench-turn-tts-backend"))==null||_i.addEventListener("change",()=>{window.BenchmarkVoicePicker?BenchmarkVoicePicker.populate("bench-turn-voice",[],{placeholder:"Fetch voices",empty:"No voices"}):q("bench-turn-voice")&&(q("bench-turn-voice").innerHTML='
')}))}function loadBenchmarkSectionData(){bindBenchmarkSection(),!(benchmarkDataLoaded||!q("bench-stt-run"))&&(benchmarkDataLoaded=!0,loadBenchmarkSttEngines(),loadBenchmarkVoiceLibrary(),initTurnControls())}window.loadBenchmarkSectionData=loadBenchmarkSectionData,bindBenchmarkSection(),!initialized&&document.body&&new MutationObserver(()=>bindBenchmarkSection()).observe(document.body,{childList:!0,subtree:!0})}(),function(root){var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l;let sttTtsSourceId=null,sttTtsOutputBlob=null,_sttBackends=[],sttTtsRecorder=null,sttTtsRecordStream=null,sttTtsRecordChunks=[],sttTtsRecordTimer=null,sttTtsRecordSecs=0;function sttTtsSelectedSttBackend(){var _a2;return((_a2=$("stt-tts-stt-backend"))==null?void 0:_a2.value)||"configured"}function sttBackendOptionHtml(selected="configured"){var _a2;if(!_sttBackends.length)return'
';const preferred=_sttBackends.some(b=>b.id===selected&&b.available)?selected:((_a2=_sttBackends.find(b=>b.available))==null?void 0:_a2.id)||selected;return _sttBackends.map(b=>{const suffix=b.available?"":" (unavailable)",disabled=b.available?"":" disabled";return`
`}).join("")}function updateSttBackendHelp(){const selected=sttTtsSelectedSttBackend(),b=_sttBackends.find(item=>item.id===selected)||_sttBackends.find(item=>item.available)||null,help=$("stt-tts-stt-help");if(help){if(!b){help.textContent="No STT engine status loaded yet.";return}help.innerHTML=sttBackendHelpHtml(b)}}async function refreshSttBackends2(selected=""){try{_sttBackends=((await fetch("/api/stt-backends").then(r=>r.json())).backends||[]).filter(b=>b&&b.id)}catch{_sttBackends=[]}["stt-tts-stt-backend","clone-stt-backend"].forEach(id=>{const sel=$(id);if(!sel)return;const prev=selected||sel.value||"configured";sel.innerHTML=sttBackendOptionHtml(prev),sel.disabled=!_sttBackends.some(b=>b.available)}),updateSttBackendHelp(),typeof window.updateStatusBar=="function"&&window.updateStatusBar(),typeof window.refreshStatusBarEngines=="function"&&window.refreshStatusBarEngines()}function sttTtsSelectedBackend(){var _a2;return((_a2=$("stt-tts-backend-select"))==null?void 0:_a2.value)||""}function sttTtsDownload(blob,name){if(!blob)return;const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=name,a.click()}async function sttTtsUploadFile(file){if(!file)return;$("stt-tts-source-status").textContent="Uploading "+file.name+"...",sttTtsSourceId=null,sttTtsOutputBlob=null,$("stt-tts-transcribe-btn").disabled=!0,$("stt-tts-copy-preview-btn").disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;const fd=new FormData;fd.append("file",file);try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();sttTtsSourceId=d.id;const audio=$("stt-tts-source-audio");audio.src="/api/audio/"+encodeURIComponent(d.id),audio.style.display="",$("stt-tts-source-status").textContent=`${d.filename||file.name} loaded (${Number(d.duration||0).toFixed(1)} s).`,$("stt-tts-transcribe-btn").disabled=!1,toast("Speech audio loaded","success")}catch(e){$("stt-tts-source-status").textContent="Upload failed.",toast("STT source upload failed: "+e.message,"error")}}async function sttTtsFetchVoices(){const backend=sttTtsSelectedBackend();if(!backend)throw new Error("No available TTS backend");const rawVoices=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json());let voices=Array.isArray(rawVoices)?rawVoices:[];if(shouldFilterBackendVoices(backend)){const activeIds=await activeLibraryVoiceIds();voices=voices.filter(v=>activeIds.has(backendVoiceId(v)))}const sel=$("stt-tts-voice-select"),prev=sel.value;return sel.innerHTML='
',voices.forEach(v=>{const id=backendVoiceId(v),opt=document.createElement("option");opt.value=opt.textContent=id,sel.appendChild(opt)}),prev&&voices.some(v=>backendVoiceId(v)===prev)&&(sel.value=prev),voices.length}(_a=$("stt-tts-file"))==null||_a.addEventListener("change",async()=>{const input=$("stt-tts-file");input.files&&input.files.length&&await sttTtsUploadFile(input.files[0]),input.value=""}),(_b=$("stt-tts-refresh-stt-btn"))==null||_b.addEventListener("click",async()=>{const btn=$("stt-tts-refresh-stt-btn");btn.disabled=!0;try{await refreshSttBackends2(sttTtsSelectedSttBackend()),toast("STT engines refreshed","success")}finally{btn.disabled=!1}}),(_c=$("stt-tts-stt-backend"))==null||_c.addEventListener("change",updateSttBackendHelp);function sttTtsSetRecording(on){$("stt-tts-rec-start").disabled=on,$("stt-tts-rec-stop").disabled=!on}function sttTtsStopTracks(){sttTtsRecordStream&&sttTtsRecordStream.getTracks().forEach(t=>t.stop()),sttTtsRecordStream=null}(_d=$("stt-tts-rec-start"))==null||_d.addEventListener("click",async()=>{try{sttTtsRecordStream=await requestMicrophoneStream(),sttTtsRecordChunks=[],sttTtsRecordSecs=0,$("stt-tts-rec-time").textContent="0:00",$("stt-tts-source-status").textContent="Recording...",sttTtsSetRecording(!0),sttTtsRecordTimer=setInterval(()=>{sttTtsRecordSecs++,$("stt-tts-rec-time").textContent=Math.floor(sttTtsRecordSecs/60)+":"+String(sttTtsRecordSecs%60).padStart(2,"0")},1e3),sttTtsRecorder=new MediaRecorder(sttTtsRecordStream,{audioBitsPerSecond:256e3}),sttTtsRecorder.ondataavailable=e=>{e.data.size&&sttTtsRecordChunks.push(e.data)},sttTtsRecorder.onstop=async()=>{clearInterval(sttTtsRecordTimer),sttTtsRecordTimer=null,sttTtsSetRecording(!1),sttTtsStopTracks();const mime=sttTtsRecorder.mimeType||"audio/webm",blob=new Blob(sttTtsRecordChunks,{type:mime}),ext=mime.includes("ogg")?".ogg":".webm";if(!blob.size){$("stt-tts-source-status").textContent="Recording was empty.",toast("Recording was empty","error");return}await sttTtsUploadFile(new File([blob],"stt-recording"+ext,{type:mime}))},sttTtsRecorder.start(100),toast("Recording started","success")}catch(e){sttTtsSetRecording(!1),sttTtsStopTracks();const message=await microphoneErrorMessage(e);$("stt-tts-source-status").textContent=message,toast(message,"error")}}),(_e=$("stt-tts-rec-stop"))==null||_e.addEventListener("click",()=>{sttTtsRecorder&&sttTtsRecorder.state!=="inactive"&&sttTtsRecorder.stop()}),(_f=$("stt-tts-transcribe-btn"))==null||_f.addEventListener("click",async()=>{if(!sttTtsSourceId){toast("Load speech audio first","error");return}const btn=$("stt-tts-transcribe-btn");btn.disabled=!0,$("stt-tts-source-status").textContent="Transcribing...";try{const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:sttTtsSourceId,backend:sttTtsSelectedSttBackend()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();$("stt-tts-text").value=d.text||"",$("stt-tts-copy-preview-btn").disabled=!(d.text||"").trim();const used=d.backend?" via "+d.backend:"";$("stt-tts-source-status").textContent="Transcription ready"+used+".",typeof updateRefineButtonState=="function"&&updateRefineButtonState(),toast("Transcription ready","success")}catch(e){$("stt-tts-source-status").textContent="Transcription failed.",toast("STT failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_g=$("stt-tts-copy-preview-btn"))==null||_g.addEventListener("click",()=>{const text=$("stt-tts-text").value.trim();text&&($("preview-text-area").value=text,switchTab("generation"),toast("Copied transcription to TTS Generation","success"))}),(_h=$("stt-tts-backend-select"))==null||_h.addEventListener("change",()=>{$("stt-tts-voice-select").innerHTML='
',sttTtsOutputBlob=null,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0,updateBackendHelp()}),(_i=$("stt-tts-fetch-voices-btn"))==null||_i.addEventListener("click",async()=>{const btn=$("stt-tts-fetch-voices-btn");btn.disabled=!0;try{const count=await sttTtsFetchVoices();toast("Fetched "+count+" voices","success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_j=$("stt-tts-generate-btn"))==null||_j.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend){toast("No available TTS backend","error");return}if(!voice){toast("Select a TTS voice","error");return}if(!text){toast("Transcribe or enter text first","error");return}const btn=$("stt-tts-generate-btn");btn.disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;try{const source=await createTtsAudioSource(voice,text,backend,$("stt-tts-playback-mode").value,instruct);sttTtsOutputBlob=source.blob;const audio=$("stt-tts-output-audio");audio.src=source.url,audio.style.display="",await audio.play(),$("stt-tts-save-mp3-btn").disabled=!1,$("stt-tts-save-wav-btn").disabled=source.streaming,toast(source.streaming?"Streaming synthesized speech":"Synthesized speech ready","success")}catch(e){toast("TTS failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_k=$("stt-tts-save-mp3-btn"))==null||_k.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend||!voice||!text)return;const btn=$("stt-tts-save-mp3-btn");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(voice,text,"mp3",instruct,backend);sttTtsDownload(blob,(voice||"stt_tts")+"_stt_tts.mp3"),toast("MP3 saved","success")}catch(e){toast("MP3 save failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_l=$("stt-tts-save-wav-btn"))==null||_l.addEventListener("click",()=>{sttTtsOutputBlob&&sttTtsDownload(sttTtsOutputBlob,($("stt-tts-voice-select").value||"stt_tts")+"_stt_tts.wav")}),Object.assign(root,{sttTtsSourceId,sttTtsOutputBlob,_sttBackends,sttTtsRecorder,sttTtsRecordStream,sttTtsRecordChunks,sttTtsRecordTimer,sttTtsRecordSecs,sttTtsSelectedSttBackend,sttBackendOptionHtml,updateSttBackendHelp,refreshSttBackends:refreshSttBackends2,sttTtsSelectedBackend,sttTtsDownload,sttTtsUploadFile,sttTtsFetchVoices,sttTtsSetRecording,sttTtsStopTracks}),Object.defineProperty(root,"_sttBackends",{configurable:!0,get:()=>_sttBackends,set:v=>{_sttBackends=v}}),root.TTVC=root.TTVC||{},root.TTVC.stt=root.TTVC.stt||{},Object.assign(root.TTVC.stt,{get backends(){return _sttBackends},refresh:refreshSttBackends2,fetchVoices:sttTtsFetchVoices,upload:sttTtsUploadFile})}(window),function(){const root=window.TTVC=window.TTVC||{};root.audiobook=root.audiobook||{},root.rehearser=root.rehearser||{},root.jobs=root.jobs||{},[["audiobook","fetchWithTimeout","audiobookFetchWithTimeout"],["audiobook","attributeStream","audiobookAttributeStream"],["audiobook","timeoutSeconds","audiobookTimeoutSeconds"],["rehearser","renderLibrary","renderLibraryList"],["rehearser","showPhase","showPhase"]].forEach(([namespace,publicName,legacyName])=>{typeof window[legacyName]!="undefined"&&root[namespace][publicName]==null&&(root[namespace][publicName]=window[legacyName])}),typeof rehState!="undefined"&&root.rehearser.state==null&&(root.rehearser.state=rehState)}();
+`);buf=lines.pop();for(const line of lines){if(!line.startsWith("data:"))continue;let evt;try{evt=JSON.parse(line.slice(5).trim())}catch{continue}if(evt.type==="transcript"&&userBubble&&(userBubble.textContent=evt.text||"(empty)"),evt.type==="token"&&(assistantText+=evt.delta||"",assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="llm_done"&&(assistantText=evt.text||assistantText,assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="audio"&&evt.b64){const bytes=Uint8Array.from(atob(evt.b64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:evt.mime||"audio/wav"})),audioEl=document.createElement("audio");audioEl.controls=!0,audioEl.src=url,audioEl.addEventListener("ended",()=>URL.revokeObjectURL(url),{once:!0}),(_j=q("bench-turn-log"))==null||_j.appendChild(audioEl)}if(evt.type==="stats"&&(lastStats=evt,updateTurnStats(evt)),evt.type==="error")throw new Error(`[${evt.stage||"turn"}] ${evt.message||"Unknown error"}`)}}const total=(_k=lastStats==null?void 0:lastStats.total_ms)!=null?_k:Date.now()-t0;addTurnHistory(total,!0),st&&(st.textContent=`Finished in ${fmtMs(total)}.`),say("Turn benchmark complete","success")}catch(e){assistantBubble&&(assistantBubble.textContent=e.message),addTurnHistory(Date.now()-t0,!1),st&&(st.textContent="Turn benchmark failed"),say("Turn benchmark failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function bindBenchmarkSection(){var _a,_b,_c,_d,_e,_f,_g,_h,_i;initialized||!q("bench-stt-run")||(initialized=!0,document.querySelectorAll(".bench-tab").forEach(btn=>btn.addEventListener("click",()=>setBenchTab(btn.dataset.benchTab))),window.BenchmarkVoicePicker&&(BenchmarkVoicePicker.upgrade("bench-stt-library-voice",{placeholder:"-- choose from voice library --",empty:"No library voices with reference transcripts found"}),BenchmarkVoicePicker.upgrade("perf-voice-select",{placeholder:"-- select after fetch --",empty:"No voices"}),BenchmarkVoicePicker.upgrade("bench-turn-voice",{placeholder:"Fetch voices",empty:"No voices"})),document.addEventListener("click",()=>closeBenchmarkModelPickers()),(_a=q("bench-stt-refresh"))==null||_a.addEventListener("click",loadBenchmarkSttEngines),(_b=q("bench-stt-load-voice"))==null||_b.addEventListener("click",useBenchmarkLibraryVoice),(_c=q("bench-stt-audio"))==null||_c.addEventListener("change",()=>{q("bench-stt-source-id")&&(q("bench-stt-source-id").value="")}),(_d=q("bench-stt-run"))==null||_d.addEventListener("click",runSttBenchmark),(_e=q("bench-tts-run"))==null||_e.addEventListener("click",runTtsBenchmark),(_f=q("bench-turn-fetch-llm"))==null||_f.addEventListener("click",fetchTurnModels),(_g=q("bench-turn-fetch-voices"))==null||_g.addEventListener("click",fetchTurnVoices),(_h=q("bench-turn-run"))==null||_h.addEventListener("click",runTurnBenchmark),(_i=q("bench-turn-tts-backend"))==null||_i.addEventListener("change",()=>{window.BenchmarkVoicePicker?BenchmarkVoicePicker.populate("bench-turn-voice",[],{placeholder:"Fetch voices",empty:"No voices"}):q("bench-turn-voice")&&(q("bench-turn-voice").innerHTML='
')}))}function loadBenchmarkSectionData(){bindBenchmarkSection(),!(benchmarkDataLoaded||!q("bench-stt-run"))&&(benchmarkDataLoaded=!0,loadBenchmarkSttEngines(),loadBenchmarkVoiceLibrary(),initTurnControls())}window.loadBenchmarkSectionData=loadBenchmarkSectionData,bindBenchmarkSection(),!initialized&&document.body&&new MutationObserver(()=>bindBenchmarkSection()).observe(document.body,{childList:!0,subtree:!0})}(),function(root){var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l;let sttTtsSourceId=null,sttTtsOutputBlob=null,_sttBackends=[],sttTtsRecorder=null,sttTtsRecordStream=null,sttTtsRecordChunks=[],sttTtsRecordTimer=null,sttTtsRecordSecs=0;function sttTtsSelectedSttBackend(){var _a2;return((_a2=$("stt-tts-stt-backend"))==null?void 0:_a2.value)||"configured"}function sttBackendOptionHtml(selected="configured"){var _a2;const choices=sttBackendChoices(_sttBackends,selected);if(!choices.length)return'
';const preferred=choices.some(b=>b.id===selected&&b.available)?selected:((_a2=choices.find(b=>b.available))==null?void 0:_a2.id)||selected;return choices.map(b=>{const suffix=b.available?"":" (unavailable)",disabled=b.available?"":" disabled";return`
`}).join("")}function updateSttBackendHelp(){const selected=sttTtsSelectedSttBackend(),b=_sttBackends.find(item=>item.id===selected)||_sttBackends.find(item=>item.available)||null,help=$("stt-tts-stt-help");if(help){if(!b){help.textContent="No STT engine status loaded yet.";return}help.innerHTML=sttBackendHelpHtml(b)}}async function refreshSttBackends2(selected=""){try{_sttBackends=((await fetch("/api/stt-backends").then(r=>r.json())).backends||[]).filter(b=>b&&b.id).map(b=>b.connected===!1?{...b,available:!1}:b)}catch{_sttBackends=[]}["conv-stt-select","stt-tts-stt-backend","clone-stt-backend"].forEach(id=>{const sel=$(id);if(!sel)return;const prev=selected||typeof _appSettings!="undefined"&&(_appSettings==null?void 0:_appSettings.stt_preferred_backend)||sel.value||"configured";sel.innerHTML=sttBackendOptionHtml(prev),sel.disabled=!_sttBackends.some(b=>b.available)}),updateSttBackendHelp(),typeof window.updateStatusBar=="function"&&window.updateStatusBar(),typeof window.refreshStatusBarEngines=="function"&&window.refreshStatusBarEngines()}function sttTtsSelectedBackend(){var _a2;return((_a2=$("stt-tts-backend-select"))==null?void 0:_a2.value)||""}function sttTtsDownload(blob,name){if(!blob)return;const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=name,a.click()}async function sttTtsUploadFile(file){if(!file)return;$("stt-tts-source-status").textContent="Uploading "+file.name+"...",sttTtsSourceId=null,sttTtsOutputBlob=null,$("stt-tts-transcribe-btn").disabled=!0,$("stt-tts-copy-preview-btn").disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;const fd=new FormData;fd.append("file",file);try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();sttTtsSourceId=d.id;const audio=$("stt-tts-source-audio");audio.src="/api/audio/"+encodeURIComponent(d.id),audio.style.display="",$("stt-tts-source-status").textContent=`${d.filename||file.name} loaded (${Number(d.duration||0).toFixed(1)} s).`,$("stt-tts-transcribe-btn").disabled=!1,toast("Speech audio loaded","success")}catch(e){$("stt-tts-source-status").textContent="Upload failed.",toast("STT source upload failed: "+e.message,"error")}}async function sttTtsFetchVoices(){const backend=sttTtsSelectedBackend();if(!backend)throw new Error("No available TTS backend");const rawVoices=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json());let voices=Array.isArray(rawVoices)?rawVoices:[];if(shouldFilterBackendVoices(backend)){const activeIds=await activeLibraryVoiceIds();voices=voices.filter(v=>activeIds.has(backendVoiceId(v)))}const sel=$("stt-tts-voice-select"),prev=sel.value;return sel.innerHTML='
',voices.forEach(v=>{const id=backendVoiceId(v),opt=document.createElement("option");opt.value=opt.textContent=id,sel.appendChild(opt)}),prev&&voices.some(v=>backendVoiceId(v)===prev)&&(sel.value=prev),voices.length}(_a=$("stt-tts-file"))==null||_a.addEventListener("change",async()=>{const input=$("stt-tts-file");input.files&&input.files.length&&await sttTtsUploadFile(input.files[0]),input.value=""}),(_b=$("stt-tts-refresh-stt-btn"))==null||_b.addEventListener("click",async()=>{const btn=$("stt-tts-refresh-stt-btn");btn.disabled=!0;try{await refreshSttBackends2(sttTtsSelectedSttBackend()),toast("STT engines refreshed","success")}finally{btn.disabled=!1}}),(_c=$("stt-tts-stt-backend"))==null||_c.addEventListener("change",updateSttBackendHelp);function sttTtsSetRecording(on){$("stt-tts-rec-start").disabled=on,$("stt-tts-rec-stop").disabled=!on}function sttTtsStopTracks(){sttTtsRecordStream&&sttTtsRecordStream.getTracks().forEach(t=>t.stop()),sttTtsRecordStream=null}(_d=$("stt-tts-rec-start"))==null||_d.addEventListener("click",async()=>{try{sttTtsRecordStream=await requestMicrophoneStream(),sttTtsRecordChunks=[],sttTtsRecordSecs=0,$("stt-tts-rec-time").textContent="0:00",$("stt-tts-source-status").textContent="Recording...",sttTtsSetRecording(!0),sttTtsRecordTimer=setInterval(()=>{sttTtsRecordSecs++,$("stt-tts-rec-time").textContent=Math.floor(sttTtsRecordSecs/60)+":"+String(sttTtsRecordSecs%60).padStart(2,"0")},1e3),sttTtsRecorder=new MediaRecorder(sttTtsRecordStream,{audioBitsPerSecond:256e3}),sttTtsRecorder.ondataavailable=e=>{e.data.size&&sttTtsRecordChunks.push(e.data)},sttTtsRecorder.onstop=async()=>{clearInterval(sttTtsRecordTimer),sttTtsRecordTimer=null,sttTtsSetRecording(!1),sttTtsStopTracks();const mime=sttTtsRecorder.mimeType||"audio/webm",blob=new Blob(sttTtsRecordChunks,{type:mime}),ext=mime.includes("ogg")?".ogg":".webm";if(!blob.size){$("stt-tts-source-status").textContent="Recording was empty.",toast("Recording was empty","error");return}await sttTtsUploadFile(new File([blob],"stt-recording"+ext,{type:mime}))},sttTtsRecorder.start(100),toast("Recording started","success")}catch(e){sttTtsSetRecording(!1),sttTtsStopTracks();const message=await microphoneErrorMessage(e);$("stt-tts-source-status").textContent=message,toast(message,"error")}}),(_e=$("stt-tts-rec-stop"))==null||_e.addEventListener("click",()=>{sttTtsRecorder&&sttTtsRecorder.state!=="inactive"&&sttTtsRecorder.stop()}),(_f=$("stt-tts-transcribe-btn"))==null||_f.addEventListener("click",async()=>{if(!sttTtsSourceId){toast("Load speech audio first","error");return}const btn=$("stt-tts-transcribe-btn");btn.disabled=!0,$("stt-tts-source-status").textContent="Transcribing...";try{const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:sttTtsSourceId,backend:sttTtsSelectedSttBackend()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();$("stt-tts-text").value=d.text||"",$("stt-tts-copy-preview-btn").disabled=!(d.text||"").trim();const used=d.backend?" via "+d.backend:"";$("stt-tts-source-status").textContent="Transcription ready"+used+".",typeof updateRefineButtonState=="function"&&updateRefineButtonState(),toast("Transcription ready","success")}catch(e){$("stt-tts-source-status").textContent="Transcription failed.",toast("STT failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_g=$("stt-tts-copy-preview-btn"))==null||_g.addEventListener("click",()=>{const text=$("stt-tts-text").value.trim();text&&($("preview-text-area").value=text,switchTab("generation"),toast("Copied transcription to TTS Generation","success"))}),(_h=$("stt-tts-backend-select"))==null||_h.addEventListener("change",()=>{$("stt-tts-voice-select").innerHTML='
',sttTtsOutputBlob=null,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0,updateBackendHelp()}),(_i=$("stt-tts-fetch-voices-btn"))==null||_i.addEventListener("click",async()=>{const btn=$("stt-tts-fetch-voices-btn");btn.disabled=!0;try{const count=await sttTtsFetchVoices();toast("Fetched "+count+" voices","success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_j=$("stt-tts-generate-btn"))==null||_j.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend){toast("No available TTS backend","error");return}if(!voice){toast("Select a TTS voice","error");return}if(!text){toast("Transcribe or enter text first","error");return}const btn=$("stt-tts-generate-btn");btn.disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;try{const source=await createTtsAudioSource(voice,text,backend,$("stt-tts-playback-mode").value,instruct);sttTtsOutputBlob=source.blob;const audio=$("stt-tts-output-audio");audio.src=source.url,audio.style.display="",await audio.play(),$("stt-tts-save-mp3-btn").disabled=!1,$("stt-tts-save-wav-btn").disabled=source.streaming,toast(source.streaming?"Streaming synthesized speech":"Synthesized speech ready","success")}catch(e){toast("TTS failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_k=$("stt-tts-save-mp3-btn"))==null||_k.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend||!voice||!text)return;const btn=$("stt-tts-save-mp3-btn");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(voice,text,"mp3",instruct,backend);sttTtsDownload(blob,(voice||"stt_tts")+"_stt_tts.mp3"),toast("MP3 saved","success")}catch(e){toast("MP3 save failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_l=$("stt-tts-save-wav-btn"))==null||_l.addEventListener("click",()=>{sttTtsOutputBlob&&sttTtsDownload(sttTtsOutputBlob,($("stt-tts-voice-select").value||"stt_tts")+"_stt_tts.wav")}),Object.assign(root,{sttTtsSourceId,sttTtsOutputBlob,_sttBackends,sttTtsRecorder,sttTtsRecordStream,sttTtsRecordChunks,sttTtsRecordTimer,sttTtsRecordSecs,sttTtsSelectedSttBackend,sttBackendOptionHtml,updateSttBackendHelp,refreshSttBackends:refreshSttBackends2,sttTtsSelectedBackend,sttTtsDownload,sttTtsUploadFile,sttTtsFetchVoices,sttTtsSetRecording,sttTtsStopTracks}),Object.defineProperty(root,"_sttBackends",{configurable:!0,get:()=>_sttBackends,set:v=>{_sttBackends=v}}),root.TTVC=root.TTVC||{},root.TTVC.stt=root.TTVC.stt||{},Object.assign(root.TTVC.stt,{get backends(){return _sttBackends},refresh:refreshSttBackends2,fetchVoices:sttTtsFetchVoices,upload:sttTtsUploadFile})}(window),function(){const root=window.TTVC=window.TTVC||{};root.audiobook=root.audiobook||{},root.rehearser=root.rehearser||{},root.jobs=root.jobs||{},[["audiobook","fetchWithTimeout","audiobookFetchWithTimeout"],["audiobook","attributeStream","audiobookAttributeStream"],["audiobook","timeoutSeconds","audiobookTimeoutSeconds"],["rehearser","renderLibrary","renderLibraryList"],["rehearser","showPhase","showPhase"]].forEach(([namespace,publicName,legacyName])=>{typeof window[legacyName]!="undefined"&&root[namespace][publicName]==null&&(root[namespace][publicName]=window[legacyName])}),typeof rehState!="undefined"&&root.rehearser.state==null&&(root.rehearser.state=rehState)}();
diff --git a/static/js/ai-backends.js b/static/js/ai-backends.js
index 504b435..7b7267b 100644
--- a/static/js/ai-backends.js
+++ b/static/js/ai-backends.js
@@ -70,20 +70,12 @@
card?.classList.add('llm-local-card-online');
}
btn.addEventListener('click', async () => {
- if (btn.classList.contains('ok')) {
- btn.innerHTML = '
Connect';
- btn.className = 'llm-cloud-connect-btn llm-local-ping';
- card?.classList.remove('llm-local-card-online');
- localStorage.removeItem('llm-cloud-con-' + key);
- return;
- }
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || '';
- if (!apiKey) { toast('Enter an API key first', 'error'); return; }
btn.disabled = true;
btn.innerHTML = '
Connecting…';
try {
- const d = await probeUrl(endpoint, probeType, apiKey);
+ const d = await probeUrl(endpoint, probeType, apiKey, key);
if (d.ok) {
btn.innerHTML = '
Connected';
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
@@ -114,7 +106,6 @@
const card = btn.closest('.llm-card');
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || '';
- if (!apiKey) { toast('Enter an API key first', 'error'); return; }
if (btn.dataset.llmIncompatible && typeof confirmDialog === 'function') {
const proceed = await confirmDialog(
"Anthropic doesn't speak the OpenAI-compatible /chat/completions format every LLM call in this app uses — setting it active will very likely break generation until Anthropic support is added. Set it anyway?",
@@ -122,13 +113,20 @@
);
if (!proceed) return;
}
- const patch = { llm_url: url, llm_api_key: apiKey };
- if (model) patch.llm_model = model;
- await applyAndSaveSettings(patch);
- document.querySelectorAll('.llm-cloud-use-btn.active').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- window.syncActiveLlmPanel?.(url, apiKey);
- toast(`✓ Active LLM set to ${card?.querySelector('.llm-card-name')?.textContent || key}`, 'success');
+ try {
+ const headers = { 'Content-Type': 'application/json' };
+ if (apiKey) headers['X-Provider-API-Key'] = apiKey;
+ const response = await fetch(`/api/local-containers/${encodeURIComponent(key)}/use-as-llm`, {
+ method: 'POST', headers, body: JSON.stringify({ url, model: card?.querySelector('.cloud-model-select')?.value || model }),
+ });
+ const result = await response.json();
+ if (!response.ok || !result.ok) { toast(result.error || 'Could not activate LLM', 'error'); return; }
+ Object.assign(_appSettings || (_appSettings = {}), await fetch('/api/settings').then(r => r.json()));
+ document.querySelectorAll('.llm-cloud-use-btn.active').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ window.syncActiveLlmPanel?.(url, apiKey);
+ toast(`✓ Active LLM set to ${card?.querySelector('.llm-card-name')?.textContent || key}`, 'success');
+ } catch (e) { toast('Could not activate LLM: ' + e.message, 'error'); }
});
});
@@ -192,7 +190,7 @@
const type = cardType(card);
const apiKey = typeof window.engineApiKeyValue === 'function'
? window.engineApiKeyValue(key) : '';
- const d = await probeUrl(rawUrl, type, apiKey);
+ const d = await probeUrl(rawUrl, type, apiKey, key);
applyCardState(card, key, d.ok, !d.ok);
if (d.ok) {
toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success');
@@ -230,6 +228,109 @@
window.applyAndSaveSettings = applyAndSaveSettings;
window.probeUrl = probeUrl;
+ async function fetchEngineModels(url, type, apiKey = '', name = '') {
+ const params = new URLSearchParams({ url, type, name });
+ return fetch('/api/engine-models?' + params, {
+ headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {},
+ }).then(r => r.json());
+ }
+
+ document.querySelectorAll('.llm-card').forEach(card => {
+ const keyInp = card.querySelector('.llm-input[data-llm-key]');
+ if (!keyInp) return;
+ const key = keyInp.dataset.llmKey;
+ const useBtn = card.querySelector('.llm-cloud-use-btn');
+ const groqStt = key === 'groq_stt';
+ if (!useBtn && !groqStt) {
+ if (['hf_stt', 'assemblyai', 'fish_audio'].includes(key)) {
+ const note = document.createElement('div');
+ note.className = 'llm-info-note';
+ note.textContent = 'Provider-native transcription or speech API; connection testing and model discovery are not integrated.';
+ card.appendChild(note);
+ }
+ return;
+ }
+ const url = useBtn?.dataset.llmEndpoint || 'https://api.groq.com/openai/v1';
+ const type = groqStt ? 'stt' : useBtn.dataset.llmProbeType || (useBtn.dataset.llmIncompatible ? 'anthropic' : 'llm');
+ const controls = document.createElement('div');
+ controls.className = 'llm-field-row';
+ controls.innerHTML = '
';
+ const select = controls.querySelector('select');
+ const loadBtn = controls.querySelector('button');
+ const wanted = _appSettings?.engine_models?.[key] || useBtn?.dataset.llmModel || '';
+ if (wanted) select.innerHTML = `
`;
+ loadBtn.addEventListener('click', async () => {
+ loadBtn.disabled = true;
+ try {
+ const d = await fetchEngineModels(url, type, keyInp.value.trim(), key);
+ if (!d.ok) { toast(d.error || 'Could not load models', 'error'); return; }
+ const old = select.value;
+ const models = d.models || [];
+ select.innerHTML = '
' + models.map(m => `
`).join('');
+ if (models.includes(old)) select.value = old;
+ } catch (e) { toast('Could not load models: ' + e.message, 'error'); }
+ finally { loadBtn.disabled = false; }
+ });
+ select.addEventListener('change', () => applyAndSaveSettings({ engine_models: { ...(_appSettings?.engine_models || {}), [key]: select.value } }));
+ if (groqStt) {
+ const testBtn = document.createElement('button');
+ testBtn.type = 'button'; testBtn.className = 'llm-use-btn'; testBtn.textContent = 'Test connection';
+ testBtn.addEventListener('click', async () => {
+ testBtn.disabled = true;
+ try {
+ const d = await probeUrl(url, 'stt', keyInp.value.trim(), key);
+ toast(d.ok ? 'Service reachable' : d.error || 'Connection failed', d.ok ? 'success' : 'error');
+ } catch (e) { toast('Connection failed: ' + e.message, 'error'); }
+ finally { testBtn.disabled = false; }
+ });
+ const activateBtn = document.createElement('button');
+ activateBtn.type = 'button'; activateBtn.className = 'llm-use-btn'; activateBtn.textContent = 'Use as STT';
+ activateBtn.addEventListener('click', async () => {
+ const headers = { 'Content-Type': 'application/json' };
+ if (keyInp.value.trim()) headers['X-Provider-API-Key'] = keyInp.value.trim();
+ try {
+ const d = await fetch(`/api/local-containers/${key}/use-as-engine`, { method: 'POST', headers,
+ body: JSON.stringify({ url, role: 'stt', setting_key: 'whisper_url', model: select.value }) }).then(r => r.json());
+ if (d.ok) {
+ Object.assign(_appSettings || (_appSettings = {}), await fetch('/api/settings').then(r => r.json()));
+ $('s-whisper-url').value = url;
+ }
+ toast(d.ok ? 'Active STT updated' : d.error || 'Activation failed', d.ok ? 'success' : 'error');
+ } catch (e) { toast('Activation failed: ' + e.message, 'error'); }
+ });
+ controls.append(testBtn, activateBtn);
+ }
+ card.appendChild(controls);
+ });
+
+ ['s-stt-test-connection', 's-stt-load-models'].forEach(id => {
+ $(id)?.addEventListener('click', async () => {
+ const url = $('s-whisper-url').value.trim();
+ const apiKey = $('s-stt-api-key').value.trim();
+ const status = $('s-stt-connection-status');
+ const btn = $(id);
+ if (!url) { status.textContent = 'Enter an endpoint first'; return; }
+ btn.disabled = true;
+ status.textContent = 'Checking…';
+ try {
+ const d = id === 's-stt-load-models'
+ ? await fetchEngineModels(url, 'stt', apiKey)
+ : await probeUrl(url, 'stt', apiKey);
+ if (!d.ok) { status.textContent = d.error || 'Connection failed'; return; }
+ status.textContent = 'Service reachable';
+ if (id === 's-stt-load-models') {
+ const select = $('s-stt-model');
+ const wanted = select.value;
+ const models = d.models || [];
+ select.innerHTML = '
' + models.map(m => `
`).join('');
+ if (models.includes(wanted)) select.value = wanted;
+ status.textContent = models.length ? `${models.length} models loaded` : 'No models advertised';
+ }
+ } catch (e) { status.textContent = 'Connection failed: ' + e.message; }
+ finally { btn.disabled = false; }
+ });
+ });
+
$('llm-use-faster-whisper-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim()
|| 'http://host.docker.internal:8000';
@@ -315,6 +416,14 @@
};
}
+ function activeEngineDisconnected(settingKey, url, modelSel, setStatus) {
+ const identity = _appSettings?.engine_active?.[settingKey];
+ if (url !== _appSettings?.[settingKey] || _appSettings?.engine_disabled?.[identity] !== true) return false;
+ modelSel.innerHTML = '
';
+ setStatus('Disconnected', 'err');
+ return true;
+ }
+
// ── Active Language Model (global picker) ───────────────────────────────
(function initActiveLlmPanel() {
upgradeModelSelect('llm-active-model');
@@ -329,12 +438,13 @@
async function fetchModels(url, apiKey) {
url = (url || urlInp.value.trim());
if (!url) { modelSel.innerHTML = '
'; setStatus(''); return; }
+ if (activeEngineDisconnected('llm_url', url, modelSel, setStatus)) return;
const want = (_appSettings && _appSettings.llm_model) || '';
setStatus('Loading models…');
if (refreshBtn) refreshBtn.disabled = true;
try {
- const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
- const d = await fetch(fetchUrl).then(r => r.json());
+ const d = await fetchEngineModels(url, 'llm', apiKey);
+ if (activeEngineDisconnected('llm_url', url, modelSel, setStatus)) return;
if (d.error) {
let msg = 'Failed';
try {
@@ -468,11 +578,13 @@
async function fetchModels(url) {
url = (url || urlInp.value.trim());
if (!url) { modelSel.innerHTML = '
'; setStatus(''); return; }
+ if (activeEngineDisconnected('vision_ocr_url', url, modelSel, setStatus)) return;
const want = (_appSettings && _appSettings.vision_ocr_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 d = await fetchEngineModels(url, 'ocr');
+ if (activeEngineDisconnected('vision_ocr_url', url, modelSel, setStatus)) return;
if (d.error) {
let msg = d.error;
try { const j = JSON.parse(d.error); msg = j.error?.message || j.detail || d.error; } catch (_) {}
diff --git a/static/js/conversation.js b/static/js/conversation.js
index 17e94c5..00c3fd4 100644
--- a/static/js/conversation.js
+++ b/static/js/conversation.js
@@ -335,17 +335,17 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// ── Populate STT backends ────────────────────────────────────────────────
async function loadConvSttBackends() {
if (!sttSel) return;
- const prev = sttSel.value;
+ const prev = _appSettings?.stt_preferred_backend || sttSel.value;
try {
const d = await fetch('/api/stt-backends').then(r => r.json());
- const all = d.backends || [];
+ const all = sttBackendChoices(d.backends || [], prev);
if (!all.length) {
sttSel.innerHTML = '
';
return;
}
sttSel.innerHTML = all.map(b => {
const icon = b.available ? '✓' : '✗';
- const label = `${icon} ${escHtml(b.label)}`;
+ const label = `${icon} ${escHtml(b.title || b.label || b.id)}`;
const disabled = !b.available;
return `
`;
}).join('');
diff --git a/static/js/engines.js b/static/js/engines.js
index 4d466e7..f5f78da 100644
--- a/static/js/engines.js
+++ b/static/js/engines.js
@@ -253,6 +253,7 @@ function loadCustomEngineCards() {
return merged;
}
function saveCustomEngineCards(cards) {
+ cards = cards.map(({ apiKey, api_key, ...card }) => card);
localStorage.setItem('engines-custom-cards', JSON.stringify(cards));
if (_appSettings) _appSettings.custom_engine_cards = cards;
_patchSettings({ custom_engine_cards: cards });
@@ -288,26 +289,107 @@ const DC_ROLE_USE = {
ocr: { settingKey: 'vision_ocr_url', label: 'Use as OCR' },
};
+async function activateEngine(id, role, settingKey, url, model, apiKey = '') {
+ const headers = { 'Content-Type': 'application/json' };
+ if (apiKey) headers['X-Provider-API-Key'] = apiKey;
+ const body = role === 'llm' ? { url, model } : { url, model, role, setting_key: settingKey, activate: true };
+ const response = await fetch(`/api/local-containers/${encodeURIComponent(id)}/use-as-${role === 'llm' ? 'llm' : 'engine'}`, {
+ method: 'POST', headers, body: JSON.stringify(body),
+ });
+ const result = await response.json();
+ if (!response.ok || !result.ok) throw new Error(result.error || 'Activation failed');
+ _appSettings[settingKey] = url;
+ try { Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json())); } catch (_) { /* Activation is already saved. */ }
+ try {
+ if (role === 'tts') {
+ const custom = (_appSettings.custom_engine_cards || []).some(card => String(card.id || card.name) === id);
+ await window.refreshTtsBackendAvailability?.(custom ? 'custom:' + id : '');
+ }
+ if (role === 'stt') {
+ await window.refreshSttBackends?.(_appSettings.stt_preferred_backend || '');
+ window.syncPreferredSttBackend?.(_appSettings);
+ }
+ } catch (_) { /* Optional picker refresh does not undo saved activation. */ }
+ if (role === 'llm') window.syncActiveLlmPanel?.(url);
+ if (role === 'ocr') window.syncActiveOcrPanel?.(url);
+ try { await window.refreshStatusBarEngines?.({ force: true }); } catch (_) { /* status bar catches up on its next poll */ }
+}
+
+async function setEngineConnection(id, connected, role) {
+ const response = await fetch(`/api/local-containers/${encodeURIComponent(id)}/connection`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connected }),
+ });
+ const result = await response.json();
+ if (!response.ok || !result.ok) throw new Error(result.error || 'Connection change failed');
+ const disabled = { ...(_appSettings?.engine_disabled || {}) };
+ if (connected) delete disabled[id]; else disabled[id] = true;
+ _appSettings.engine_disabled = disabled;
+ try {
+ Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json()));
+ await window.refreshTtsBackendAvailability?.();
+ if (role === 'stt') {
+ await window.refreshSttBackends?.();
+ window.syncPreferredSttBackend?.(_appSettings);
+ }
+ if (role === 'llm') window.syncActiveLlmPanel?.(_appSettings.llm_url || '');
+ if (role === 'ocr') window.syncActiveOcrPanel?.(_appSettings.vision_ocr_url || '');
+ await window.refreshStatusBarEngines?.({ force: true });
+ } catch (_) { /* The connection change is already saved. */ }
+}
+
+let _inlineEngineCard = null;
+
+function closeEngineEditor() {
+ const dlg = $('add-engine-dlg');
+ if (_inlineEngineCard) {
+ dlg.appendChild(_inlineEngineCard.querySelector('.add-engine-form'));
+ _inlineEngineCard.classList.remove('dc-editing');
+ const editButton = _inlineEngineCard.querySelector('.dc-edit-btn');
+ _inlineEngineCard = null;
+ editButton?.focus();
+ }
+ dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
+ delete dlg.dataset.editId;
+ $('aed-api-key').value = '';
+ dlg.close();
+}
+
window.openAddEngineDialog = function(role) {
+ if ($('aed-save')?.disabled) return;
const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return;
- delete dlg.dataset.editId;
+ closeEngineEditor();
+ dlg.dataset.cardId = 'custom-' + (crypto.randomUUID?.() || Date.now());
+ dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
+ delete dlg.dataset.probeName;
const titleEl = dlg.querySelector('.add-engine-title');
if (titleEl) titleEl.textContent = ' Add Custom Engine';
const saveBtn = document.getElementById('aed-save');
if (saveBtn) saveBtn.textContent = 'Add Card';
const roleEl = document.getElementById('aed-role');
if (roleEl && role) roleEl.value = role;
- ['aed-name', 'aed-url', 'aed-container', 'aed-desc'].forEach(id => {
+ ['aed-name', 'aed-url', 'aed-container', 'aed-desc', 'aed-api-key', 'aed-model'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
+ $('aed-api-key').placeholder = 'API key (optional)';
+ $('aed-models').innerHTML = '';
+ $('aed-status').textContent = '';
+ $('aed-params').value = '{}';
+ $('aed-active').checked = false;
dlg.showModal();
};
function editCustomEngineCard(card) {
+ if ($('aed-save')?.disabled) return;
const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return;
+ closeEngineEditor();
+ const cardElement = Array.from(document.querySelectorAll('.dc-edit-btn')).find(button => button.dataset.dcCustomId === String(card.id || card.name))?.closest('.llm-local-card');
+ if (!cardElement) return;
dlg.dataset.editId = String(card.id || card.name);
+ dlg.dataset.cardId = dlg.dataset.editId;
+ dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
+ dlg.dataset.probeName = _appSettings?.engine_api_keys?.[dlg.dataset.cardId] ? dlg.dataset.cardId : card.name;
const titleEl = dlg.querySelector('.add-engine-title');
if (titleEl) titleEl.textContent = ' Edit Engine';
const saveBtn = document.getElementById('aed-save');
@@ -316,18 +398,76 @@ function editCustomEngineCard(card) {
if (roleEl) roleEl.value = card.role || 'tts';
const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; };
setVal('aed-name', card.label || card.name);
- setVal('aed-url', card.url);
- setVal('aed-container', card.containerName);
+ setVal('aed-url', cardElement.querySelector('.dc-url-inp')?.value || card.url);
+ setVal('aed-container', cardElement.querySelector('.dc-container-name-inp')?.value ?? card.containerName);
setVal('aed-desc', card.description);
- dlg.showModal();
+ setVal('aed-api-key', '');
+ setVal('aed-model', _appSettings?.engine_models?.[dlg.dataset.cardId] || _appSettings?.engine_models?.[card.name]);
+ $('aed-api-key').placeholder = _appSettings?.engine_api_keys?.[dlg.dataset.cardId] || _appSettings?.engine_api_keys?.[card.name]
+ ? 'Configured — enter a new key to replace' : 'API key (optional)';
+ $('aed-models').innerHTML = '';
+ $('aed-status').textContent = '';
+ $('aed-params').value = JSON.stringify(card.params || {}, null, 2);
+ $('aed-active').checked = card.role === 'stt'
+ ? _appSettings?.stt_preferred_backend === 'custom:' + dlg.dataset.cardId
+ : _appSettings?.[DC_ROLE_USE[card.role]?.settingKey] === card.url && (card.role !== 'ocr' || !!_appSettings?.vision_ocr_enabled);
+ _inlineEngineCard = cardElement;
+ cardElement.classList.add('dc-editing');
+ cardElement.appendChild(dlg.querySelector('.add-engine-form'));
+ $('aed-name').focus();
}
(function initCustomEngineDialog() {
const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return;
- document.getElementById('aed-cancel')?.addEventListener('click', () => { delete dlg.dataset.editId; dlg.close(); });
- dlg.addEventListener('click', e => { if (e.target === dlg) { delete dlg.dataset.editId; dlg.close(); } });
- document.getElementById('aed-save')?.addEventListener('click', () => {
+ function readParams() {
+ let params;
+ try { params = JSON.parse($('aed-params').value.trim() || '{}'); }
+ catch (_) { throw new Error('Additional parameters must be valid JSON'); }
+ if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Additional parameters must be a JSON object');
+ const reserved = new Set(['model', 'messages', 'input', 'text', 'voice', 'stream', 'response_format', 'file', 'files', 'audio', 'audio_prompt', 'audio_prompt_transcript', 'references', 'url', 'base_url', 'endpoint', 'headers', 'method']);
+ if (Object.keys(params).some(key => reserved.has(key))) throw new Error('Additional parameters cannot replace connection, model, or request input fields');
+ function hasCredentials(value) {
+ return value && typeof value === 'object' && Object.entries(value).some(([key, child]) =>
+ /(apikeys?|authorization|password|passwd|secret|secretkey|accesskey|token|privatekey|credentials?|headers)$/.test(key.toLowerCase().replace(/[^a-z]/g, '')) || hasCredentials(child));
+ }
+ if (hasCredentials(params)) throw new Error('Keep credentials in the API key field, outside additional parameters');
+ return params;
+ }
+ document.getElementById('aed-cancel')?.addEventListener('click', () => { if (!$('aed-save').disabled) closeEngineEditor(); });
+ dlg.addEventListener('click', e => { if (e.target === dlg && !$('aed-save').disabled) closeEngineEditor(); });
+ dlg.addEventListener('cancel', e => { if ($('aed-save').disabled) e.preventDefault(); });
+ ['aed-url', 'aed-role', 'aed-api-key'].forEach(id => $(id)?.addEventListener('input', () => {
+ dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
+ $('aed-models').innerHTML = '';
+ $('aed-status').textContent = '';
+ }));
+ ['aed-test', 'aed-load-models'].forEach(id => $(id)?.addEventListener('click', async () => {
+ const requestId = dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
+ const url = $('aed-url').value.trim();
+ if (!url) { $('aed-status').textContent = 'URL is required'; return; }
+ try { readParams(); } catch (e) { $('aed-status').textContent = e.message; return; }
+ const apiKey = $('aed-api-key').value.trim();
+ $(id).disabled = true;
+ $('aed-status').textContent = 'Checking…';
+ try {
+ const route = id === 'aed-test' ? '/api/probe-url?' : '/api/engine-models?';
+ const response = await fetch(route + new URLSearchParams({ url, type: $('aed-role').value, name: dlg.dataset.probeName || dlg.dataset.cardId }), {
+ headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {},
+ });
+ const result = await response.json();
+ if (!(dlg.open || _inlineEngineCard) || dlg.dataset.requestId !== requestId) return;
+ if (!response.ok || !result.ok) { $('aed-status').textContent = result.error || 'Request failed'; return; }
+ $('aed-status').textContent = 'Service reachable';
+ if (id === 'aed-load-models') {
+ const models = result.models || [];
+ $('aed-models').innerHTML = models.map(model => `
`).join('');
+ $('aed-status').textContent = models.length ? `${models.length} models loaded — select or enter an ID` : 'No models advertised — enter an ID manually';
+ }
+ } catch (e) { if ((dlg.open || _inlineEngineCard) && dlg.dataset.requestId === requestId) $('aed-status').textContent = 'Request failed: ' + e.message; }
+ finally { $(id).disabled = false; }
+ }));
+ document.getElementById('aed-save')?.addEventListener('click', async () => {
const name = document.getElementById('aed-name')?.value.trim();
const role = document.getElementById('aed-role')?.value;
const url = document.getElementById('aed-url')?.value.trim();
@@ -335,27 +475,47 @@ function editCustomEngineCard(card) {
const desc = document.getElementById('aed-desc')?.value.trim() || '';
if (!name) { toast('Name is required', 'error'); return; }
if (!url) { toast('URL is required', 'error'); return; }
+ let params;
+ try { params = readParams(); } catch (e) { $('aed-status').textContent = e.message; return; }
const editId = dlg.dataset.editId;
- if (editId) {
- const cards = loadCustomEngineCards().map(c =>
+ const id = dlg.dataset.cardId || editId || 'custom-' + Date.now();
+ const cards = editId ? loadCustomEngineCards().map(c =>
String(c.id || c.name) === editId
- ? { ...c, name, label: name, role, url, containerName, description: desc }
+ ? { ...c, id, name, label: name, role, url, containerName, description: desc, params }
: c
- );
- saveCustomEngineCards(cards);
- delete dlg.dataset.editId;
- dlg.close();
+ ) : [...loadCustomEngineCards(), { id, name, label: name, role, url, containerName, description: desc, params, isCustom: true }];
+ const apiKey = $('aed-api-key').value.trim();
+ const model = $('aed-model').value.trim();
+ const useActive = $('aed-active').checked;
+ const metadata = cards.map(({ apiKey, api_key, ...card }) => card);
+ const patch = {
+ custom_engine_cards: metadata,
+ engine_models: { ...(_appSettings?.engine_models || {}), [id]: model },
+ engine_local_urls: { ...(_appSettings?.engine_local_urls || {}), ['dc-' + id]: url },
+ };
+ if (apiKey) patch.engine_api_keys = { [id]: apiKey };
+ $('aed-save').disabled = true;
+ $('aed-cancel').disabled = true;
+ try {
+ const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
+ if (!response.ok) throw new Error('Could not save engine');
+ localStorage.setItem('engines-custom-cards', JSON.stringify(metadata));
+ localStorage.setItem('dc-url-' + id, url);
+ Object.assign(_appSettings || (_appSettings = {}), { custom_engine_cards: metadata, engine_models: patch.engine_models, engine_local_urls: patch.engine_local_urls });
+ if (apiKey) _appSettings.engine_api_keys = { ...(_appSettings.engine_api_keys || {}), [id]: '__configured__' };
+ try { Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json())); } catch (_) { /* The save already succeeded. */ }
+ $('aed-api-key').value = '';
+ closeEngineEditor();
loadLocalContainers();
- toast(`"${name}" updated`, 'success');
- } else {
- const card = { id: 'custom-' + Date.now(), name, label: name, role, url, containerName, description: desc, isCustom: true };
- const cards = loadCustomEngineCards();
- cards.push(card);
- saveCustomEngineCards(cards);
- dlg.close();
- loadLocalContainers();
- toast(`"${name}" added`, 'success');
- }
+ toast(`"${name}" ${editId ? 'updated' : 'added'}`, 'success');
+ if (useActive) {
+ try {
+ await activateEngine(id, role, DC_ROLE_USE[role].settingKey, url, model, apiKey);
+ toast(`"${name}" is active`, 'success');
+ } catch (_) { toast(`"${name}" saved; activation failed. Use the card's activation button to retry.`, 'error'); }
+ }
+ } catch (e) { toast(e.message, 'error'); }
+ finally { $('aed-save').disabled = false; $('aed-cancel').disabled = false; }
});
})();
@@ -400,6 +560,7 @@ async function fetchLocalContainersWithRetry() {
}
async function loadLocalContainers() {
+ if (_inlineEngineCard) return;
const seq = ++_localContainersLoadSeq;
const gridTts = $('dc-grid-tts');
const gridStt = $('dc-grid-stt');
@@ -438,6 +599,8 @@ async function loadLocalContainers() {
window.loadLocalContainers = loadLocalContainers;
function renderLocalContainers(containers) {
+ if (_inlineEngineCard) return;
+ containers = containers.map(c => c.isCustom ? { ...c, legacyName: c.name, name: c.id || c.name, label: c.label || c.name } : c);
const gridTts = $('dc-grid-tts');
const gridStt = $('dc-grid-stt');
const gridLlm = $('dc-grid-llm');
@@ -524,6 +687,7 @@ function renderLocalContainers(containers) {
`;
@@ -541,27 +705,36 @@ function renderLocalContainers(containers) {
data-cn-key="${n}">
`;
- // API key row — only for LLMs
+ // Authentication is optional for every supported engine role.
const savedApiKey = isCustom
- ? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || c.apiKey || '')
+ ? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || '')
: ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name) : '') || '');
- const apiKeyRowHtml = (c.role === 'llm' || c.name === 'litellm') ? `
+ const apiKeyRowHtml = `
-
` : '';
+ `;
+ const savedModel = _appSettings?.engine_models?.[c.name] || _appSettings?.engine_models?.[c.legacyName] || '';
+ const modelRowHtml = `
+
+ Model
+
+
+
+
`;
// Use-as button — right-aligned, clearly labelled as the activation action
const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role];
+ const useLabel = c.role === 'tts' ? 'Use as TTS' : useEntry?.label;
const useBtn = useEntry
? ``
: '';
@@ -586,6 +759,7 @@ function renderLocalContainers(containers) {
const controlsRowHtml = `
+
${dockerBtnsSpan}${githubBtn}${editBtn}${deleteBtn}
${useBtn}
`;
@@ -622,7 +796,7 @@ function renderLocalContainers(containers) {
${escHtml(stLabel)}
${metricsHtml}
- ${c.description ? `${escHtml(c.description)}
` : ''}${urlRowHtml}${apiKeyRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
+ ${c.description ? `${escHtml(c.description)}
` : ''}${urlRowHtml}${apiKeyRowHtml}${modelRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
`;
}
@@ -673,32 +847,33 @@ function renderLocalContainers(containers) {
// Helpers — set connected / disconnected visual state on a card
function setConnected(btn, card, useBtn, connected) {
+ const disconnectBtn = card?.querySelector('.dc-disconnect-btn');
+ if (disconnectBtn) disconnectBtn.hidden = !connected;
if (connected) {
btn.innerHTML = ' Connected';
btn.className = 'llm-local-ping dc-connect-btn ok';
- btn.title = 'Reachable — click to disconnect';
+ btn.title = 'Reachable — click to test again';
if (card) card.classList.add('llm-local-card-online');
- if (useBtn) useBtn.classList.add('active');
} else {
btn.innerHTML = ' Connect';
btn.className = 'llm-local-ping dc-connect-btn';
btn.title = 'Test connection to this service';
if (card) card.classList.remove('llm-local-card-online');
- if (useBtn) useBtn.classList.remove('active');
}
}
// URL input — server settings first, localStorage fallback; write to both on change
grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => {
const key = inp.dataset.dcUrlKey;
- const fromSrv = _appSettings?.engine_local_urls?.['dc-' + key];
- const fromLS = localStorage.getItem('dc-url-' + key);
+ const legacy = inp.dataset.dcLegacyName;
+ const fromSrv = _appSettings?.engine_local_urls?.['dc-' + key] || _appSettings?.engine_local_urls?.['dc-' + legacy];
+ const fromLS = localStorage.getItem('dc-url-' + key) || localStorage.getItem('dc-url-' + legacy);
if (fromSrv || fromLS) inp.value = fromSrv || fromLS;
inp.addEventListener('input', () => {
localStorage.setItem('dc-url-' + key, inp.value);
if (window._saveEngineLocalUrls) window._saveEngineLocalUrls();
});
- if (localStorage.getItem('dc-con-' + key) === '1') {
+ if (_appSettings?.engine_disabled?.[key] !== true && localStorage.getItem('dc-con-' + key) === '1') {
const card = inp.closest('.llm-local-card');
const btn = card?.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`);
const useBtn = card?.querySelector('.dc-use-btn');
@@ -706,6 +881,50 @@ function renderLocalContainers(containers) {
}
});
+ grid.querySelectorAll('.dc-disconnect-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ btn.disabled = true;
+ try {
+ const key = btn.dataset.dcName;
+ const card = btn.closest('.llm-local-card');
+ await setEngineConnection(key, false, cardType(card));
+ localStorage.removeItem('dc-con-' + key);
+ const useBtn = card.querySelector('.dc-use-btn');
+ setConnected(card.querySelector('.dc-connect-btn'), card, useBtn, false);
+ useBtn?.classList.remove('active');
+ toast('Engine disconnected', 'success');
+ } catch (e) { toast('Disconnect failed: ' + e.message, 'error'); }
+ finally { btn.disabled = false; }
+ });
+ });
+
+ grid.querySelectorAll('.dc-models-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const card = btn.closest('.llm-local-card');
+ const inp = card.querySelector('.dc-url-inp');
+ const key = inp.dataset.dcUrlKey;
+ const url = inp.value.trim() || inp.placeholder;
+ const apiKey = card.querySelector('.dc-apikey-inp').value.trim();
+ if (!url) { toast('Enter a URL first', 'error'); return; }
+ btn.disabled = true;
+ try {
+ const params = new URLSearchParams({ url, type: cardType(card), name: key });
+ const d = await fetch('/api/engine-models?' + params, { headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {} }).then(r => r.json());
+ if (!d.ok) { toast(d.error || 'Could not load models', 'error'); return; }
+ const models = d.models || [];
+ card.querySelector('datalist').innerHTML = models.map(model => ``).join('');
+ toast(models.length ? `Loaded ${models.length} models` : 'No models advertised — enter an ID manually', 'success');
+ } catch (e) { toast('Failed to load models: ' + e.message, 'error'); }
+ finally { btn.disabled = false; }
+ });
+ });
+ grid.querySelectorAll('.dc-model-inp').forEach(inp => {
+ inp.addEventListener('change', async () => {
+ const key = inp.closest('.llm-local-card').querySelector('.dc-url-inp').dataset.dcUrlKey;
+ await window.applyAndSaveSettings({ engine_models: { ...(_appSettings?.engine_models || {}), [key]: inp.value.trim() } });
+ });
+ });
+
// API key input — save only to server-backed engine_api_keys
grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => {
const key = inp.dataset.dcApikeyKey;
@@ -725,15 +944,6 @@ function renderLocalContainers(containers) {
// Connect / Disconnect — probe URL, auto-apply setting, show state
grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => {
btn.addEventListener('click', async () => {
- // Disconnect if already connected
- if (btn.classList.contains('ok')) {
- const key = btn.dataset.dcUrlKey;
- const card = btn.closest('.llm-local-card');
- const useBtn = card?.querySelector('.dc-use-btn');
- setConnected(btn, card, useBtn, false);
- localStorage.removeItem('dc-con-' + key);
- return;
- }
const key = btn.dataset.dcUrlKey;
const card = btn.closest('.llm-local-card');
const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`);
@@ -749,18 +959,10 @@ function renderLocalContainers(containers) {
const type = card ? cardType(card) : '';
const d = await window.probeUrl(rawUrl, type, apiKey, key);
if (d.ok) {
+ await setEngineConnection(key, true, type);
setConnected(btn, card, useBtn, true);
localStorage.setItem('dc-con-' + key, '1');
- // Auto-apply URL to settings so it appears in dropdowns immediately.
- // Reuses the "Use as" button's own click handler (rather than
- // duplicating its logic) so LLM cards go through the same
- // credential-aware /use-as-llm endpoint Connect just verified.
- if (useBtn) {
- useBtn.click();
- toast(`✓ Connected and set as active ${useBtn.textContent.replace('Use as ', '').trim()}`, 'success');
- } else {
- toast('✓ Service reachable', 'success');
- }
+ toast('✓ Service reachable', 'success');
} else {
setConnected(btn, card, useBtn, false);
localStorage.removeItem('dc-con-' + key);
@@ -789,32 +991,17 @@ function renderLocalContainers(containers) {
const url = inp?.value.trim() || inp?.placeholder || '';
const apiKey = apikeyInp?.value.trim() || '';
if (!url) { toast('Enter a URL first', 'error'); return; }
- if (settingKey === 'llm_url') {
- // Goes through a dedicated endpoint (not applyAndSaveSettings) so the
- // backend can fall back to this card's own saved key — the browser
- // only has a real key in hand here if the user just typed one.
- try {
- const headers = { 'Content-Type': 'application/json' };
- if (apiKey) headers['X-Provider-API-Key'] = apiKey;
- const r = await fetch(`/api/local-containers/${encodeURIComponent(urlKey)}/use-as-llm`, {
- method: 'POST', headers, body: JSON.stringify({ url }),
- });
- const d = await r.json();
- if (!r.ok || !d.ok) { toast(d.error || 'Failed to set active LLM', 'error'); return; }
- if (_appSettings) { _appSettings.llm_url = url; if (d.key_set) _appSettings.llm_api_key = '__configured__'; }
- if (window.syncActiveLlmPanel) window.syncActiveLlmPanel(url, apiKey);
- btn.classList.add('active');
- toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
- } catch (e) { toast('Failed to set active LLM: ' + e.message, 'error'); }
- return;
- }
- if (window.applyAndSaveSettings) {
- const patch = { [settingKey]: url };
- await window.applyAndSaveSettings(patch);
- if (settingKey === 'vision_ocr_url' && window.syncActiveOcrPanel) window.syncActiveOcrPanel(url);
+
+ const role = cardType(card);
+ const model = card?.querySelector('.dc-model-inp')?.value.trim() || '';
+
+ try {
+ await activateEngine(urlKey, role, settingKey, url, model, apiKey);
btn.classList.add('active');
+ setConnected(card.querySelector('.dc-connect-btn'), card, btn, true);
+ localStorage.setItem('dc-con-' + urlKey, '1');
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
- }
+ } catch (e) { toast('Failed to set active engine: ' + e.message, 'error'); }
});
});
diff --git a/static/js/settings.js b/static/js/settings.js
index 1a7d9ec..5386c15 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -41,7 +41,7 @@ function ttsBackendOptions(selected = '') {
const backends = availableTtsBackends();
if (!backends.length) return '';
const current = selected || backends[0].id;
- return backends.map(b => ``).join('');
+ return backends.map(b => ``).join('');
}
function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) {
@@ -51,7 +51,7 @@ function styleBackendOptions(selected = 'customvoice', preferStyleAware = false)
: preferStyleAware
? (backends.find(b => b.style_aware)?.id || backends[0].id)
: backends[0].id;
- return backends.map(b => ``).join('');
+ return backends.map(b => ``).join('');
}
function backendById(id) {
@@ -178,7 +178,8 @@ function updateBackendDependentTabs() {
if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`;
});
const active = document.querySelector('.tab.active');
- if (!active || active.classList.contains('backend-unavailable')) {
+ // Sidebar pages own navigation; their hidden legacy tabs can retain a stale active tab.
+ if (!document.querySelector('.page-section.is-active') && (!active || active.classList.contains('backend-unavailable'))) {
const first = document.querySelector('.tab:not(.backend-unavailable)');
if (first) switchTab(first.dataset.tab);
}
@@ -507,12 +508,37 @@ function _populateImageGenModelSelect(selectEl, provider, savedModel) {
}
}
+window.syncPreferredSttBackend = function (s) {
+ const select = $('s-stt-preferred-backend');
+ if (!select) return;
+ select.querySelectorAll('[data-custom-engine]').forEach(option => option.remove());
+ for (const card of s.custom_engine_cards || []) {
+ if (card.role !== 'stt' && card.role !== 'stt+tts') continue;
+ const value = 'custom:' + (card.id || card.name);
+ if (Array.from(select.options).some(option => option.value === value)) continue;
+ const option = new Option(card.label || card.name, value);
+ option.dataset.customEngine = '1';
+ select.add(option);
+ }
+ if (s.stt_preferred_backend?.startsWith('custom:') && !Array.from(select.options).some(option => option.value === s.stt_preferred_backend)) {
+ const option = new Option(s.stt_preferred_backend, s.stt_preferred_backend);
+ option.dataset.customEngine = '1';
+ select.add(option);
+ }
+ select.value = s.stt_preferred_backend || '';
+};
+
async function loadSettings() {
const s = await fetch('/api/settings').then(r => r.json());
const sv = (id, val) => { const el = $(id); if (el) el.value = val; };
const svSecret = (id, val) => { const el = $(id); if (el) { el.value = ''; el.placeholder = val === '__configured__' ? 'Configured — enter a new key to replace' : ''; } };
sv('s-whisper-url', s.whisper_url || '');
svSecret('s-whisper-key', s.whisper_api_key);
+ svSecret('s-stt-api-key', s.whisper_api_key);
+ if ($('s-stt-model')) {
+ const model = s.stt_models?.configured || '';
+ $('s-stt-model').innerHTML = ``;
+ }
sv('s-tts-url', s.tts_url || '');
sv('s-faster-whisper-url', s.faster_whisper_url || '');
sv('s-whisper-cpp-url', s.whisper_cpp_url || '');
@@ -748,7 +774,7 @@ async function loadSettings() {
if (window.populateLangSelect) window.populateLangSelect();
// Captures settings
const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || '';
- const sttPref = $('s-stt-preferred-backend'); if (sttPref) sttPref.value = s.stt_preferred_backend || '';
+ window.syncPreferredSttBackend(s);
const autoRef = $('s-auto-refine'); if (autoRef) autoRef.value = s.auto_refine || 'off';
const refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || '';
const rfFill = $('s-refine-fillers'); if (rfFill) rfFill.checked = s.refine_fillers !== false;
@@ -818,7 +844,8 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
whisper_url: $('s-whisper-url').value,
- whisper_api_key: $('s-whisper-key').value,
+ whisper_api_key: $('s-stt-api-key')?.value || $('s-whisper-key').value,
+ stt_models: { ...(_appSettings?.stt_models || {}), configured: $('s-stt-model')?.value || '' },
faster_whisper_url: $('s-faster-whisper-url').value,
whisper_cpp_url: $('s-whisper-cpp-url').value,
groq_api_key: $('s-groq-api-key').value,
diff --git a/static/js/stt.js b/static/js/stt.js
index 0f716b9..0cc523a 100644
--- a/static/js/stt.js
+++ b/static/js/stt.js
@@ -16,12 +16,13 @@ function sttTtsSelectedSttBackend() {
}
function sttBackendOptionHtml(selected = 'configured') {
- if (!_sttBackends.length) return '';
- const preferred = _sttBackends.some(b => b.id === selected && b.available) ? selected : (_sttBackends.find(b => b.available)?.id || selected);
- return _sttBackends.map(b => {
+ const choices = sttBackendChoices(_sttBackends, selected);
+ if (!choices.length) return '';
+ const preferred = choices.some(b => b.id === selected && b.available) ? selected : (choices.find(b => b.available)?.id || selected);
+ return choices.map(b => {
const suffix = b.available ? '' : ' (unavailable)';
const disabled = b.available ? '' : ' disabled';
- return ``;
+ return ``;
}).join('');
}
@@ -37,14 +38,14 @@ function updateSttBackendHelp() {
async function refreshSttBackends(selected = '') {
try {
const d = await fetch('/api/stt-backends').then(r => r.json());
- _sttBackends = (d.backends || []).filter(b => b && b.id);
+ _sttBackends = (d.backends || []).filter(b => b && b.id).map(b => b.connected === false ? { ...b, available: false } : b);
} catch (_) {
_sttBackends = [];
}
- ['stt-tts-stt-backend', 'clone-stt-backend'].forEach(id => {
+ ['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'].forEach(id => {
const sel = $(id);
if (!sel) return;
- const prev = selected || sel.value || 'configured';
+ const prev = selected || (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || sel.value || 'configured';
sel.innerHTML = sttBackendOptionHtml(prev);
sel.disabled = !_sttBackends.some(b => b.available);
});
diff --git a/static/js/utils.js b/static/js/utils.js
index b3b59c6..3a11b4a 100644
--- a/static/js/utils.js
+++ b/static/js/utils.js
@@ -115,7 +115,7 @@ function statusActiveTts() {
try {
all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
} catch (_) {}
- const labels = all.map(b => b.label || b.id);
+ const labels = all.map(b => b.title || b.label || b.id);
return {
kind: 'tts',
label: 'TTS',
@@ -125,21 +125,33 @@ function statusActiveTts() {
};
}
+function sttBackendChoices(backends, selected = '') {
+ const all = backends.filter(b => b && b.id).map(b => b.connected === false ? { ...b, available: false } : b);
+ const winners = new Map();
+ for (const b of all) {
+ if (!b.available || !b.engine_id) continue;
+ const previous = winners.get(b.engine_id);
+ if (!previous || b.id === selected || (previous.id !== selected && b.id === `custom:${b.engine_id}`)) winners.set(b.engine_id, b);
+ }
+ return all.filter(b => !b.available || !b.engine_id || winners.get(b.engine_id) === b);
+}
+
function statusActiveStt() {
let all = [];
try {
all = Array.isArray(_sttBackends) ? _sttBackends : [];
} catch (_) {}
- const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value
- || (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || '';
+ const preferred = (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend)
+ || $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
+ all = sttBackendChoices(all, preferred);
const available = all.filter(b => b.available);
const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null;
return {
kind: 'stt',
label: 'STT',
ok: !!(chosen && chosen.available),
- value: chosen ? (chosen.label || chosen.model || chosen.id) : 'No STT backend',
- title: chosen ? `${chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
+ value: chosen ? (chosen.title || chosen.label || chosen.id) : 'No STT backend',
+ title: chosen ? `${chosen.title || chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
};
}
@@ -149,6 +161,9 @@ function statusActiveStt() {
function statusLlmTarget() {
let settings = {};
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
+ if (settings.engine_disabled?.[settings.engine_active?.llm_url] === true) {
+ return { url: '', model: '', apiKey: '', disconnected: true };
+ }
return {
url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''),
model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '',
@@ -157,14 +172,14 @@ function statusLlmTarget() {
}
function statusActiveLlm() {
- const { url, model } = statusLlmTarget();
+ const { url, model, disconnected } = statusLlmTarget();
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
const ok = !!url && same && STATUS_LLM_CACHE.ok;
return {
kind: 'llm',
label: 'LLM',
ok,
- value: model || statusShortUrl(url) || 'No LLM configured',
+ value: disconnected ? 'Disconnected' : model || statusShortUrl(url) || 'No LLM configured',
title: url ? `${model || 'default model'} · ${url}` : 'No active LLM endpoint configured',
};
}
@@ -247,8 +262,8 @@ function _statusApplyToSelects(ids, value) {
}
async function _statusOpenLlmFlyup(anchor) {
- const { url, model } = statusLlmTarget();
- if (!url) { _statusFlyupList(anchor, [], null, 'No LLM endpoint configured', 'llm'); return; }
+ const { url, model, disconnected } = statusLlmTarget();
+ if (!url) { _statusFlyupList(anchor, [], null, disconnected ? 'LLM disconnected — use Connect to restore' : 'No LLM endpoint configured', 'llm'); return; }
_statusFlyupList(anchor, [], null, 'Loading models…', 'llm');
let models = [];
try {
@@ -257,10 +272,13 @@ async function _statusOpenLlmFlyup(anchor) {
models = Array.isArray(data.models) ? data.models : [];
} catch (_) { logErr('status-llm-models', _); }
if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting
+ if (statusLlmTarget().disconnected) {
+ _statusFlyupList(anchor, [], null, 'LLM disconnected — use Connect to restore', 'llm'); return;
+ }
_statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => {
if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_model: picked }) }).catch(() => {});
- _statusApplyToSelects(['reh-llm-model', 'conv-llm-model-select'], picked);
+ _statusApplyToSelects(['llm-active-model', 'reh-llm-model', 'conv-llm-model-select'], picked);
refreshStatusBarEngines({ force: true });
toast('LLM model set to ' + picked, 'success');
}, 'No models found at this endpoint', 'llm');
@@ -268,10 +286,10 @@ async function _statusOpenLlmFlyup(anchor) {
function _statusOpenSttFlyup(anchor) {
const all = Array.isArray(_sttBackends) ? _sttBackends : [];
- const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value
- || (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || '';
- _statusFlyupList(anchor, all.filter(b => b.available).map(b => ({
- id: b.id, label: b.label || b.id, active: b.id === preferred,
+ const preferred = (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend)
+ || $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
+ _statusFlyupList(anchor, sttBackendChoices(all, preferred).filter(b => b.available).map(b => ({
+ id: b.id, label: b.title || b.label || b.id, active: b.id === preferred,
})), (picked) => {
// Persisting this (not just pushing it into a few unrelated