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&&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 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 +
+ `; + const ta = ov.querySelector('textarea'); + ta.value = text; + const close = () => { ov.remove(); document.removeEventListener('keydown', onKey); }; + const onKey = e => { if (e.key === 'Escape') close(); }; + document.addEventListener('keydown', onKey); + ov.querySelector('button').addEventListener('click', close); + ov.addEventListener('click', e => { if (e.target === ov) close(); }); + document.body.appendChild(ov); + ta.focus(); + ta.select(); +} function fmtDuration(sec) { if (sec == null || Number.isNaN(Number(sec))) return '-'; const total = Math.max(0, Math.round(Number(sec))); @@ -1432,8 +1483,8 @@ else initLangSelects(document); })(); Object.assign(root, { - $, avatarColor, toast, confirmDialog, status, ensureStatusBar, updateStatusBar, - escHtml, debounce, logErr, ttvcIsLowMemoryDevice, fmtDuration, loadingMarkup, + $, avatarColor, toast, confirmDialog, status, ensureStatusBar, updateStatusBar, sttBackendChoices, + escHtml, copyText, copyTextDialog, debounce, logErr, ttvcIsLowMemoryDevice, fmtDuration, loadingMarkup, setBusyButton, enhanceAccessibility, applyTheme, cc2flag, ccDisplay, setPreviewLang, openPreviewLangPicker, createSearchablePicker, getStoredTags, addStoredTag, scheduleVoiceLibraryLoad, disabledBackendTabMessage, switchTab, diff --git a/static/js/voice-library.js b/static/js/voice-library.js index 1802180..1c9e419 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -164,10 +164,13 @@ function toggleSortDir() { } function syncSortHeaders() { - document.querySelectorAll('.vl-header [data-sort], .vl-table-header [data-sort]').forEach(el => { + 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'); - if (el.dataset.sort === _sortField) + el.removeAttribute('aria-sort'); + if (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'); if (sel && sel.value !== _sortField) sel.value = _sortField; @@ -1119,11 +1122,16 @@ $('benchmark-confirm-start')?.addEventListener('click', async () => { $('copy-active-voices-btn').addEventListener('click', async () => { const useSelected = _bulkSelected && _bulkSelected.size > 0; const ids = useSelected ? [..._bulkSelected] : activeVoiceIds(); - const 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'); + 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 { + // Async clipboard unavailable (plain-HTTP LAN origin, embedded webview): show the list to copy by hand. + copyTextDialog(text, `${ids.length} voice name${ids.length === 1 ? '' : 's'}`); + } }); // Round-trip verify: synthesize each voice's own reference line (or a @@ -3456,15 +3464,22 @@ function renderVoiceGroupsBar() { if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; } bar.hidden = false; const active = window._voiceGroupFilter || ''; + // Collapsed by default (accordion): the chip list is long and duplicates the + // "All groups" dropdown; the summary line shows the count and the active group. bar.innerHTML = - ` Groups` + + `
+ Groups + ${names.length} + ${active ? `${escHtml(active)}` : ''} + +
` + names.map(g => ` ${escHtml(g)} ${groups[g]} ` - ).join('') + - (active ? `` : ''); + ).join('') + `
`; + bar.querySelector('details').addEventListener('toggle', e => { window._vgOpen = e.target.open; }); bar.querySelectorAll('.vg-chip').forEach(chip => { chip.addEventListener('click', e => { @@ -3478,7 +3493,8 @@ function renderVoiceGroupsBar() { bar.querySelectorAll('.vg-del').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); }); }); - bar.querySelector('.vg-clear')?.addEventListener('click', () => { + bar.querySelector('.vg-clear')?.addEventListener('click', e => { + e.preventDefault(); e.stopPropagation(); window._voiceGroupFilter = ''; const groupSel = $('library-filter-group'); if (groupSel) groupSel.value = ''; renderVoiceList(); @@ -3528,18 +3544,25 @@ function _bulkUpdate() { const count = $('vl-bulk-count'); const n = _bulkSelected.size; if (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'); if (cnt) cnt.textContent = n ? `(${n})` : ''; + if (n === 0) _setActionsMenu(false); + } if (count) count.textContent = `${n} selected`; // Sync checkboxes in the DOM to match the set document.querySelectorAll('.vl-bulk-cb').forEach(cb => { cb.checked = _bulkSelected.has(cb.dataset.id); }); - const headerCb = document.getElementById('vl-select-all-header'); - if (headerCb) { - const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id); - const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id)); - headerCb.checked = allSelected; - } + const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id); + const nSel = rows.filter(r => _bulkSelected.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 < rows.length; + }); } function _bulkToggle(id, checked) { @@ -3552,8 +3575,69 @@ const _origRenderVoiceList = renderVoiceList; renderVoiceList = function() { _origRenderVoiceList.apply(this, arguments); _bulkInjectCheckboxes(); + _bulkUpdate(); + _updateFilterBadge(); }; +// Actions menu (bottom bar): one button instead of a dozen; closes on pick, outside click or Esc. +function _setActionsMenu(open) { + const m = $('vl-actions-menu'), b = $('vl-actions-btn'); + if (!m || !b) return; + m.hidden = !open; + b.setAttribute('aria-expanded', String(open)); +} +$('vl-actions-btn')?.addEventListener('click', () => _setActionsMenu($('vl-actions-menu').hidden)); +$('vl-actions-menu')?.addEventListener('click', e => { if (e.target.closest('button')) _setActionsMenu(false); }); +document.addEventListener('click', e => { if (!e.target.closest('.vl-actions')) _setActionsMenu(false); }); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !$('vl-actions-menu')?.hidden) { _setActionsMenu(false); $('vl-actions-btn')?.focus(); } +}); + +// Preview-text / synth-source drawer: an icon in the top bar instead of a permanent card. +function _synthModeLabel() { + return document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.textContent.trim() || 'Preview text'; +} +function _refreshSynthToggle() { + const t = $('vl-synth-toggle'); + if (t) t.title = `Synth uses: ${_synthModeLabel()} — click to ${$('vl-synth-drawer')?.hidden ? 'edit' : 'hide'}`; +} +function _setSynthDrawer(open) { + const d = $('vl-synth-drawer'), t = $('vl-synth-toggle'); + if (!d || !t) return; + 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 (_) {} +} +$('vl-synth-toggle')?.addEventListener('click', () => _setSynthDrawer($('vl-synth-drawer').hidden)); +document.addEventListener('click', e => { if (e.target.closest('#vl-synth-mode-seg')) setTimeout(_refreshSynthToggle, 0); }); +try { _setSynthDrawer(localStorage.getItem('vl-synth-open') === '1'); } catch (_) {} + +// Filter panel: collapsed by default; the badge counts the filters that are narrowing the list. +function _updateFilterBadge() { + const badge = $('vl-filter-badge'); + if (!badge) return; + const val = id => ($(id)?.value || '').trim(); + const n = ['library-filter-lang', 'library-filter-sex', 'library-filter-type', 'library-filter-tag', + 'library-filter-rating', 'library-filter-group'].filter(id => val(id)).length + + ($('show-disabled-cb')?.checked ? 1 : 0) + (val('library-filter-text') ? 1 : 0); + badge.textContent = String(n); + badge.hidden = n === 0; + const t = $('vl-filter-toggle'); + if (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) return; + panel.hidden = !open; + t.setAttribute('aria-expanded', String(open)); + t.classList.toggle('active', open); + try { localStorage.setItem('vl-filter-open', open ? '1' : '0'); } catch (_) {} +} +$('vl-filter-toggle')?.addEventListener('click', () => _setFilterPanel($('vl-filter-panel').hidden)); +try { _setFilterPanel(localStorage.getItem('vl-filter-open') === '1'); } catch (_) {} + // Shift-click range select: remembers the last checkbox actually clicked // (not just toggled programmatically) so a shift-click on a later row can // fill in everything between them — the same convention as a file manager @@ -3598,17 +3682,11 @@ function _bulkInjectCheckboxes() { // ── Toolbar button wiring ─────────────────────────────────────────────────── -$('vl-bulk-select-all')?.addEventListener('click', () => { - document.querySelectorAll('#voice-list .vl-row').forEach(row => { - if (row.dataset.id) _bulkSelected.add(row.dataset.id); - }); - _bulkUpdate(); -}); - // Always-visible Select-all toggle in the table header (the bulk bar only appears once // something is selected, so this is the entry point). Selects every visible row, or // clears the selection if they're all already selected. -$('vl-select-all-header')?.addEventListener('change', (e) => { +document.addEventListener('change', (e) => { + if (!e.target.classList.contains('vl-select-all-cb')) return; const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id); if (e.target.checked) { rows.forEach(r => _bulkSelected.add(r.dataset.id)); @@ -3618,11 +3696,6 @@ $('vl-select-all-header')?.addEventListener('change', (e) => { _bulkUpdate(); }); -$('vl-bulk-deselect')?.addEventListener('click', () => { - _bulkSelected.clear(); - _bulkUpdate(); -}); - $('vl-bulk-hide')?.addEventListener('click', async () => { if (!_bulkSelected.size) return; const ids = [..._bulkSelected]; diff --git a/static/sections/s-llms.html b/static/sections/s-llms.html index 613c665..c41e9b9 100644 --- a/static/sections/s-llms.html +++ b/static/sections/s-llms.html @@ -431,6 +431,15 @@ Default recognition endpoint. Expected: POST /v1/audio/transcriptions. + + + + +
+ + + +
@@ -844,7 +853,7 @@
- +

Add Custom Engine

@@ -864,6 +873,28 @@
+
+ + +
+
+ + + +
+ + +
+ +
+
+ + + Optional request parameters. Keep credentials in the API key field. +
+
+ +
diff --git a/static/sections/s-voices.html b/static/sections/s-voices.html index bd73c58..fa3a8d9 100644 --- a/static/sections/s-voices.html +++ b/static/sections/s-voices.html @@ -17,9 +17,19 @@
- -
-
+ +
+ +
+ + + + + + +
+
-
- - -
-
- - - - - + + + +
-
- - Sort - + + + +
+
+ + +
+
-
-
+
Img
Play
Name
@@ -132,19 +126,42 @@
Note
Active
+
+ + + + + + +
- - - - + + + - - - - - - +
+ + +