diff --git a/CHANGELOG.md b/CHANGELOG.md index 3050b86..2e033b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi ## [Unreleased] ### Added +- **Optional vocal isolation for URL/YouTube imports (default OFF).** `POST /api/isolate-vocals {id}` runs the Demucs CLI (`htdemucs`, two stems) on a registered temp audio and registers the vocals as a new id; the original stays valid. Needs `VOCAL_ISOLATION=1`, a `demucs` binary (or `VOCAL_ISOLATION_CMD`) and the weights already on disk; the app never downloads anything and nothing is added to the main image. Limits: 15 min input, 15 min runtime (`VOCAL_ISOLATION_MAX_SECONDS`/`_TIMEOUT`), one job at a time, cancelled on client disconnect, temp files always removed. `GET /api/version` reports `features.vocal_isolation`; the "Remove background music" button (clone page URL card and voice library add-URL) appears only then, with an aria-live status and a play original / play without music toggle. Tests: `tests/test_vocal_isolation.py`, `tests/vocal-isolation.spec.js`. See `architecture/vocal-isolation.md`. - **Voice export/import bundles.** `GET /api/voices/{id}/export` streams a `.voice.zip` (audio, `reference.txt`, `meta.json` with seed, design prompt, rating/tags, `provenance`, `audio_problems`; optional picture and consent recording; `manifest.json` with format version, app version and sha256 per file; no temp files). `POST /api/voices/import` validates at the boundary: 150 MB upload cap, fixed flat member names only (zip-slip/path traversal rejected), per-file, total and compression-ratio caps, hashes verified, audio decoded, meta and provenance re-validated; a taken name gets a `_2` suffix, an identical voice is a no-op, nothing is overwritten. Voice library toolbar: "Export selected" and "Import bundle" (44px). Tests: `tests/test_voice_bundle.py`, `tests/voice-bundle.spec.js`. See `architecture/voice-bundle.md`. - **Consent/provenance field per voice.** Each voice gets `provenance` in its `.meta.json` (`basis`: own_voice, consented, licensed, public_domain or unknown; `source`, `licence`, `note`, optional `recorded_consent`, `set_at`). Existing voices count as `unknown`; `quick-import-voice` prefills the source URL (basis stays `unknown`). New `PUT /api/voices/{id}/provenance` (enum, length caps, control characters stripped, http(s) URLs only, no credentials) and `POST`/`GET /api/voices/{id}/consent-recording` (audio only, 10 MB cap, stored as `.consent`, moves/deletes with the voice). The voice inspector has a "Source & consent" section (typed note or MediaRecorder recording) and voices without a recorded basis show a "Source unknown" badge. Generation is not blocked. Data stays local. Tests: `tests/test_voice_provenance.py`, `tests/voice-provenance.spec.js`. - **Seed Finder: Auto-pick best.** `POST /api/seed-autopick {voice, text, seeds|count}` (default 8, max 16) generates candidates sequentially through the existing temporary-voice path (voices.json is always restored), scores each 0-100 (ASR word accuracy, loudness/clipping, `audio_problems`) and returns all sub-scores plus the winner. Degrades to loudness/problems when STT is unavailable. The UI highlights the winner; saving stays a click on "Use seed". Tests: `tests/test_seed_autopick.py`, `tests/seed-autopick.spec.js`. diff --git a/Manual.md b/Manual.md index bbc6bc1..66c1e4e 100644 --- a/Manual.md +++ b/Manual.md @@ -14,6 +14,16 @@ Living manual. Every feature updates this file in the same commit as the code. --- +## Remove background music from URL imports (optional) + +- **Purpose:** strip music and background sound from a downloaded clip before cloning, so the clone does not learn the music. +- **Prerequisites:** off by default. The server owner sets `VOCAL_ISOLATION=1`, installs Demucs (`pip install demucs` in a venv or sidecar, ARM64 supported; set `VOCAL_ISOLATION_CMD` if it is not on `PATH`) and downloads the `htdemucs` weights once (`955717e8-8726e21a.th`, about 80 MB, into `$TORCH_HOME/hub/checkpoints`). The app never downloads weights itself. Without all of this the button does not appear. +- **Steps:** download a YouTube/URL clip, press **Remove background music**, wait (minutes on CPU). The cleaned audio loads in the player or waveform; press **Play original** / **Play without music** to compare. Then trim and save as usual. +- **Result:** a new temporary audio; the original is kept until it expires. +- **Errors:** clips over 15 minutes are refused (trim first), only one job runs at a time, a timeout or failure shows a message under the button. +- **Mobile/accessibility:** buttons are normal buttons; progress and errors are announced through a polite live region. +- **Offline/privacy:** runs fully locally; no audio leaves the machine. + ## Voice export and import bundles Purpose: back up a voice or move it between machines (DGX and NUC) as one `.voice.zip` file containing the reference audio, transcript, settings (seed, design prompt, rating, tags, source & consent, audio problems), and optionally the picture and the consent recording. diff --git a/architecture/vocal-isolation.md b/architecture/vocal-isolation.md new file mode 100644 index 0000000..474196c --- /dev/null +++ b/architecture/vocal-isolation.md @@ -0,0 +1,42 @@ +# Vocal isolation for URL/YouTube imports + +Status: implemented behind a flag, default OFF. Goal: remove music/background from a downloaded +clip before trimming and cloning (`/api/download-yt` -> `/api/isolate-vocals` -> trim -> `quick-import-voice`). + +## Findings (read-only inspection, 2026-10-04) +- App image: `python:3.11-slim-bookworm`, `requirements.txt` has no torch/onnx/demucs. The live app + container has no `torch` (`import torch` fails). It is `read_only: true`, `cap_drop: ALL`, 8 GB mem + limit, no GPU reservation. Source dirs are bind-mounted read-only. +- GPU/torch live in other service containers (tts-faster-qwen3-*, aligner, ...), not reusable: different + images, no shared API for source separation. +- There is no VAD, denoise or separation anywhere in the app today. + +## Options +| Option | Footprint | Verdict | +|---|---|---| +| Demucs `htdemucs` (MIT code; weights MIT, ~80 MB, from dl.fbaipublicfiles.com via torch hub) in the main image | torch + torchaudio on ARM64 ~ 2-3 GB image growth | rejected: heavy, unconditional | +| Demucs in a separate sidecar container | no growth of main image, GPU optional | best long-term, but needs a new image, compose service and network contract | +| **Optional external Demucs CLI**, called as a subprocess, only when the flag is on and the binary + weights exist | zero deps in the main image; the owner picks where Demucs lives (venv on a bind mount, or a derived image) | **chosen** | +| Lighter: RNNoise/DeepFilterNet/spectral gating | small, but removes noise only, not music/singing | does not meet the goal | + +Why the CLI: `demucs --two-stems=vocals -n htdemucs` is the stable public interface; no import-time cost, +no behaviour change when absent. ARM64: `torch`/`torchaudio` publish aarch64 wheels (CUDA wheels exist for +DGX Spark / GB10; CPU wheels work everywhere), `demucs` itself is pure Python (`pip install demucs`). +A sidecar can later expose the same CLI via `VOCAL_ISOLATION_CMD` (e.g. `docker exec ...` wrapper) without code changes. + +## Safety rules +- `VOCAL_ISOLATION=1` required; otherwise the feature reports unavailable and the API answers 503. +- Available only if the command resolves AND the htdemucs weights file (`955717e8-*.th`) is already in + `$TORCH_HOME/hub/checkpoints` (default `~/.cache/torch`). The app never downloads weights: the owner + installs them once (documented in Manual.md). This prevents Demucs' silent first-run download. +- Input bounded to `VOCAL_ISOLATION_MAX_SECONDS` (default 900), runtime to `VOCAL_ISOLATION_TIMEOUT` + (default 900 s), one job at a time (409 when busy), client disconnect cancels and kills the process. +- Work happens in a private temp dir that is always removed; the result is converted to 24 kHz mono WAV + and registered as a new temp id. The original id is untouched (compare / undo in the UI). +- Subprocess started with an argument list (no shell); the path comes from the registry, never from the client. + +## Contract +- `GET /api/version` gains `features.vocal_isolation: bool`. +- `POST /api/isolate-vocals {id}` -> `{id, duration}`. Errors: 400 bad id, 404 unknown id, 413 too long, + 409 busy, 503 disabled/unavailable, 504 timeout, 500 failed (message without internals). +- Offline behaviour: fully local; no network access. diff --git a/core/vocal_isolation.py b/core/vocal_isolation.py new file mode 100644 index 0000000..bf20c9a --- /dev/null +++ b/core/vocal_isolation.py @@ -0,0 +1,82 @@ +"""Optional vocal isolation (Demucs CLI). Off unless VOCAL_ISOLATION=1; never downloads weights.""" +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import threading +import time +import uuid +from pathlib import Path + +from core.audio import _duration, _to_wav_24k +from core.registry import TEMP_DIR + +_WEIGHTS_GLOB = "955717e8*.th" # htdemucs +_busy = threading.Lock() # ponytail: one job at a time; a queue only if several users isolate concurrently + + +class VocalIsolationError(Exception): + def __init__(self, message: str, status: int = 500): + super().__init__(message) + self.status = status + + +def _cmd() -> str | None: + return os.environ.get("VOCAL_ISOLATION_CMD") or shutil.which("demucs") + + +def _weights_present() -> bool: + home = Path(os.environ.get("TORCH_HOME") or Path.home() / ".cache" / "torch") + return any((home / "hub" / "checkpoints").glob(_WEIGHTS_GLOB)) + + +def enabled() -> bool: + return os.environ.get("VOCAL_ISOLATION", "").strip().lower() in {"1", "true", "yes", "on"} + + +def available() -> bool: + return enabled() and _cmd() is not None and _weights_present() + + +def isolate_vocals(path: Path, cancel: threading.Event | None = None) -> Path: + """Return a new 24 kHz mono WAV with vocals only; `path` is left untouched.""" + if not enabled(): + raise VocalIsolationError("Vocal isolation is turned off (set VOCAL_ISOLATION=1).", 503) + cmd = _cmd() + if cmd is None or not _weights_present(): + raise VocalIsolationError("Vocal isolation is not installed (Demucs or its weights are missing).", 503) + max_s = float(os.environ.get("VOCAL_ISOLATION_MAX_SECONDS", "900")) + timeout = float(os.environ.get("VOCAL_ISOLATION_TIMEOUT", "900")) + if _duration(path) > max_s: + raise VocalIsolationError(f"Audio is longer than {int(max_s // 60)} minutes; trim it first.", 413) + if not _busy.acquire(blocking=False): + raise VocalIsolationError("Another vocal isolation is running; try again shortly.", 409) + work = Path(tempfile.mkdtemp(prefix="vocal_", dir=TEMP_DIR)) + try: + proc = subprocess.Popen( + [cmd, "--two-stems=vocals", "-n", "htdemucs", "-o", str(work), str(path)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + deadline = time.monotonic() + timeout + while proc.poll() is None: + if cancel is not None and cancel.is_set(): + proc.kill(); proc.wait() + raise VocalIsolationError("Vocal isolation was cancelled.", 499) + if time.monotonic() > deadline: + proc.kill(); proc.wait() + raise VocalIsolationError("Vocal isolation timed out.", 504) + time.sleep(0.2) + found = next(work.glob("*/*/vocals.*"), None) + if proc.returncode != 0 or found is None: + raise VocalIsolationError("Vocal isolation failed.", 500) + staged = TEMP_DIR / f"{uuid.uuid4().hex}_vocals{found.suffix}" + shutil.move(str(found), staged) + try: + return _to_wav_24k(staged) + finally: + staged.unlink(missing_ok=True) + finally: + _busy.release() + shutil.rmtree(work, ignore_errors=True) diff --git a/routes/admin.py b/routes/admin.py index c0c368c..6f28548 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -199,7 +199,8 @@ async def browse_dirs_mkdir(payload: dict = Body(...)): @router.get("/api/version") async def get_version(): - return {"version": __version__} + from core.vocal_isolation import available as _vocal_isolation + return {"version": __version__, "features": {"vocal_isolation": _vocal_isolation()}} @router.get("/api/changelog", response_class=PlainTextResponse) diff --git a/routes/sources.py b/routes/sources.py index 44b649b..e0b7735 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -6,6 +6,7 @@ import glob import json import os import re +import threading import uuid from datetime import datetime, timezone from html import unescape @@ -18,7 +19,7 @@ from fastapi.responses import StreamingResponse from core.config import _load_settings from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES -from core.registry import _registry_put, TEMP_DIR +from core.registry import _registry_put, _registry_get, TEMP_DIR from core.validation import _validate_http_url from core.audio import _to_wav_24k, _duration from core.voice import ( @@ -722,6 +723,34 @@ async def download_yt(url: str): headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) +@router.post("/api/isolate-vocals") +async def isolate_vocals_route(request: Request): + """Remove background music from a registered temp audio id; the original id stays valid.""" + from core import vocal_isolation as vi + data = await request.json() + fid = data.get("id") if isinstance(data, dict) else None + if not isinstance(fid, str) or not fid: + raise HTTPException(400, "id required") + src = _registry_get(fid) + if src is None or not src.exists(): + raise HTTPException(404, "Source file not found") + cancel = threading.Event() + task = asyncio.create_task(asyncio.to_thread(vi.isolate_vocals, src, cancel)) + try: + while not task.done(): + await asyncio.wait({task}, timeout=1.0) + if not task.done() and await request.is_disconnected(): + cancel.set() + wav = task.result() + except vi.VocalIsolationError as e: + raise HTTPException(e.status, str(e)) + except Exception: + raise HTTPException(500, "Vocal isolation failed") + nid = uuid.uuid4().hex + _registry_put(nid, wav) + return {"id": nid, "duration": _duration(wav)} + + @router.get("/api/elevenlabs/voices") async def elevenlabs_shared_voices(request: Request): settings = _load_settings() diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 9d25293..481fece 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -366,7 +366,7 @@ curl -X POST ${proxyBase}/speak \\
${escHtml(voice)}
${escHtml(routingLogMeta(item)||"-")}
${escHtml(text||"-")}
- `}).join("")}}async function loadRoutingLog(){const el=$("routing-log-list");if(el)try{const r=await fetch("/api/tts-routing-log?limit=80");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_routingLogItems=Array.isArray(d.items)?d.items:[],renderCurrentRoutingLog()}catch(e){el.innerHTML=`
Routing log unavailable: ${escHtml(e.message)}
`}}async function clearRoutingLog(){const btn=$("routing-log-clear-btn");btn&&(btn.disabled=!0);try{const r=await fetch("/api/tts-routing-log",{method:"DELETE"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_routingLogItems=[],renderRoutingLog([]),toast("Routing log cleared","success")}catch(e){toast("Clear log failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function testRouting(){readRoutingForm();const btn=$("routing-test-btn"),el=$("routing-test-result");btn.disabled=!0,el.className="routing-test-result",el.textContent="Testing route...";try{const r=await fetch("/api/tts-route-test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({app:$("routing-test-app").value.trim()||"Open WebUI",voice:$("routing-test-voice").value.trim()||"default",input:$("routing-test-text").value.trim()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}renderRouteTestResult(await r.json()),loadRoutingLog()}catch(e){el.className="routing-test-result warn",el.textContent="Route test failed: "+e.message}finally{btn.disabled=!1}}async function uploadRouteSoundForRow(row,target){const input=document.createElement("input");input.type="file",input.accept="audio/*",input.multiple=!1,input.onchange=async()=>{if(!input.files||!input.files.length)return;const btn=row.querySelector(`.route-sound-upload[data-target="${target}"]`),field=row.querySelector(target==="before"?".route-before-sound":".route-after-sound");btn&&(btn.disabled=!0);try{const fd=new FormData;fd.append("file",input.files[0]);const r=await fetch("/api/route-sounds/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();field.value=d.path||"",await loadRouteSounds(),readRoutingForm(),renderRoutingList(),toast(`${target==="before"?"Before":"After"} sound uploaded`,"success"),status(`Uploaded route sound: ${d.path}`)}catch(e){toast("Sound upload failed: "+e.message,"error"),status("Sound upload failed")}finally{btn&&(btn.disabled=!1)}},input.click()}(_a=$("routing-refresh-btn"))==null||_a.addEventListener("click",loadRoutingTab),(_b=$("routing-add-btn"))==null||_b.addEventListener("click",()=>{readRoutingForm(),_ttsRoutes.push(newRoute()),renderRoutingList()}),(_c=$("routing-add-openwebui-btn"))==null||_c.addEventListener("click",()=>{readRoutingForm();const voices=activeVoiceIds(),firstByLang=lang=>voices.find(v=>v.toUpperCase().startsWith(lang+"_"))||"";_ttsRoutes.push(newRoute("Open WebUI","default","EN",firstByLang("EN"))),_ttsRoutes.push(newRoute("Open WebUI","default","DE",firstByLang("DE"))),renderRoutingList()}),(_d=$("routing-save-btn"))==null||_d.addEventListener("click",saveRoutingTab),(_e=$("routing-save-btn-head"))==null||_e.addEventListener("click",saveRoutingTab),(_f=$("routing-add-btn-head"))==null||_f.addEventListener("click",()=>{var _a2;return(_a2=$("routing-add-btn"))==null?void 0:_a2.click()}),(_g=$("routing-test-btn"))==null||_g.addEventListener("click",testRouting),(_h=$("routing-log-refresh-btn"))==null||_h.addEventListener("click",loadRoutingLog),(_i=$("routing-log-clear-btn"))==null||_i.addEventListener("click",clearRoutingLog),(_j=$("routing-log-filter"))==null||_j.addEventListener("change",e=>{_routingLogFilter=e.target.value||"all",renderCurrentRoutingLog()}),(_k=$("routing-list"))==null||_k.addEventListener("change",e=>{const picker=e.target.closest(".route-sound-picker");if(!picker)return;const field=picker.closest(".routing-row").querySelector(picker.dataset.target==="before"?".route-before-sound":".route-after-sound");field&&(field.value=picker.value||""),readRoutingForm()}),(_l=$("routing-list"))==null||_l.addEventListener("click",e=>{const speedPreviewBtn=e.target.closest(".route-speed-preview");if(speedPreviewBtn){previewRouteSpeed(speedPreviewBtn.closest(".routing-row"),speedPreviewBtn);return}const pickBtn=e.target.closest(".route-sound-pick");if(pickBtn){const row2=pickBtn.closest(".routing-row");openRouteSoundBrowser(row2,pickBtn.dataset.target);return}const uploadBtn=e.target.closest(".route-sound-upload");if(uploadBtn){const row2=uploadBtn.closest(".routing-row");uploadRouteSoundForRow(row2,uploadBtn.dataset.target);return}const btn=e.target.closest(".routing-delete");if(!btn)return;readRoutingForm();const row=btn.closest(".routing-row");_ttsRoutes.splice(Number(row.dataset.index),1),renderRoutingList()}),(_m=$("routing-sound-search"))==null||_m.addEventListener("input",debounce(renderRouteSoundBrowser,120)),(_n=$("routing-sound-refresh-btn"))==null||_n.addEventListener("click",async()=>{await loadRouteSounds(),renderRouteSoundBrowser()}),(_o=$("routing-sound-close-btn"))==null||_o.addEventListener("click",closeRouteSoundBrowser),(_p=$("routing-sound-list"))==null||_p.addEventListener("click",e=>{const item=e.target.closest(".routing-sound-item");if(!item)return;const path=item.dataset.path||"",playBtn=e.target.closest(".sound-play");if(playBtn){playRouteSound(path,playBtn);return}e.target.closest(".sound-use-current")&&useRouteSound(path,(_routeSoundPickerTarget==null?void 0:_routeSoundPickerTarget.target)||"before")}),Object.assign(root,{ROUTE_LANGS,_routingSetStatus,routeSelectOptions,enhanceRouteLangSelects,ROUTE_BACKENDS,_routeSounds,routeSoundOptions,loadRouteSounds,_routeSoundPickerTarget,_routeSoundPlayingButton,routeSoundUrl,setRouteSoundField,renderRouteSoundBrowser,openRouteSoundBrowser,closeRouteSoundBrowser,playRouteSound,ROUTE_SPEED_PREVIEW_TEXT,previewRouteSpeed,_routeSpeedPreviewButton,useRouteSound,routeBackendOptions,refreshRoutingVoiceOptions,newRoute,renderRoutingList,readRoutingForm,loadRoutingTab,saveRoutingTab,renderRouteTestResult,routingLogTime,routingLogBadge,routingLogMeta,_routingLogItems,_routingLogFilter,routingLogPassesFilter,renderCurrentRoutingLog,renderRoutingLog,loadRoutingLog,clearRoutingLog,testRouting,uploadRouteSoundForRow}),Object.defineProperty(root,"_ttsRoutes",{configurable:!0,get:()=>_ttsRoutes,set:v=>{_ttsRoutes=v}}),root.TTVC=root.TTVC||{},root.TTVC.routing=root.TTVC.routing||{},Object.assign(root.TTVC.routing,{get routes(){return _ttsRoutes},get sounds(){return _routeSounds},load:loadRoutingTab,save:saveRoutingTab,test:testRouting,render:renderRoutingList})}(window),function(root){var _a,_b,_c,_d;const CLONE_SAMPLE_TEXTS={EN:"Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain \u2014 these are the things I love. Can you hear how clearly I speak?",DE:"Hallo! Ich hei\xDFe Alex und das ist meine Stimme. Ich kann leise fl\xFCstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster \u2014 das liebe ich. H\xF6rst du, wie deutlich ich spreche?",IT:"Ciao! Mi chiamo Marco e questa \xE8 la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia \u2014 queste sono le cose che amo. Senti come parlo chiaramente?",ES:"\xA1Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire fr\xEDo del invierno, la c\xE1lida luz del fuego y el suave sonido de la lluvia \u2014 estas son las cosas que amo. \xBFPuedes o\xEDr lo claramente que hablo?",FR:"Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumi\xE8re du feu et le son de la pluie \u2014 voil\xE0 ce que j'aime. Entends-tu comme je parle clairement ?",PT:"Ol\xE1! Meu nome \xE9 Ana e esta \xE9 a minha voz. Posso falar suavemente ou com grande for\xE7a. O ar fresco do inverno, a luz quente do fogo e o som da chuva \u2014 estas s\xE3o as coisas que amo. Consegues ouvir como falo claramente?",NL:"Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen \u2014 dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?",PL:"Cze\u015B\u0107! Mam na imi\u0119 Anna i to jest m\xF3j g\u0142os. Mog\u0119 m\xF3wi\u0107 cicho lub z ca\u0142\u0105 moc\u0105. Mro\u017Ane zimowe powietrze, ciep\u0142e \u015Bwiat\u0142o ognia i d\u017Awi\u0119k deszczu za oknem \u2014 to s\u0105 rzeczy, kt\xF3re kocham. Czy s\u0142yszysz, jak wyra\u017Anie m\xF3wi\u0119?"},CLONE_SAMPLE_NAMES={EN:"Sam",DE:"Alex",IT:"Marco",ES:"Carlos",FR:"Sophie",PT:"Ana",NL:"Laura",PL:"Anna"};function cloneSampleForLang(lang){var _a2;const txt=$("clone-sample-text"),def=CLONE_SAMPLE_NAMES[lang]||"";let t=CLONE_SAMPLE_TEXTS[lang]||CLONE_SAMPLE_TEXTS.EN;const name=(((_a2=$("clone-your-name"))==null?void 0:_a2.value)||"").trim();name&&def&&(t=t.replace(new RegExp("\\b"+def.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b"),name)),txt&&(txt.value=t,txt.dataset.sampleName=name||def)}window.initCloneSampleText=function(){const sel=$("clone-sample-lang"),txt=$("clone-sample-text");!sel||!txt||(cloneSampleForLang(sel.value),sel._cloneSampleBound||(sel.addEventListener("change",()=>cloneSampleForLang(sel.value)),sel._cloneSampleBound=!0))},initCloneSampleText();let ws=null,wsRegions=null,currentFileId2=null,trimmedFileId2=null,designedFileId2=null,editingVoiceId2=null,editingVoicePath2=null;function initWaveSurfer(){ws&&(ws.destroy(),ws=null,wsRegions=null),wsRegions=WaveSurfer.Regions.create(),ws=WaveSurfer.create({container:"#waveform",waveColor:"#45475a",progressColor:"#89b4fa",cursorColor:"#cba6f7",height:90,normalize:!0,plugins:[wsRegions]}),ws.on("ready",()=>{const dur=ws.getDuration();$("trim-end").value=dur.toFixed(2),$("trim-end").max=dur.toFixed(2),$("trim-start").max=dur.toFixed(2),updateRegion()}),wsRegions.on("region-updated",r=>{$("trim-start").value=r.start.toFixed(2),$("trim-end").value=r.end.toFixed(2),updateDurationLabel()})}function updateRegion(){wsRegions.clearRegions();const s=parseFloat($("trim-start").value)||0,e=parseFloat($("trim-end").value)||(ws?ws.getDuration():0);wsRegions.addRegion({start:s,end:e,color:"rgba(137,180,250,0.25)",drag:!0,resize:!0}),updateDurationLabel()}function updateDurationLabel(){const d=Math.max(0,(parseFloat($("trim-end").value)||0)-(parseFloat($("trim-start").value)||0)),el=$("trim-duration");el.textContent=d.toFixed(1)+" s",el.className=d>=5&&d<=20?"dur-ok":d>20?"dur-warn":"dur-bad"}["trim-start","trim-end"].forEach(id=>$(id).addEventListener("input",()=>{ws&&updateRegion()})),$("play-btn").addEventListener("click",()=>{ws&&ws.playPause()}),$("play-selection-btn").addEventListener("click",()=>{ws&&ws.play(parseFloat($("trim-start").value)||0,parseFloat($("trim-end").value)||ws.getDuration())}),$("auto-trim-btn").addEventListener("click",async()=>{if(!currentFileId2){toast("No audio loaded","error");return}$("auto-trim-btn").disabled=!0,status("Finding best TTS reference segment\u2026");try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId2})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)status("Backend auto trim unavailable; analysing audio in browser\u2026"),d=await clientAutoTrimBounds(currentFileId2);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText||"Auto trim failed")}$("trim-start").value=Number(d.start).toFixed(2),$("trim-end").value=Number(d.end).toFixed(2),ws&&updateRegion(),toast("Auto trim set: "+Number(d.duration).toFixed(1)+" s","success"),status(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),status("Auto trim failed")}finally{$("auto-trim-btn").disabled=!1}});function loadAudioId(id,dur,opts={}){currentFileId2=id,trimmedFileId2=null,designedFileId2=null,editingVoiceId2=opts.editingVoiceId||null,editingVoicePath2=opts.editingVoicePath||null,$("trim-start").value="0",$("trim-end").value=dur.toFixed(2),$("waveform-card").style.display="",initWaveSurfer(),ws.load("/api/audio/"+id),$("save-result").style.display="none",$("trim-audio").style.display="none",$("no-audio-hint").style.display="",editingVoiceId2&&($("voice-id-input").value=editingVoiceId2,$("voice-id-input").dispatchEvent(new Event("input")),$("transcript-area").value=opts.transcript||"",status("Editing existing voice: "+editingVoiceId2))}const dropZone=$("drop-zone"),fileInput=$("file-input");dropZone.addEventListener("click",()=>fileInput.click()),dropZone.addEventListener("dragover",e=>{e.preventDefault(),dropZone.classList.add("drag-over")}),dropZone.addEventListener("dragleave",()=>dropZone.classList.remove("drag-over")),dropZone.addEventListener("drop",e=>{e.preventDefault(),dropZone.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadFile(e.dataTransfer.files[0])}),fileInput.addEventListener("change",()=>{fileInput.files.length&&uploadFile(fileInput.files[0])});async function uploadFile(file){status("Uploading "+file.name+"\u2026");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();throw new Error(e.detail||r.statusText)}const d=await r.json();loadAudioId(d.id,d.duration),status("Loaded: "+file.name+" ("+d.duration.toFixed(1)+" s)"),toast("File loaded","success")}catch(e){toast("Upload failed: "+e.message,"error"),status("Upload failed")}}async function loadLibraryVoiceAudio2(v){const audioResp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!audioResp.ok){const e=await audioResp.json().catch(()=>({}));throw new Error(e.detail||audioResp.statusText)}const blob=await audioResp.blob(),ext=(v.file_type||"wav").toLowerCase(),fd=new FormData;fd.append("file",new File([blob],`${v.id}.${ext}`,{type:blob.type||"audio/wav"}));const upload=await fetch("/api/upload",{method:"POST",body:fd});if(!upload.ok){const e=await upload.json().catch(()=>({}));throw new Error(e.detail||upload.statusText)}const d=await upload.json();return{id:d.id,voice_id:v.id,duration:d.duration,transcript:v.transcript||"",file_type:ext,path:v.path}}$("yt-btn").addEventListener("click",()=>{const url=$("yt-url").value.trim();if(!url)return;$("yt-btn").disabled=!0,$("yt-progress").textContent="Starting download\u2026";const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),$("yt-progress").textContent=d.error,$("yt-btn").disabled=!1,es.close()):d.done?(es.close(),$("yt-btn").disabled=!1,$("yt-progress").textContent="Done!",loadAudioId(d.id,d.duration),toast("YouTube audio loaded","success")):($("yt-progress").textContent=d.msg||"",d.pct&&status("Downloading\u2026 "+d.pct+"%"))},es.onerror=()=>{es.close(),$("yt-btn").disabled=!1}});const RAW_MIC_CONSTRAINTS={echoCancellation:!1,noiseSuppression:!1,autoGainControl:!1};async function visibleMicrophoneCount(){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.enumerateDevices))return null;try{return(await navigator.mediaDevices.enumerateDevices()).filter(device=>device.kind==="audioinput").length}catch{return null}}async function microphoneErrorMessage2(error){const name=(error==null?void 0:error.name)||"",message=(error==null?void 0:error.message)||"",lowerMessage=message.toLowerCase(),micCount=await visibleMicrophoneCount();return name==="NotFoundError"||lowerMessage.includes("requested device not found")?micCount===0?"No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.":"The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.":name==="NotAllowedError"||name==="PermissionDeniedError"?"Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.":name==="NotReadableError"?"The microphone is busy or unavailable. Close other apps using it, then try again.":name==="SecurityError"?"Microphone access requires localhost or HTTPS.":message||"Microphone failed."}async function requestMicrophoneStream2(options={}){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.getUserMedia))throw new Error("Microphone requires HTTPS. Open the app via https://... or access it on localhost.");if(!options.raw)return navigator.mediaDevices.getUserMedia({audio:!0});try{return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS})}catch(e){if((e==null?void 0:e.name)==="OverconstrainedError"||(e==null?void 0:e.name)==="NotFoundError")return navigator.mediaDevices.getUserMedia({audio:!0});throw e}}let _cloneMonState={stream:null,recordStream:null,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,waveRing:null,monitoring:!1};function _cloneRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("clone-mic-meter");if(!meter)return;if(!meter.children.length)for(let i=0;i<18;i++){const b=document.createElement("div");b.className="bar",meter.appendChild(b)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))});const el=$("clone-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function _cloneStartMeter(){if(!_cloneMonState.analyser)return;_cloneMonState.meterRaf&&cancelAnimationFrame(_cloneMonState.meterRaf);const data=new Float32Array(_cloneMonState.analyser.fftSize),canvas=$("clone-live-wave"),RING=300,ADD=10;_cloneMonState.waveRing=new Float32Array(RING);const tick=()=>{_cloneMonState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const s of data)sum+=s*s,peak=Math.max(peak,Math.abs(s));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;if(_cloneRenderMeter(level,db,peak>.98),canvas&&_cloneMonState.waveRing){const ring=_cloneMonState.waveRing;ring.copyWithin(0,ADD);for(let i=0;i.98?"#f38ba8":db>-12?"#f9e2af":"#a6e3a1",ctx.lineWidth=1.5;const mid=h/2;for(let i=0;i{try{n&&n.disconnect()}catch{}}),_cloneMonState.stream&&_cloneMonState.stream.getTracks().forEach(t=>t.stop()),_cloneMonState.recordStream&&_cloneMonState.recordStream.getTracks().forEach(t=>t.stop()),_cloneMonState.audioCtx&&_cloneMonState.audioCtx.close().catch(()=>{}),Object.assign(_cloneMonState,{stream:null,recordStream:null,sourceNode:null,gainNode:null,analyser:null,audioCtx:null,monitoring:!1,waveRing:null}),_cloneRenderMeter(0,-1/0,!1);const wc=$("clone-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height),$("clone-monitor-btn")&&($("clone-monitor-btn").disabled=!1),$("clone-monitor-stop")&&($("clone-monitor-stop").disabled=!0)}(_a=$("clone-monitor-btn"))==null||_a.addEventListener("click",async()=>{try{await _cloneStartMonitor(),status("Mic level monitor active")}catch(e){const m=await microphoneErrorMessage2(e);toast(m,"error")}}),(_b=$("clone-monitor-stop"))==null||_b.addEventListener("click",()=>{_cloneStopMonitor(),status("Mic level monitor stopped")}),(_c=$("clone-mic-gain"))==null||_c.addEventListener("input",()=>{const v=parseFloat($("clone-mic-gain").value)||0;$("clone-mic-gain-value")&&($("clone-mic-gain-value").textContent=v.toFixed(2)+"x"),_cloneMonState.gainNode&&(_cloneMonState.gainNode.gain.value=v)}),_cloneRenderMeter();let mediaRec=null,recChunks=[],recTimer=null,recSecs=0;$("rec-start-btn").addEventListener("click",async()=>{try{await _cloneStartMonitor(),recChunks=[],recSecs=0,$("rec-time").textContent="0:00",$("rec-indicator").classList.add("active"),$("rec-start-btn").disabled=!0,$("rec-stop-btn").disabled=!1,recTimer=setInterval(()=>{recSecs++,$("rec-time").textContent=Math.floor(recSecs/60)+":"+String(recSecs%60).padStart(2,"0")},1e3),mediaRec=new MediaRecorder(_cloneMonState.recordStream||_cloneMonState.stream,{audioBitsPerSecond:256e3}),mediaRec.ondataavailable=e=>{e.data.size&&recChunks.push(e.data)},mediaRec.onstop=async()=>{clearInterval(recTimer),$("rec-indicator").classList.remove("active");const blob=new Blob(recChunks,{type:mediaRec.mimeType||"audio/webm"}),ext=(mediaRec.mimeType||"").includes("ogg")?".ogg":".webm";_cloneStopMonitor(),await uploadFile(new File([blob],"recording"+ext,{type:blob.type}))},mediaRec.start(100),status("Recording\u2026")}catch(e){_cloneStopMonitor(),toast(await microphoneErrorMessage2(e),"error")}}),$("rec-stop-btn").addEventListener("click",()=>{mediaRec&&mediaRec.state!=="inactive"&&mediaRec.stop(),$("rec-start-btn").disabled=!1,$("rec-stop-btn").disabled=!0}),$("trim-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;if(!currentFileId2){toast("No audio loaded","error");return}try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId2,start:parseFloat($("trim-start").value)||0,end:parseFloat($("trim-end").value)||0})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();trimmedFileId2=d.id,designedFileId2=null,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",switchTab("save"),toast("Trim done","success"),(_b2=(_a2=$("transcript-area"))==null?void 0:_a2.closest(".card"))==null||_b2.scrollIntoView({behavior:"smooth",block:"center"}),(_c2=window._cloneAutoTranscribe)==null||_c2.call(window)}catch(e){toast("Trim failed: "+e.message,"error")}});const DESIGN_LANG_CODE={Auto:"EN",English:"EN",Chinese:"ZH",Japanese:"JA",Korean:"KO",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Portuguese:"PT",Russian:"RU"},DESIGN_GENDER_WORD={F:"female",M:"male",N:"neutral"},DESIGN_PRESET_KEY="vcf-design-presets",DESIGN_PRESET_SEEDED_KEY="vcf-design-presets-seeded-v2",DEFAULT_DESIGN_PRESETS={EN_M_Young_Energetic:{description:"Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.",sample_text:"Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.",language:"English",gender:"M"},EN_F_Warm_Narrator:{description:"Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.",sample_text:"The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.",language:"English",gender:"F"},DE_M_Elderly_Documentary:{description:"Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.",sample_text:"Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.",language:"German",gender:"M"},DE_F_Young_Friendly:{description:"Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.",sample_text:"Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.",language:"German",gender:"F"},EN_N_Old_Wise_Assistant:{description:"Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.",sample_text:"Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.",language:"English",gender:"N"}},QWEN_DESIGN_SAMPLES={"qwen-timbre-reuse":{title:"Qwen Timbre Reuse",summary:"Reference clip for designing a reusable teen character timbre.",description:"Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous",text:"H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?",language:"English",gender:"M"},"acoustic-sausage-announcer":{title:"Acoustic Attribute Control - British announcer",summary:"Fast, loud, articulate British male delivery with excitement and performative authority.",description:`gender: Male. + `}).join("")}}async function loadRoutingLog(){const el=$("routing-log-list");if(el)try{const r=await fetch("/api/tts-routing-log?limit=80");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_routingLogItems=Array.isArray(d.items)?d.items:[],renderCurrentRoutingLog()}catch(e){el.innerHTML=`
Routing log unavailable: ${escHtml(e.message)}
`}}async function clearRoutingLog(){const btn=$("routing-log-clear-btn");btn&&(btn.disabled=!0);try{const r=await fetch("/api/tts-routing-log",{method:"DELETE"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_routingLogItems=[],renderRoutingLog([]),toast("Routing log cleared","success")}catch(e){toast("Clear log failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function testRouting(){readRoutingForm();const btn=$("routing-test-btn"),el=$("routing-test-result");btn.disabled=!0,el.className="routing-test-result",el.textContent="Testing route...";try{const r=await fetch("/api/tts-route-test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({app:$("routing-test-app").value.trim()||"Open WebUI",voice:$("routing-test-voice").value.trim()||"default",input:$("routing-test-text").value.trim()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}renderRouteTestResult(await r.json()),loadRoutingLog()}catch(e){el.className="routing-test-result warn",el.textContent="Route test failed: "+e.message}finally{btn.disabled=!1}}async function uploadRouteSoundForRow(row,target){const input=document.createElement("input");input.type="file",input.accept="audio/*",input.multiple=!1,input.onchange=async()=>{if(!input.files||!input.files.length)return;const btn=row.querySelector(`.route-sound-upload[data-target="${target}"]`),field=row.querySelector(target==="before"?".route-before-sound":".route-after-sound");btn&&(btn.disabled=!0);try{const fd=new FormData;fd.append("file",input.files[0]);const r=await fetch("/api/route-sounds/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();field.value=d.path||"",await loadRouteSounds(),readRoutingForm(),renderRoutingList(),toast(`${target==="before"?"Before":"After"} sound uploaded`,"success"),status(`Uploaded route sound: ${d.path}`)}catch(e){toast("Sound upload failed: "+e.message,"error"),status("Sound upload failed")}finally{btn&&(btn.disabled=!1)}},input.click()}(_a=$("routing-refresh-btn"))==null||_a.addEventListener("click",loadRoutingTab),(_b=$("routing-add-btn"))==null||_b.addEventListener("click",()=>{readRoutingForm(),_ttsRoutes.push(newRoute()),renderRoutingList()}),(_c=$("routing-add-openwebui-btn"))==null||_c.addEventListener("click",()=>{readRoutingForm();const voices=activeVoiceIds(),firstByLang=lang=>voices.find(v=>v.toUpperCase().startsWith(lang+"_"))||"";_ttsRoutes.push(newRoute("Open WebUI","default","EN",firstByLang("EN"))),_ttsRoutes.push(newRoute("Open WebUI","default","DE",firstByLang("DE"))),renderRoutingList()}),(_d=$("routing-save-btn"))==null||_d.addEventListener("click",saveRoutingTab),(_e=$("routing-save-btn-head"))==null||_e.addEventListener("click",saveRoutingTab),(_f=$("routing-add-btn-head"))==null||_f.addEventListener("click",()=>{var _a2;return(_a2=$("routing-add-btn"))==null?void 0:_a2.click()}),(_g=$("routing-test-btn"))==null||_g.addEventListener("click",testRouting),(_h=$("routing-log-refresh-btn"))==null||_h.addEventListener("click",loadRoutingLog),(_i=$("routing-log-clear-btn"))==null||_i.addEventListener("click",clearRoutingLog),(_j=$("routing-log-filter"))==null||_j.addEventListener("change",e=>{_routingLogFilter=e.target.value||"all",renderCurrentRoutingLog()}),(_k=$("routing-list"))==null||_k.addEventListener("change",e=>{const picker=e.target.closest(".route-sound-picker");if(!picker)return;const field=picker.closest(".routing-row").querySelector(picker.dataset.target==="before"?".route-before-sound":".route-after-sound");field&&(field.value=picker.value||""),readRoutingForm()}),(_l=$("routing-list"))==null||_l.addEventListener("click",e=>{const speedPreviewBtn=e.target.closest(".route-speed-preview");if(speedPreviewBtn){previewRouteSpeed(speedPreviewBtn.closest(".routing-row"),speedPreviewBtn);return}const pickBtn=e.target.closest(".route-sound-pick");if(pickBtn){const row2=pickBtn.closest(".routing-row");openRouteSoundBrowser(row2,pickBtn.dataset.target);return}const uploadBtn=e.target.closest(".route-sound-upload");if(uploadBtn){const row2=uploadBtn.closest(".routing-row");uploadRouteSoundForRow(row2,uploadBtn.dataset.target);return}const btn=e.target.closest(".routing-delete");if(!btn)return;readRoutingForm();const row=btn.closest(".routing-row");_ttsRoutes.splice(Number(row.dataset.index),1),renderRoutingList()}),(_m=$("routing-sound-search"))==null||_m.addEventListener("input",debounce(renderRouteSoundBrowser,120)),(_n=$("routing-sound-refresh-btn"))==null||_n.addEventListener("click",async()=>{await loadRouteSounds(),renderRouteSoundBrowser()}),(_o=$("routing-sound-close-btn"))==null||_o.addEventListener("click",closeRouteSoundBrowser),(_p=$("routing-sound-list"))==null||_p.addEventListener("click",e=>{const item=e.target.closest(".routing-sound-item");if(!item)return;const path=item.dataset.path||"",playBtn=e.target.closest(".sound-play");if(playBtn){playRouteSound(path,playBtn);return}e.target.closest(".sound-use-current")&&useRouteSound(path,(_routeSoundPickerTarget==null?void 0:_routeSoundPickerTarget.target)||"before")}),Object.assign(root,{ROUTE_LANGS,_routingSetStatus,routeSelectOptions,enhanceRouteLangSelects,ROUTE_BACKENDS,_routeSounds,routeSoundOptions,loadRouteSounds,_routeSoundPickerTarget,_routeSoundPlayingButton,routeSoundUrl,setRouteSoundField,renderRouteSoundBrowser,openRouteSoundBrowser,closeRouteSoundBrowser,playRouteSound,ROUTE_SPEED_PREVIEW_TEXT,previewRouteSpeed,_routeSpeedPreviewButton,useRouteSound,routeBackendOptions,refreshRoutingVoiceOptions,newRoute,renderRoutingList,readRoutingForm,loadRoutingTab,saveRoutingTab,renderRouteTestResult,routingLogTime,routingLogBadge,routingLogMeta,_routingLogItems,_routingLogFilter,routingLogPassesFilter,renderCurrentRoutingLog,renderRoutingLog,loadRoutingLog,clearRoutingLog,testRouting,uploadRouteSoundForRow}),Object.defineProperty(root,"_ttsRoutes",{configurable:!0,get:()=>_ttsRoutes,set:v=>{_ttsRoutes=v}}),root.TTVC=root.TTVC||{},root.TTVC.routing=root.TTVC.routing||{},Object.assign(root.TTVC.routing,{get routes(){return _ttsRoutes},get sounds(){return _routeSounds},load:loadRoutingTab,save:saveRoutingTab,test:testRouting,render:renderRoutingList})}(window);function setupVocalIsolation({btn,toggle,msg,getId,getDur,apply}){const b=$(btn),t=$(toggle),m=$(msg);let pair=null,showing="clean";const say=text=>{m.textContent=text,status(text)};fetch("/api/version").then(r=>r.json()).then(v=>{var _a;(_a=v==null?void 0:v.features)!=null&&_a.vocal_isolation&&(b.hidden=!1)}).catch(()=>{});const label=()=>{t.textContent=showing==="clean"?"Play original":"Play without music"};b.addEventListener("click",async()=>{const id=getId();if(!id){say("Load audio first, then remove the background music.");return}b.disabled=!0,t.hidden=!0,say("Removing background music. This can take a few minutes.");try{const r=await fetch("/api/isolate-vocals",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id})}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);pair={orig:id,origDur:getDur(),clean:d.id,cleanDur:d.duration},showing="clean",apply(d.id,d.duration),t.hidden=!1,label(),say("Background music removed. Use the button next to it to compare with the original.")}catch(e){say("Could not remove background music: "+e.message),toast("Vocal isolation failed: "+e.message,"error")}finally{b.disabled=!1}}),t.addEventListener("click",()=>{pair&&(showing=showing==="clean"?"orig":"clean",showing==="clean"?apply(pair.clean,pair.cleanDur):apply(pair.orig,pair.origDur),label(),say(showing==="clean"?"Playing the version without background music.":"Playing the original audio."))})}(function(root){var _a,_b,_c,_d;const CLONE_SAMPLE_TEXTS={EN:"Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain \u2014 these are the things I love. Can you hear how clearly I speak?",DE:"Hallo! Ich hei\xDFe Alex und das ist meine Stimme. Ich kann leise fl\xFCstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster \u2014 das liebe ich. H\xF6rst du, wie deutlich ich spreche?",IT:"Ciao! Mi chiamo Marco e questa \xE8 la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia \u2014 queste sono le cose che amo. Senti come parlo chiaramente?",ES:"\xA1Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire fr\xEDo del invierno, la c\xE1lida luz del fuego y el suave sonido de la lluvia \u2014 estas son las cosas que amo. \xBFPuedes o\xEDr lo claramente que hablo?",FR:"Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumi\xE8re du feu et le son de la pluie \u2014 voil\xE0 ce que j'aime. Entends-tu comme je parle clairement ?",PT:"Ol\xE1! Meu nome \xE9 Ana e esta \xE9 a minha voz. Posso falar suavemente ou com grande for\xE7a. O ar fresco do inverno, a luz quente do fogo e o som da chuva \u2014 estas s\xE3o as coisas que amo. Consegues ouvir como falo claramente?",NL:"Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen \u2014 dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?",PL:"Cze\u015B\u0107! Mam na imi\u0119 Anna i to jest m\xF3j g\u0142os. Mog\u0119 m\xF3wi\u0107 cicho lub z ca\u0142\u0105 moc\u0105. Mro\u017Ane zimowe powietrze, ciep\u0142e \u015Bwiat\u0142o ognia i d\u017Awi\u0119k deszczu za oknem \u2014 to s\u0105 rzeczy, kt\xF3re kocham. Czy s\u0142yszysz, jak wyra\u017Anie m\xF3wi\u0119?"},CLONE_SAMPLE_NAMES={EN:"Sam",DE:"Alex",IT:"Marco",ES:"Carlos",FR:"Sophie",PT:"Ana",NL:"Laura",PL:"Anna"};function cloneSampleForLang(lang){var _a2;const txt=$("clone-sample-text"),def=CLONE_SAMPLE_NAMES[lang]||"";let t=CLONE_SAMPLE_TEXTS[lang]||CLONE_SAMPLE_TEXTS.EN;const name=(((_a2=$("clone-your-name"))==null?void 0:_a2.value)||"").trim();name&&def&&(t=t.replace(new RegExp("\\b"+def.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b"),name)),txt&&(txt.value=t,txt.dataset.sampleName=name||def)}window.initCloneSampleText=function(){const sel=$("clone-sample-lang"),txt=$("clone-sample-text");!sel||!txt||(cloneSampleForLang(sel.value),sel._cloneSampleBound||(sel.addEventListener("change",()=>cloneSampleForLang(sel.value)),sel._cloneSampleBound=!0))},initCloneSampleText();let ws=null,wsRegions=null,currentFileId2=null,trimmedFileId2=null,designedFileId2=null,editingVoiceId2=null,editingVoicePath2=null;function initWaveSurfer(){ws&&(ws.destroy(),ws=null,wsRegions=null),wsRegions=WaveSurfer.Regions.create(),ws=WaveSurfer.create({container:"#waveform",waveColor:"#45475a",progressColor:"#89b4fa",cursorColor:"#cba6f7",height:90,normalize:!0,plugins:[wsRegions]}),ws.on("ready",()=>{const dur=ws.getDuration();$("trim-end").value=dur.toFixed(2),$("trim-end").max=dur.toFixed(2),$("trim-start").max=dur.toFixed(2),updateRegion()}),wsRegions.on("region-updated",r=>{$("trim-start").value=r.start.toFixed(2),$("trim-end").value=r.end.toFixed(2),updateDurationLabel()})}function updateRegion(){wsRegions.clearRegions();const s=parseFloat($("trim-start").value)||0,e=parseFloat($("trim-end").value)||(ws?ws.getDuration():0);wsRegions.addRegion({start:s,end:e,color:"rgba(137,180,250,0.25)",drag:!0,resize:!0}),updateDurationLabel()}function updateDurationLabel(){const d=Math.max(0,(parseFloat($("trim-end").value)||0)-(parseFloat($("trim-start").value)||0)),el=$("trim-duration");el.textContent=d.toFixed(1)+" s",el.className=d>=5&&d<=20?"dur-ok":d>20?"dur-warn":"dur-bad"}["trim-start","trim-end"].forEach(id=>$(id).addEventListener("input",()=>{ws&&updateRegion()})),$("play-btn").addEventListener("click",()=>{ws&&ws.playPause()}),$("play-selection-btn").addEventListener("click",()=>{ws&&ws.play(parseFloat($("trim-start").value)||0,parseFloat($("trim-end").value)||ws.getDuration())}),$("auto-trim-btn").addEventListener("click",async()=>{if(!currentFileId2){toast("No audio loaded","error");return}$("auto-trim-btn").disabled=!0,status("Finding best TTS reference segment\u2026");try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId2})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)status("Backend auto trim unavailable; analysing audio in browser\u2026"),d=await clientAutoTrimBounds(currentFileId2);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText||"Auto trim failed")}$("trim-start").value=Number(d.start).toFixed(2),$("trim-end").value=Number(d.end).toFixed(2),ws&&updateRegion(),toast("Auto trim set: "+Number(d.duration).toFixed(1)+" s","success"),status(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),status("Auto trim failed")}finally{$("auto-trim-btn").disabled=!1}});function loadAudioId2(id,dur,opts={}){currentFileId2=id,trimmedFileId2=null,designedFileId2=null,editingVoiceId2=opts.editingVoiceId||null,editingVoicePath2=opts.editingVoicePath||null,$("trim-start").value="0",$("trim-end").value=dur.toFixed(2),$("waveform-card").style.display="",initWaveSurfer(),ws.load("/api/audio/"+id),$("save-result").style.display="none",$("trim-audio").style.display="none",$("no-audio-hint").style.display="",editingVoiceId2&&($("voice-id-input").value=editingVoiceId2,$("voice-id-input").dispatchEvent(new Event("input")),$("transcript-area").value=opts.transcript||"",status("Editing existing voice: "+editingVoiceId2))}const dropZone=$("drop-zone"),fileInput=$("file-input");dropZone.addEventListener("click",()=>fileInput.click()),dropZone.addEventListener("dragover",e=>{e.preventDefault(),dropZone.classList.add("drag-over")}),dropZone.addEventListener("dragleave",()=>dropZone.classList.remove("drag-over")),dropZone.addEventListener("drop",e=>{e.preventDefault(),dropZone.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadFile(e.dataTransfer.files[0])}),fileInput.addEventListener("change",()=>{fileInput.files.length&&uploadFile(fileInput.files[0])});async function uploadFile(file){status("Uploading "+file.name+"\u2026");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();throw new Error(e.detail||r.statusText)}const d=await r.json();loadAudioId2(d.id,d.duration),status("Loaded: "+file.name+" ("+d.duration.toFixed(1)+" s)"),toast("File loaded","success")}catch(e){toast("Upload failed: "+e.message,"error"),status("Upload failed")}}async function loadLibraryVoiceAudio2(v){const audioResp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!audioResp.ok){const e=await audioResp.json().catch(()=>({}));throw new Error(e.detail||audioResp.statusText)}const blob=await audioResp.blob(),ext=(v.file_type||"wav").toLowerCase(),fd=new FormData;fd.append("file",new File([blob],`${v.id}.${ext}`,{type:blob.type||"audio/wav"}));const upload=await fetch("/api/upload",{method:"POST",body:fd});if(!upload.ok){const e=await upload.json().catch(()=>({}));throw new Error(e.detail||upload.statusText)}const d=await upload.json();return{id:d.id,voice_id:v.id,duration:d.duration,transcript:v.transcript||"",file_type:ext,path:v.path}}$("yt-btn").addEventListener("click",()=>{const url=$("yt-url").value.trim();if(!url)return;$("yt-btn").disabled=!0,$("yt-progress").textContent="Starting download\u2026";const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),$("yt-progress").textContent=d.error,$("yt-btn").disabled=!1,es.close()):d.done?(es.close(),$("yt-btn").disabled=!1,$("yt-progress").textContent="Done!",loadAudioId2(d.id,d.duration),toast("YouTube audio loaded","success")):($("yt-progress").textContent=d.msg||"",d.pct&&status("Downloading\u2026 "+d.pct+"%"))},es.onerror=()=>{es.close(),$("yt-btn").disabled=!1}});const RAW_MIC_CONSTRAINTS={echoCancellation:!1,noiseSuppression:!1,autoGainControl:!1};async function visibleMicrophoneCount(){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.enumerateDevices))return null;try{return(await navigator.mediaDevices.enumerateDevices()).filter(device=>device.kind==="audioinput").length}catch{return null}}async function microphoneErrorMessage2(error){const name=(error==null?void 0:error.name)||"",message=(error==null?void 0:error.message)||"",lowerMessage=message.toLowerCase(),micCount=await visibleMicrophoneCount();return name==="NotFoundError"||lowerMessage.includes("requested device not found")?micCount===0?"No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.":"The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.":name==="NotAllowedError"||name==="PermissionDeniedError"?"Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.":name==="NotReadableError"?"The microphone is busy or unavailable. Close other apps using it, then try again.":name==="SecurityError"?"Microphone access requires localhost or HTTPS.":message||"Microphone failed."}async function requestMicrophoneStream2(options={}){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.getUserMedia))throw new Error("Microphone requires HTTPS. Open the app via https://... or access it on localhost.");if(!options.raw)return navigator.mediaDevices.getUserMedia({audio:!0});try{return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS})}catch(e){if((e==null?void 0:e.name)==="OverconstrainedError"||(e==null?void 0:e.name)==="NotFoundError")return navigator.mediaDevices.getUserMedia({audio:!0});throw e}}let _cloneMonState={stream:null,recordStream:null,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,waveRing:null,monitoring:!1};function _cloneRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("clone-mic-meter");if(!meter)return;if(!meter.children.length)for(let i=0;i<18;i++){const b=document.createElement("div");b.className="bar",meter.appendChild(b)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))});const el=$("clone-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function _cloneStartMeter(){if(!_cloneMonState.analyser)return;_cloneMonState.meterRaf&&cancelAnimationFrame(_cloneMonState.meterRaf);const data=new Float32Array(_cloneMonState.analyser.fftSize),canvas=$("clone-live-wave"),RING=300,ADD=10;_cloneMonState.waveRing=new Float32Array(RING);const tick=()=>{_cloneMonState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const s of data)sum+=s*s,peak=Math.max(peak,Math.abs(s));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;if(_cloneRenderMeter(level,db,peak>.98),canvas&&_cloneMonState.waveRing){const ring=_cloneMonState.waveRing;ring.copyWithin(0,ADD);for(let i=0;i.98?"#f38ba8":db>-12?"#f9e2af":"#a6e3a1",ctx.lineWidth=1.5;const mid=h/2;for(let i=0;i{try{n&&n.disconnect()}catch{}}),_cloneMonState.stream&&_cloneMonState.stream.getTracks().forEach(t=>t.stop()),_cloneMonState.recordStream&&_cloneMonState.recordStream.getTracks().forEach(t=>t.stop()),_cloneMonState.audioCtx&&_cloneMonState.audioCtx.close().catch(()=>{}),Object.assign(_cloneMonState,{stream:null,recordStream:null,sourceNode:null,gainNode:null,analyser:null,audioCtx:null,monitoring:!1,waveRing:null}),_cloneRenderMeter(0,-1/0,!1);const wc=$("clone-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height),$("clone-monitor-btn")&&($("clone-monitor-btn").disabled=!1),$("clone-monitor-stop")&&($("clone-monitor-stop").disabled=!0)}(_a=$("clone-monitor-btn"))==null||_a.addEventListener("click",async()=>{try{await _cloneStartMonitor(),status("Mic level monitor active")}catch(e){const m=await microphoneErrorMessage2(e);toast(m,"error")}}),(_b=$("clone-monitor-stop"))==null||_b.addEventListener("click",()=>{_cloneStopMonitor(),status("Mic level monitor stopped")}),(_c=$("clone-mic-gain"))==null||_c.addEventListener("input",()=>{const v=parseFloat($("clone-mic-gain").value)||0;$("clone-mic-gain-value")&&($("clone-mic-gain-value").textContent=v.toFixed(2)+"x"),_cloneMonState.gainNode&&(_cloneMonState.gainNode.gain.value=v)}),_cloneRenderMeter();let mediaRec=null,recChunks=[],recTimer=null,recSecs=0;$("rec-start-btn").addEventListener("click",async()=>{try{await _cloneStartMonitor(),recChunks=[],recSecs=0,$("rec-time").textContent="0:00",$("rec-indicator").classList.add("active"),$("rec-start-btn").disabled=!0,$("rec-stop-btn").disabled=!1,recTimer=setInterval(()=>{recSecs++,$("rec-time").textContent=Math.floor(recSecs/60)+":"+String(recSecs%60).padStart(2,"0")},1e3),mediaRec=new MediaRecorder(_cloneMonState.recordStream||_cloneMonState.stream,{audioBitsPerSecond:256e3}),mediaRec.ondataavailable=e=>{e.data.size&&recChunks.push(e.data)},mediaRec.onstop=async()=>{clearInterval(recTimer),$("rec-indicator").classList.remove("active");const blob=new Blob(recChunks,{type:mediaRec.mimeType||"audio/webm"}),ext=(mediaRec.mimeType||"").includes("ogg")?".ogg":".webm";_cloneStopMonitor(),await uploadFile(new File([blob],"recording"+ext,{type:blob.type}))},mediaRec.start(100),status("Recording\u2026")}catch(e){_cloneStopMonitor(),toast(await microphoneErrorMessage2(e),"error")}}),$("rec-stop-btn").addEventListener("click",()=>{mediaRec&&mediaRec.state!=="inactive"&&mediaRec.stop(),$("rec-start-btn").disabled=!1,$("rec-stop-btn").disabled=!0}),$("trim-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;if(!currentFileId2){toast("No audio loaded","error");return}try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId2,start:parseFloat($("trim-start").value)||0,end:parseFloat($("trim-end").value)||0})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();trimmedFileId2=d.id,designedFileId2=null,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",switchTab("save"),toast("Trim done","success"),(_b2=(_a2=$("transcript-area"))==null?void 0:_a2.closest(".card"))==null||_b2.scrollIntoView({behavior:"smooth",block:"center"}),(_c2=window._cloneAutoTranscribe)==null||_c2.call(window)}catch(e){toast("Trim failed: "+e.message,"error")}});const DESIGN_LANG_CODE={Auto:"EN",English:"EN",Chinese:"ZH",Japanese:"JA",Korean:"KO",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Portuguese:"PT",Russian:"RU"},DESIGN_GENDER_WORD={F:"female",M:"male",N:"neutral"},DESIGN_PRESET_KEY="vcf-design-presets",DESIGN_PRESET_SEEDED_KEY="vcf-design-presets-seeded-v2",DEFAULT_DESIGN_PRESETS={EN_M_Young_Energetic:{description:"Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.",sample_text:"Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.",language:"English",gender:"M"},EN_F_Warm_Narrator:{description:"Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.",sample_text:"The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.",language:"English",gender:"F"},DE_M_Elderly_Documentary:{description:"Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.",sample_text:"Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.",language:"German",gender:"M"},DE_F_Young_Friendly:{description:"Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.",sample_text:"Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.",language:"German",gender:"F"},EN_N_Old_Wise_Assistant:{description:"Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.",sample_text:"Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.",language:"English",gender:"N"}},QWEN_DESIGN_SAMPLES={"qwen-timbre-reuse":{title:"Qwen Timbre Reuse",summary:"Reference clip for designing a reusable teen character timbre.",description:"Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous",text:"H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?",language:"English",gender:"M"},"acoustic-sausage-announcer":{title:"Acoustic Attribute Control - British announcer",summary:"Fast, loud, articulate British male delivery with excitement and performative authority.",description:`gender: Male. pitch: Low male pitch with significant upward inflections for emphasis and excitement. speed: Fast-paced delivery with deliberate pauses for dramatic effect. volume: Loud and projecting, increasing notably during moments of praise and announcements. @@ -449,7 +449,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(),dialogue=isDialogueDesign(baseInstruct,sample,currentDesignSource),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,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:loadAudioId,upload:uploadFile},design:{run:runVoiceDesign,buildPayload:voiceDesignPayload}})}(window),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"]},FLAG_LANGUAGE=Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc,langs])=>[cc,langs[0]])),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(),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)}function hideBenchmarkProgress(){$("benchmark-progress").hidden=!0,$("benchmark-progress-bar").style.width="0%",$("benchmark-live-last")&&($("benchmark-live-last").textContent="")}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(),dialogue=isDialogueDesign(baseInstruct,sample,currentDesignSource),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(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"]},FLAG_LANGUAGE=Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc,langs])=>[cc,langs[0]])),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(),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)}function hideBenchmarkProgress(){$("benchmark-progress").hidden=!0,$("benchmark-progress-bar").style.width="0%",$("benchmark-live-last")&&($("benchmark-live-last").textContent="")}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
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=`
@@ -469,7 +469,7 @@ Mia:Only if you promise not to spill coffee on my notes again... though I guess This warms each voice so the engine caches its .pt and first playback is instant.`))return;const btn=$("precompute-embeddings-btn");btn&&(btn.disabled=!0);const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="precompute-overlay",ov.innerHTML=`
Precomputing embeddings
0 / ${ids.length}
-
`,document.body.appendChild(ov);let cancel=!1;ov.querySelector("#pc-cancel").addEventListener("click",()=>{cancel=!0});const fill=ov.querySelector("#pc-fill"),msg=ov.querySelector("#pc-msg");let done=0,ok=0,failed=0;const queue=ids.slice(),worker=async()=>{for(;queue.length&&!cancel;){const id=queue.shift();msg&&(msg.textContent=`${done} / ${ids.length} \xB7 ${id}`);const voiceBackend=typeof _ttsBackendForVoice=="function"?_ttsBackendForVoice(id,backend):backend;try{await fetchTtsPreviewBlob(id,"Hallo.","wav","",voiceBackend),ok++}catch{failed++}done++,fill&&(fill.style.width=done/ids.length*100+"%")}};try{await Promise.all(Array.from({length:Math.min(2,ids.length)},worker))}finally{ov.remove(),btn&&(btn.disabled=!1)}toast(cancel?`Cancelled \u2014 ${ok} warmed`:`Precomputed ${ok} embedding(s)${failed?`, ${failed} skipped/failed`:""}`,!ok&&failed?"error":"success")});const LIB_ADD_SAMPLE_TEXTS={EN:"The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.",DE:"Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.",IT:"La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.",ES:"La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.",FR:"La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.",PT:"A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.",NL:"Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.",PL:"Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie."},LIB_ADD_SAMPLE_STORAGE_KEY="vcf-lib-add-sample-texts";function libAddSampleOverrides(){try{return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY)||"{}")||{}}catch{return{}}}function getLibAddSampleText(code){return libAddSampleOverrides()[code]||LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN}function saveLibAddSampleText(){const code=$("lib-add-sample-lang").value,text=$("lib-add-sample-text").value.trim(),overrides=libAddSampleOverrides();text&&text!==LIB_ADD_SAMPLE_TEXTS[code]?overrides[code]=text:delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),setLibAddStatus("Sample sentence saved")}function resetLibAddSampleText(){const code=$("lib-add-sample-lang").value,overrides=libAddSampleOverrides();delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),$("lib-add-sample-text").value=LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN,setLibAddStatus("Sample sentence reset")}function updateLibAddSampleLanguage(lang){const code=LIB_ADD_SAMPLE_TEXTS[lang]?lang:"EN";$("lib-add-sample-lang").value=code,$("lib-add-lang").value=code,$("lib-add-sample-text").value=getLibAddSampleText(code);const voiceId=$("lib-add-voice-id").value.trim();voiceId&&/^[A-Z]{2}_/.test(voiceId)&&($("lib-add-voice-id").value=voiceId.replace(/^[A-Z]{2}_/,code+"_"))}function renderLibAddMeter(level=0,db=-1/0,clipped=!1){const meter=$("lib-add-mic-meter");if(!meter.children.length)for(let i=0;i<18;i++){const bar=document.createElement("div");bar.className="bar",meter.appendChild(bar)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))}),$("lib-add-db-readout").textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB"}function syncLibAddMicGain(){const gain=parseFloat($("lib-add-mic-gain").value)||0;$("lib-add-mic-gain-value").textContent=gain.toFixed(2)+"x",libAddState.gainNode&&(libAddState.gainNode.gain.value=gain)}function startLibAddMeter(){if(!libAddState.analyser)return;libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf);const data=new Float32Array(libAddState.analyser.fftSize),tick=()=>{libAddState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const sample of data)sum+=sample*sample,peak=Math.max(peak,Math.abs(sample));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;renderLibAddMeter(level,db,peak>.98),libAddState.meterRaf=requestAnimationFrame(tick)};tick()}async function ensureLibAddMicMonitor(){if(libAddState.recordStream)return;const AudioCtx=window.AudioContext||window.webkitAudioContext;if(libAddState.stream=await requestMicrophoneStream({raw:!0}),AudioCtx){libAddState.audioCtx=new AudioCtx,libAddState.sourceNode=libAddState.audioCtx.createMediaStreamSource(libAddState.stream),libAddState.gainNode=libAddState.audioCtx.createGain(),libAddState.analyser=libAddState.audioCtx.createAnalyser(),libAddState.analyser.fftSize=1024;const dest=libAddState.audioCtx.createMediaStreamDestination();syncLibAddMicGain(),libAddState.sourceNode.connect(libAddState.gainNode),libAddState.gainNode.connect(libAddState.analyser),libAddState.gainNode.connect(dest),libAddState.recordStream=dest.stream,startLibAddMeter()}else libAddState.recordStream=libAddState.stream;libAddState.monitoring=!0,$("lib-add-monitor-btn").disabled=!0,$("lib-add-monitor-stop").disabled=!1}function stopLibAddMic(){libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf),libAddState.meterRaf=null,[libAddState.sourceNode,libAddState.gainNode,libAddState.analyser].forEach(node=>{try{node&&node.disconnect()}catch{}}),libAddState.stream&&libAddState.stream.getTracks().forEach(t=>t.stop()),libAddState.recordStream&&libAddState.recordStream.getTracks().forEach(t=>t.stop()),libAddState.audioCtx&&libAddState.audioCtx.close().catch(()=>{}),libAddState.stream=null,libAddState.recordStream=null,libAddState.sourceNode=null,libAddState.gainNode=null,libAddState.analyser=null,libAddState.audioCtx=null,libAddState.monitoring=!1,$("lib-add-monitor-btn").disabled=!1,$("lib-add-monitor-stop").disabled=!0,renderLibAddMeter(0,-1/0,!1)}let libAddState={id:null,duration:0,audio:null,buffer:null,recorder:null,chunks:[],pendingSource:null,stream:null,recordStream:null,timer:null,secs:0,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,monitoring:!1};window.libAddState=libAddState,$("add-new-voice-btn").addEventListener("click",()=>{$("lib-add-panel").classList.toggle("open")}),$("lib-add-sample-lang").addEventListener("change",()=>updateLibAddSampleLanguage($("lib-add-sample-lang").value)),["change","input"].forEach(ev=>$("lib-add-lang").addEventListener(ev,()=>updateLibAddSampleLanguage($("lib-add-lang").value))),$("lib-add-sample-text").addEventListener("input",debounce(saveLibAddSampleText,500)),$("lib-add-use-sample").addEventListener("click",()=>{$("lib-add-transcript").value=$("lib-add-sample-text").value.trim(),setLibAddStatus("Sample sentence copied to transcript")}),$("lib-add-reset-sample").addEventListener("click",resetLibAddSampleText),$("lib-add-mic-help-btn").addEventListener("click",()=>{$("lib-add-mic-help").classList.toggle("open")}),$("lib-add-monitor-btn").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),setLibAddStatus("Mic level monitor active")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message)}}),$("lib-add-monitor-stop").addEventListener("click",()=>{stopLibAddMic(),setLibAddStatus("Mic level monitor stopped")}),$("lib-add-mic-gain").addEventListener("input",syncLibAddMicGain),renderLibAddMeter(),syncLibAddMicGain(),updateLibAddSampleLanguage("EN");function setLibAddStatus(msg){$("lib-add-status").textContent=msg,status(msg)}function suggestLibVoiceId(filename){if($("lib-add-voice-id").value.trim())return;const base=(typeof _umlautSafe=="function"?_umlautSafe(filename||"NewVoice"):String(filename||"NewVoice")).replace(/\.[^.]+$/,"").replace(/[^A-Za-z0-9_-]+/g,"_").replace(/^_+|_+$/g,"").slice(0,60)||"NewVoice";$("lib-add-voice-id").value=`${$("lib-add-lang").value||"EN"}_${$("lib-add-gender").value||"N"}_${base}`}function loadLibAddAudio(id,duration,label="Audio"){libAddState.id=id,libAddState.duration=Number(duration)||0,libAddState.buffer=null,$("lib-add-start").value="0.00",$("lib-add-end").value=libAddState.duration?Math.min(libAddState.duration,20).toFixed(2):"0.00",$("lib-add-audio").src="/api/audio/"+id,$("lib-add-audio").style.display="",$("lib-add-wave").style.display="",attachLibAddWaveSelection(),decodeTempAudio(id).then(buffer=>{libAddState.id===id&&(libAddState.buffer=buffer,drawLibAddWave())}).catch(()=>{}),setLibAddStatus(`${label} loaded${libAddState.duration?" ("+libAddState.duration.toFixed(1)+" s)":""}`)}async function decodeTempAudio(id){const resp=await fetch("/api/audio/"+encodeURIComponent(id));if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const data=await resp.arrayBuffer();return new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(data.slice(0))}function clampLibAddTime(value){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;return Math.max(0,Math.min(duration,Number(value)||0))}function setLibAddCropRange(start,end){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;let a=clampLibAddTime(start),b=clampLibAddTime(end);Math.abs(b-a)<.05&&(b=Math.min(duration,a+Math.min(1,duration||1))),b=3&&dur<=20?"ok":dur?"warn":"")}function drawLibAddWave(){libAddState.buffer&&(drawOptimizerWave($("lib-add-wave"),libAddState.buffer,parseFloat($("lib-add-start").value)||0,parseFloat($("lib-add-end").value)||libAddState.duration||libAddState.buffer.duration),updateLibAddCropHint())}function libAddWaveSelectionPixels(e){var _a2;const rect=$("lib-add-wave").getBoundingClientRect(),duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0,start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||duration),sx=duration&&rect.width?start/duration*rect.width:0,ex=duration&&rect.width?end/duration*rect.width:rect.width;return{x:Math.max(0,Math.min(rect.width,e.clientX-rect.left)),sx,ex,start,end,duration}}function libAddWaveDragMode(e){const{x,sx,ex}=libAddWaveSelectionPixels(e),hit=16;return Math.abs(x-sx)<=hit?"start":Math.abs(x-ex)<=hit?"end":"new"}function attachLibAddWaveSelection(){const canvas=$("lib-add-wave");if(!canvas||canvas.dataset.cropReady)return;canvas.dataset.cropReady="1";let drag=null;canvas.addEventListener("pointerdown",e=>{var _a2;if(!libAddState.buffer)return;e.preventDefault();const mode=libAddWaveDragMode(e),t=libAddWaveTimeFromEvent(e),currentStart=parseFloat($("lib-add-start").value)||0,currentEnd=parseFloat($("lib-add-end").value)||libAddState.duration||0;drag={mode,anchor:t,start:currentStart,end:currentEnd},(_a2=canvas.setPointerCapture)==null||_a2.call(canvas,e.pointerId),mode==="start"?setLibAddCropRange(t,currentEnd):setLibAddCropRange(mode==="end"?currentStart:t,t),setLibAddStatus(mode==="start"?"Dragging crop start handle":mode==="end"?"Dragging crop end handle":"Drag to choose a new crop range")}),canvas.addEventListener("pointermove",e=>{if(!libAddState.buffer)return;if(!drag){const mode=libAddWaveDragMode(e);canvas.style.cursor=mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t)});const finish=e=>{if(!drag)return;e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t),drag=null;const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||0;setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0,end-start).toFixed(1)}s) selected`)};canvas.addEventListener("pointerup",finish),canvas.addEventListener("pointerleave",()=>{drag||(canvas.style.cursor="crosshair")}),canvas.addEventListener("pointercancel",()=>{drag=null,canvas.style.cursor="crosshair"})}async function uploadLibAddFile(file){if(!file)return;const fd=new FormData;fd.append("file",file),setLibAddStatus("Uploading audio\u2026");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();suggestLibVoiceId(file.name),loadLibAddAudio(d.id,d.duration,file.name||"Audio"),toast("Audio loaded","success")}catch(e){toast("Load failed: "+e.message,"error"),setLibAddStatus("Load failed")}}const libAddDrop=$("lib-add-drop");libAddDrop.addEventListener("click",()=>$("lib-add-file").click()),libAddDrop.addEventListener("dragover",e=>{e.preventDefault(),libAddDrop.classList.add("drag-over")}),libAddDrop.addEventListener("dragleave",()=>libAddDrop.classList.remove("drag-over")),libAddDrop.addEventListener("drop",e=>{e.preventDefault(),libAddDrop.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadLibAddFile(e.dataTransfer.files[0])}),$("lib-add-file").addEventListener("change",async()=>{$("lib-add-file").files.length&&await uploadLibAddFile($("lib-add-file").files[0]),$("lib-add-file").value=""}),$("lib-add-url-btn").addEventListener("click",()=>{const url=$("lib-add-url").value.trim();if(!url){toast("Enter a YouTube or audio URL","error");return}$("lib-add-url-btn").disabled=!0,setLibAddStatus("Starting download\u2026");const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),setLibAddStatus(d.error),$("lib-add-url-btn").disabled=!1,es.close()):d.done?(es.close(),$("lib-add-url-btn").disabled=!1,suggestLibVoiceId(url.split("/").pop()||"DownloadedVoice"),loadLibAddAudio(d.id,d.duration,"Downloaded audio"),toast("URL audio loaded","success")):setLibAddStatus(d.msg||"Downloading\u2026")},es.onerror=()=>{es.close(),$("lib-add-url-btn").disabled=!1,setLibAddStatus("Download connection closed")}}),$("lib-add-rec-start").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),libAddState.chunks=[],libAddState.secs=0,$("lib-add-rec-time").textContent="0:00",$("lib-add-rec-start").disabled=!0,$("lib-add-rec-stop").disabled=!1,$("lib-add-monitor-stop").disabled=!0,libAddState.timer=setInterval(()=>{libAddState.secs++,$("lib-add-rec-time").textContent=Math.floor(libAddState.secs/60)+":"+String(libAddState.secs%60).padStart(2,"0")},1e3),libAddState.recorder=new MediaRecorder(libAddState.recordStream),libAddState.recorder.ondataavailable=e=>{e.data.size&&libAddState.chunks.push(e.data)},libAddState.recorder.onstop=async()=>{clearInterval(libAddState.timer),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0;const blob=new Blob(libAddState.chunks,{type:libAddState.recorder.mimeType||"audio/webm"}),ext=(libAddState.recorder.mimeType||"").includes("ogg")?".ogg":".webm";stopLibAddMic(),suggestLibVoiceId("recording"),await uploadLibAddFile(new File([blob],"recording"+ext,{type:blob.type}))},libAddState.recorder.start(100),setLibAddStatus("Recording\u2026")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0}}),$("lib-add-rec-stop").addEventListener("click",()=>{libAddState.recorder&&libAddState.recorder.state!=="inactive"&&libAddState.recorder.stop()}),$("lib-add-auto-trim").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}$("lib-add-auto-trim").disabled=!0;try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)d=await clientAutoTrimBounds(libAddState.id);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}$("lib-add-start").value=Number(d.start).toFixed(2),$("lib-add-end").value=Number(d.end).toFixed(2),drawLibAddWave(),setLibAddStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),setLibAddStatus("Auto trim failed")}finally{$("lib-add-auto-trim").disabled=!1}});async function transcribeLibAddCurrent(successMessage="Text recognised",audioId=libAddState.id){if(!audioId)throw new Error("Load audio first");setLibAddStatus("Recognising text...");const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:audioId})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const text=(await r.json()).text||"";return $("lib-add-transcript").value=text,setLibAddStatus(successMessage),text}function openSavedLibraryVoice(voiceId){const openRow=()=>{var _a2;const row=Array.from(document.querySelectorAll(".vl-row")).find(r=>r.dataset.id===voiceId);return row?(row.scrollIntoView({behavior:"smooth",block:"center"}),row.classList.contains("edit-open")||(_a2=row.querySelector(".edit-audio-btn"))==null||_a2.click(),!0):!1};openRow()||setTimeout(openRow,150)}async function applyLibAddCrop(){if(!libAddState.id){toast("Load audio first","error");return}const start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||libAddState.duration),duration=end-start;if(end<=start+.1){toast("Crop range is too short","error"),setLibAddStatus("Crop range is too short");return}(duration<3||duration>20)&&toast("Best clone references are 3-20 seconds; cropping anyway.","error"),["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!0)}),setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`);try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start,end})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();loadLibAddAudio(d.id,d.duration,"Cropped audio"),toast("Crop applied","success");try{await transcribeLibAddCurrent("Cropped audio loaded and text recognised",d.id)}catch(e){toast("Crop applied, but recognition failed: "+e.message,"error"),setLibAddStatus("Cropped audio loaded; recognition failed")}}catch(e){toast("Crop failed: "+e.message,"error"),setLibAddStatus("Crop failed")}finally{["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!1)})}}$("lib-add-save-crop").addEventListener("click",applyLibAddCrop),$("lib-add-save-crop-bottom").addEventListener("click",applyLibAddCrop),["lib-add-start","lib-add-end"].forEach(id=>$(id).addEventListener("input",drawLibAddWave)),$("lib-add-play").addEventListener("click",()=>{if(!libAddState.id)return;libAddState.audio&&libAddState.audio.pause(),libAddState.audio=new Audio("/api/audio/"+libAddState.id);const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||libAddState.duration;libAddState.audio.currentTime=start,libAddState.audio.ontimeupdate=()=>{libAddState.audio.currentTime>=end&&libAddState.audio.pause()},libAddState.audio.play()}),$("lib-add-recognize").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}try{await transcribeLibAddCurrent("Text recognised")}catch(e){toast("Recognition failed: "+e.message,"error"),setLibAddStatus("Recognition failed")}}),$("lib-add-save").addEventListener("click",async()=>{var _a2,_b2;if(!libAddState.id){toast("Load audio first","error");return}const voiceId=$("lib-add-voice-id").value.trim()||`${$("lib-add-lang").value}_${$("lib-add-gender").value}_NewVoice`;if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}setLibAddStatus("Saving voice..."),$("lib-add-save").disabled=!0;try{const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start:parseFloat($("lib-add-start").value)||0,end:parseFloat($("lib-add-end").value)||libAddState.duration})});if(!pr.ok){const e=await pr.json().catch(()=>({}));throw new Error(e.detail||pr.statusText)}const p=await pr.json();let transcript=$("lib-add-transcript").value.trim();if(!transcript&&(transcript=await transcribeLibAddCurrent("Final clip recognised; saving voice...",p.id),!transcript.trim()))throw new Error("Recognition returned no transcript; add text or try recognising again.");const sr=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:voiceId,transcript})});if(!sr.ok){const e=await sr.json().catch(()=>({}));throw new Error(e.detail||sr.statusText)}if((_a2=libAddState.pendingSource)!=null&&_a2.imageUrl)try{await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,image_url:libAddState.pendingSource.imageUrl})})}catch{}libAddState.pendingSource=null,setLibAddSourcePreview({}),(_b2=$("lib-add-panel"))==null||_b2.classList.remove("open"),toast("Voice saved: "+voiceId,"success"),setLibAddStatus("Voice saved"),await loadVoiceLibrary2(),openSavedLibraryVoice(voiceId)}catch(e){toast("Save failed: "+e.message,"error"),setLibAddStatus("Save failed")}finally{$("lib-add-save").disabled=!1}}),$("show-disabled-cb").addEventListener("change",()=>{$("disabled-info").style.display=$("show-disabled-cb").checked?"":"none",renderVoiceList2()}),["library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{var _a2;(_a2=$(id))==null||_a2.addEventListener("change",()=>{readLibraryFilters(),renderVoiceList2()})}),(_e=$("library-filter-text"))==null||_e.addEventListener("input",debounce(()=>{readLibraryFilters(),renderVoiceList2()},180)),(_f=$("library-filter-tag"))==null||_f.addEventListener("change",function(){window._voiceTagFilter=this.value||null,renderVoiceList2()}),(_g=$("library-filter-group"))==null||_g.addEventListener("change",function(){window._voiceGroupFilter=this.value||null,renderVoiceList2()}),(_h=$("library-clear-filters"))==null||_h.addEventListener("click",()=>{window._voiceTagFilter=null,window._voiceGroupFilter=null;const tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");tagSel&&(tagSel.value=""),groupSel&&(groupSel.value=""),clearLibraryFilters()}),(_i=$("library-tts-backend-select"))==null||_i.addEventListener("change",()=>{var _a2;status("Library TTS engine: "+(((_a2=backendById(libraryTtsBackend()))==null?void 0:_a2.label)||libraryTtsBackend()))});function renderVoiceList2(){var _a2,_b2,_c2,_d2,_e2,_f2;const showDisabled=$("show-disabled-cb").checked,list=$("voice-list");list.innerHTML="",renderVoiceGroupsBar(),populateLibraryFilters(),readLibraryFilters();const _gtBtn=$("voice-group-tag-btn");if(_gtBtn){_gtBtn.classList.toggle("active",!!window._voiceGroupByTag);const ic=_gtBtn.querySelector(".mdi");ic&&(ic.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline")}const _tvBtn=$("voice-table-view-btn");if(_tvBtn){_tvBtn.classList.toggle("active",!!window._voiceTableView),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",!!window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode))}const cat=window._voiceSidebarCat||"all",enabledOk=v=>showDisabled||v.enabled!==!1;let filtered=_voices2.filter(v=>cat==="cloned"?enabledOk(v)&&v.has_ref&&v.origin!=="designed":cat==="designed"?enabledOk(v)&&(v.origin==="designed"||!v.has_ref):cat==="favorites"?enabledOk(v)&&(v.rating||0)>=4:cat==="hidden"?v.enabled===!1:enabledOk(v));window._voiceGroupFilter&&(filtered=filtered.filter(v=>(v.group||"").trim()===window._voiceGroupFilter)),window._voiceTagFilter&&(filtered=filtered.filter(v=>String(v.tag||"").split(",").map(t=>t.trim()).includes(window._voiceTagFilter)));const visibleCount=filtered.length;filtered=filtered.filter(libraryFilterMatch);const filterCount=filtered.length;_libraryIssueFilter&&(filtered=filtered.filter(v=>libraryIssueMatch(v))),$("voice-count").textContent=filtered.length+" / "+_voices2.length+" voices",filtered=filtered.slice().sort((a,b)=>{const av=getSortValue(a,_sortField),bv=getSortValue(b,_sortField);return avbv?_sortDir:0});const pageSignature=JSON.stringify([cat,showDisabled,window._voiceGroupFilter||"",window._voiceTagFilter||"",_libraryIssueFilter||"",_sortField,_sortDir,!!window._voiceTableView,_libraryFilters]);window._voicePageSignature!==pageSignature&&(window._voicePageSignature=pageSignature,window._voicePage=1);const totalFiltered=filtered.length,pageSize=typeof window.ttvcIsLowMemoryDevice=="function"&&window.ttvcIsLowMemoryDevice()?25:50,page=Math.max(1,Number(window._voicePage)||1),pageEnd=Math.min(totalFiltered,page*pageSize),pageItems=filtered.slice(0,pageEnd);if(updateLibraryInsights(),_libraryIssueFilter){const note=document.createElement("div");note.className="library-filter-note",note.innerHTML=`${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}`,note.querySelector("button").addEventListener("click",()=>setLibraryIssueFilter("")),list.appendChild(note)}if(!filtered.length){if(_voices2.length===0){const emptyEl=document.createElement("div");emptyEl.className="voices-empty-state",emptyEl.innerHTML=` +
`,document.body.appendChild(ov);let cancel=!1;ov.querySelector("#pc-cancel").addEventListener("click",()=>{cancel=!0});const fill=ov.querySelector("#pc-fill"),msg=ov.querySelector("#pc-msg");let done=0,ok=0,failed=0;const queue=ids.slice(),worker=async()=>{for(;queue.length&&!cancel;){const id=queue.shift();msg&&(msg.textContent=`${done} / ${ids.length} \xB7 ${id}`);const voiceBackend=typeof _ttsBackendForVoice=="function"?_ttsBackendForVoice(id,backend):backend;try{await fetchTtsPreviewBlob(id,"Hallo.","wav","",voiceBackend),ok++}catch{failed++}done++,fill&&(fill.style.width=done/ids.length*100+"%")}};try{await Promise.all(Array.from({length:Math.min(2,ids.length)},worker))}finally{ov.remove(),btn&&(btn.disabled=!1)}toast(cancel?`Cancelled \u2014 ${ok} warmed`:`Precomputed ${ok} embedding(s)${failed?`, ${failed} skipped/failed`:""}`,!ok&&failed?"error":"success")});const LIB_ADD_SAMPLE_TEXTS={EN:"The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.",DE:"Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.",IT:"La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.",ES:"La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.",FR:"La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.",PT:"A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.",NL:"Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.",PL:"Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie."},LIB_ADD_SAMPLE_STORAGE_KEY="vcf-lib-add-sample-texts";function libAddSampleOverrides(){try{return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY)||"{}")||{}}catch{return{}}}function getLibAddSampleText(code){return libAddSampleOverrides()[code]||LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN}function saveLibAddSampleText(){const code=$("lib-add-sample-lang").value,text=$("lib-add-sample-text").value.trim(),overrides=libAddSampleOverrides();text&&text!==LIB_ADD_SAMPLE_TEXTS[code]?overrides[code]=text:delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),setLibAddStatus("Sample sentence saved")}function resetLibAddSampleText(){const code=$("lib-add-sample-lang").value,overrides=libAddSampleOverrides();delete overrides[code],localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY,JSON.stringify(overrides)),$("lib-add-sample-text").value=LIB_ADD_SAMPLE_TEXTS[code]||LIB_ADD_SAMPLE_TEXTS.EN,setLibAddStatus("Sample sentence reset")}function updateLibAddSampleLanguage(lang){const code=LIB_ADD_SAMPLE_TEXTS[lang]?lang:"EN";$("lib-add-sample-lang").value=code,$("lib-add-lang").value=code,$("lib-add-sample-text").value=getLibAddSampleText(code);const voiceId=$("lib-add-voice-id").value.trim();voiceId&&/^[A-Z]{2}_/.test(voiceId)&&($("lib-add-voice-id").value=voiceId.replace(/^[A-Z]{2}_/,code+"_"))}function renderLibAddMeter(level=0,db=-1/0,clipped=!1){const meter=$("lib-add-mic-meter");if(!meter.children.length)for(let i=0;i<18;i++){const bar=document.createElement("div");bar.className="bar",meter.appendChild(bar)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))}),$("lib-add-db-readout").textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB"}function syncLibAddMicGain(){const gain=parseFloat($("lib-add-mic-gain").value)||0;$("lib-add-mic-gain-value").textContent=gain.toFixed(2)+"x",libAddState.gainNode&&(libAddState.gainNode.gain.value=gain)}function startLibAddMeter(){if(!libAddState.analyser)return;libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf);const data=new Float32Array(libAddState.analyser.fftSize),tick=()=>{libAddState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const sample of data)sum+=sample*sample,peak=Math.max(peak,Math.abs(sample));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;renderLibAddMeter(level,db,peak>.98),libAddState.meterRaf=requestAnimationFrame(tick)};tick()}async function ensureLibAddMicMonitor(){if(libAddState.recordStream)return;const AudioCtx=window.AudioContext||window.webkitAudioContext;if(libAddState.stream=await requestMicrophoneStream({raw:!0}),AudioCtx){libAddState.audioCtx=new AudioCtx,libAddState.sourceNode=libAddState.audioCtx.createMediaStreamSource(libAddState.stream),libAddState.gainNode=libAddState.audioCtx.createGain(),libAddState.analyser=libAddState.audioCtx.createAnalyser(),libAddState.analyser.fftSize=1024;const dest=libAddState.audioCtx.createMediaStreamDestination();syncLibAddMicGain(),libAddState.sourceNode.connect(libAddState.gainNode),libAddState.gainNode.connect(libAddState.analyser),libAddState.gainNode.connect(dest),libAddState.recordStream=dest.stream,startLibAddMeter()}else libAddState.recordStream=libAddState.stream;libAddState.monitoring=!0,$("lib-add-monitor-btn").disabled=!0,$("lib-add-monitor-stop").disabled=!1}function stopLibAddMic(){libAddState.meterRaf&&cancelAnimationFrame(libAddState.meterRaf),libAddState.meterRaf=null,[libAddState.sourceNode,libAddState.gainNode,libAddState.analyser].forEach(node=>{try{node&&node.disconnect()}catch{}}),libAddState.stream&&libAddState.stream.getTracks().forEach(t=>t.stop()),libAddState.recordStream&&libAddState.recordStream.getTracks().forEach(t=>t.stop()),libAddState.audioCtx&&libAddState.audioCtx.close().catch(()=>{}),libAddState.stream=null,libAddState.recordStream=null,libAddState.sourceNode=null,libAddState.gainNode=null,libAddState.analyser=null,libAddState.audioCtx=null,libAddState.monitoring=!1,$("lib-add-monitor-btn").disabled=!1,$("lib-add-monitor-stop").disabled=!0,renderLibAddMeter(0,-1/0,!1)}let libAddState={id:null,duration:0,audio:null,buffer:null,recorder:null,chunks:[],pendingSource:null,stream:null,recordStream:null,timer:null,secs:0,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,monitoring:!1};window.libAddState=libAddState,$("add-new-voice-btn").addEventListener("click",()=>{$("lib-add-panel").classList.toggle("open")}),$("lib-add-sample-lang").addEventListener("change",()=>updateLibAddSampleLanguage($("lib-add-sample-lang").value)),["change","input"].forEach(ev=>$("lib-add-lang").addEventListener(ev,()=>updateLibAddSampleLanguage($("lib-add-lang").value))),$("lib-add-sample-text").addEventListener("input",debounce(saveLibAddSampleText,500)),$("lib-add-use-sample").addEventListener("click",()=>{$("lib-add-transcript").value=$("lib-add-sample-text").value.trim(),setLibAddStatus("Sample sentence copied to transcript")}),$("lib-add-reset-sample").addEventListener("click",resetLibAddSampleText),$("lib-add-mic-help-btn").addEventListener("click",()=>{$("lib-add-mic-help").classList.toggle("open")}),$("lib-add-monitor-btn").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),setLibAddStatus("Mic level monitor active")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message)}}),$("lib-add-monitor-stop").addEventListener("click",()=>{stopLibAddMic(),setLibAddStatus("Mic level monitor stopped")}),$("lib-add-mic-gain").addEventListener("input",syncLibAddMicGain),renderLibAddMeter(),syncLibAddMicGain(),updateLibAddSampleLanguage("EN");function setLibAddStatus(msg){$("lib-add-status").textContent=msg,status(msg)}function suggestLibVoiceId(filename){if($("lib-add-voice-id").value.trim())return;const base=(typeof _umlautSafe=="function"?_umlautSafe(filename||"NewVoice"):String(filename||"NewVoice")).replace(/\.[^.]+$/,"").replace(/[^A-Za-z0-9_-]+/g,"_").replace(/^_+|_+$/g,"").slice(0,60)||"NewVoice";$("lib-add-voice-id").value=`${$("lib-add-lang").value||"EN"}_${$("lib-add-gender").value||"N"}_${base}`}function loadLibAddAudio(id,duration,label="Audio"){libAddState.id=id,libAddState.duration=Number(duration)||0,libAddState.buffer=null,$("lib-add-start").value="0.00",$("lib-add-end").value=libAddState.duration?Math.min(libAddState.duration,20).toFixed(2):"0.00",$("lib-add-audio").src="/api/audio/"+id,$("lib-add-audio").style.display="",$("lib-add-wave").style.display="",attachLibAddWaveSelection(),decodeTempAudio(id).then(buffer=>{libAddState.id===id&&(libAddState.buffer=buffer,drawLibAddWave())}).catch(()=>{}),setLibAddStatus(`${label} loaded${libAddState.duration?" ("+libAddState.duration.toFixed(1)+" s)":""}`)}async function decodeTempAudio(id){const resp=await fetch("/api/audio/"+encodeURIComponent(id));if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const data=await resp.arrayBuffer();return new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(data.slice(0))}function clampLibAddTime(value){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;return Math.max(0,Math.min(duration,Number(value)||0))}function setLibAddCropRange(start,end){var _a2;const duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0;let a=clampLibAddTime(start),b=clampLibAddTime(end);Math.abs(b-a)<.05&&(b=Math.min(duration,a+Math.min(1,duration||1))),b=3&&dur<=20?"ok":dur?"warn":"")}function drawLibAddWave(){libAddState.buffer&&(drawOptimizerWave($("lib-add-wave"),libAddState.buffer,parseFloat($("lib-add-start").value)||0,parseFloat($("lib-add-end").value)||libAddState.duration||libAddState.buffer.duration),updateLibAddCropHint())}function libAddWaveSelectionPixels(e){var _a2;const rect=$("lib-add-wave").getBoundingClientRect(),duration=libAddState.duration||((_a2=libAddState.buffer)==null?void 0:_a2.duration)||0,start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||duration),sx=duration&&rect.width?start/duration*rect.width:0,ex=duration&&rect.width?end/duration*rect.width:rect.width;return{x:Math.max(0,Math.min(rect.width,e.clientX-rect.left)),sx,ex,start,end,duration}}function libAddWaveDragMode(e){const{x,sx,ex}=libAddWaveSelectionPixels(e),hit=16;return Math.abs(x-sx)<=hit?"start":Math.abs(x-ex)<=hit?"end":"new"}function attachLibAddWaveSelection(){const canvas=$("lib-add-wave");if(!canvas||canvas.dataset.cropReady)return;canvas.dataset.cropReady="1";let drag=null;canvas.addEventListener("pointerdown",e=>{var _a2;if(!libAddState.buffer)return;e.preventDefault();const mode=libAddWaveDragMode(e),t=libAddWaveTimeFromEvent(e),currentStart=parseFloat($("lib-add-start").value)||0,currentEnd=parseFloat($("lib-add-end").value)||libAddState.duration||0;drag={mode,anchor:t,start:currentStart,end:currentEnd},(_a2=canvas.setPointerCapture)==null||_a2.call(canvas,e.pointerId),mode==="start"?setLibAddCropRange(t,currentEnd):setLibAddCropRange(mode==="end"?currentStart:t,t),setLibAddStatus(mode==="start"?"Dragging crop start handle":mode==="end"?"Dragging crop end handle":"Drag to choose a new crop range")}),canvas.addEventListener("pointermove",e=>{if(!libAddState.buffer)return;if(!drag){const mode=libAddWaveDragMode(e);canvas.style.cursor=mode==="start"||mode==="end"?"ew-resize":"crosshair";return}e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t)});const finish=e=>{if(!drag)return;e.preventDefault();const t=libAddWaveTimeFromEvent(e);drag.mode==="start"?setLibAddCropRange(t,drag.end):drag.mode==="end"?setLibAddCropRange(drag.start,t):setLibAddCropRange(drag.anchor,t),drag=null;const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||0;setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0,end-start).toFixed(1)}s) selected`)};canvas.addEventListener("pointerup",finish),canvas.addEventListener("pointerleave",()=>{drag||(canvas.style.cursor="crosshair")}),canvas.addEventListener("pointercancel",()=>{drag=null,canvas.style.cursor="crosshair"})}async function uploadLibAddFile(file){if(!file)return;const fd=new FormData;fd.append("file",file),setLibAddStatus("Uploading audio\u2026");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();suggestLibVoiceId(file.name),loadLibAddAudio(d.id,d.duration,file.name||"Audio"),toast("Audio loaded","success")}catch(e){toast("Load failed: "+e.message,"error"),setLibAddStatus("Load failed")}}const libAddDrop=$("lib-add-drop");libAddDrop.addEventListener("click",()=>$("lib-add-file").click()),libAddDrop.addEventListener("dragover",e=>{e.preventDefault(),libAddDrop.classList.add("drag-over")}),libAddDrop.addEventListener("dragleave",()=>libAddDrop.classList.remove("drag-over")),libAddDrop.addEventListener("drop",e=>{e.preventDefault(),libAddDrop.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadLibAddFile(e.dataTransfer.files[0])}),$("lib-add-file").addEventListener("change",async()=>{$("lib-add-file").files.length&&await uploadLibAddFile($("lib-add-file").files[0]),$("lib-add-file").value=""}),$("lib-add-url-btn").addEventListener("click",()=>{const url=$("lib-add-url").value.trim();if(!url){toast("Enter a YouTube or audio URL","error");return}$("lib-add-url-btn").disabled=!0,setLibAddStatus("Starting download\u2026");const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),setLibAddStatus(d.error),$("lib-add-url-btn").disabled=!1,es.close()):d.done?(es.close(),$("lib-add-url-btn").disabled=!1,suggestLibVoiceId(url.split("/").pop()||"DownloadedVoice"),loadLibAddAudio(d.id,d.duration,"Downloaded audio"),toast("URL audio loaded","success")):setLibAddStatus(d.msg||"Downloading\u2026")},es.onerror=()=>{es.close(),$("lib-add-url-btn").disabled=!1,setLibAddStatus("Download connection closed")}}),setupVocalIsolation({btn:"lib-iso-btn",toggle:"lib-iso-toggle",msg:"lib-iso-msg",getId:()=>libAddState.id,getDur:()=>libAddState.duration,apply:(id,dur)=>loadLibAddAudio(id,dur,"Audio")}),$("lib-add-rec-start").addEventListener("click",async()=>{try{await ensureLibAddMicMonitor(),libAddState.chunks=[],libAddState.secs=0,$("lib-add-rec-time").textContent="0:00",$("lib-add-rec-start").disabled=!0,$("lib-add-rec-stop").disabled=!1,$("lib-add-monitor-stop").disabled=!0,libAddState.timer=setInterval(()=>{libAddState.secs++,$("lib-add-rec-time").textContent=Math.floor(libAddState.secs/60)+":"+String(libAddState.secs%60).padStart(2,"0")},1e3),libAddState.recorder=new MediaRecorder(libAddState.recordStream),libAddState.recorder.ondataavailable=e=>{e.data.size&&libAddState.chunks.push(e.data)},libAddState.recorder.onstop=async()=>{clearInterval(libAddState.timer),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0;const blob=new Blob(libAddState.chunks,{type:libAddState.recorder.mimeType||"audio/webm"}),ext=(libAddState.recorder.mimeType||"").includes("ogg")?".ogg":".webm";stopLibAddMic(),suggestLibVoiceId("recording"),await uploadLibAddFile(new File([blob],"recording"+ext,{type:blob.type}))},libAddState.recorder.start(100),setLibAddStatus("Recording\u2026")}catch(e){stopLibAddMic(),$("lib-add-mic-help").classList.add("open");const message=await microphoneErrorMessage(e);toast(message,"error"),setLibAddStatus(message),$("lib-add-rec-start").disabled=!1,$("lib-add-rec-stop").disabled=!0}}),$("lib-add-rec-stop").addEventListener("click",()=>{libAddState.recorder&&libAddState.recorder.state!=="inactive"&&libAddState.recorder.stop()}),$("lib-add-auto-trim").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}$("lib-add-auto-trim").disabled=!0;try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)d=await clientAutoTrimBounds(libAddState.id);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}$("lib-add-start").value=Number(d.start).toFixed(2),$("lib-add-end").value=Number(d.end).toFixed(2),drawLibAddWave(),setLibAddStatus(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),setLibAddStatus("Auto trim failed")}finally{$("lib-add-auto-trim").disabled=!1}});async function transcribeLibAddCurrent(successMessage="Text recognised",audioId=libAddState.id){if(!audioId)throw new Error("Load audio first");setLibAddStatus("Recognising text...");const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:audioId})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const text=(await r.json()).text||"";return $("lib-add-transcript").value=text,setLibAddStatus(successMessage),text}function openSavedLibraryVoice(voiceId){const openRow=()=>{var _a2;const row=Array.from(document.querySelectorAll(".vl-row")).find(r=>r.dataset.id===voiceId);return row?(row.scrollIntoView({behavior:"smooth",block:"center"}),row.classList.contains("edit-open")||(_a2=row.querySelector(".edit-audio-btn"))==null||_a2.click(),!0):!1};openRow()||setTimeout(openRow,150)}async function applyLibAddCrop(){if(!libAddState.id){toast("Load audio first","error");return}const start=clampLibAddTime(parseFloat($("lib-add-start").value)||0),end=clampLibAddTime(parseFloat($("lib-add-end").value)||libAddState.duration),duration=end-start;if(end<=start+.1){toast("Crop range is too short","error"),setLibAddStatus("Crop range is too short");return}(duration<3||duration>20)&&toast("Best clone references are 3-20 seconds; cropping anyway.","error"),["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!0)}),setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`);try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start,end})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();loadLibAddAudio(d.id,d.duration,"Cropped audio"),toast("Crop applied","success");try{await transcribeLibAddCurrent("Cropped audio loaded and text recognised",d.id)}catch(e){toast("Crop applied, but recognition failed: "+e.message,"error"),setLibAddStatus("Cropped audio loaded; recognition failed")}}catch(e){toast("Crop failed: "+e.message,"error"),setLibAddStatus("Crop failed")}finally{["lib-add-save-crop","lib-add-save-crop-bottom"].forEach(id=>{$(id)&&($(id).disabled=!1)})}}$("lib-add-save-crop").addEventListener("click",applyLibAddCrop),$("lib-add-save-crop-bottom").addEventListener("click",applyLibAddCrop),["lib-add-start","lib-add-end"].forEach(id=>$(id).addEventListener("input",drawLibAddWave)),$("lib-add-play").addEventListener("click",()=>{if(!libAddState.id)return;libAddState.audio&&libAddState.audio.pause(),libAddState.audio=new Audio("/api/audio/"+libAddState.id);const start=parseFloat($("lib-add-start").value)||0,end=parseFloat($("lib-add-end").value)||libAddState.duration;libAddState.audio.currentTime=start,libAddState.audio.ontimeupdate=()=>{libAddState.audio.currentTime>=end&&libAddState.audio.pause()},libAddState.audio.play()}),$("lib-add-recognize").addEventListener("click",async()=>{if(!libAddState.id){toast("Load audio first","error");return}try{await transcribeLibAddCurrent("Text recognised")}catch(e){toast("Recognition failed: "+e.message,"error"),setLibAddStatus("Recognition failed")}}),$("lib-add-save").addEventListener("click",async()=>{var _a2,_b2;if(!libAddState.id){toast("Load audio first","error");return}const voiceId=$("lib-add-voice-id").value.trim()||`${$("lib-add-lang").value}_${$("lib-add-gender").value}_NewVoice`;if(!validateVoiceId(voiceId)){toast("Voice ID contains invalid characters","error");return}setLibAddStatus("Saving voice..."),$("lib-add-save").disabled=!0;try{const pr=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:libAddState.id,start:parseFloat($("lib-add-start").value)||0,end:parseFloat($("lib-add-end").value)||libAddState.duration})});if(!pr.ok){const e=await pr.json().catch(()=>({}));throw new Error(e.detail||pr.statusText)}const p=await pr.json();let transcript=$("lib-add-transcript").value.trim();if(!transcript&&(transcript=await transcribeLibAddCurrent("Final clip recognised; saving voice...",p.id),!transcript.trim()))throw new Error("Recognition returned no transcript; add text or try recognising again.");const sr=await fetch("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:p.id,voice_id:voiceId,transcript})});if(!sr.ok){const e=await sr.json().catch(()=>({}));throw new Error(e.detail||sr.statusText)}if((_a2=libAddState.pendingSource)!=null&&_a2.imageUrl)try{await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,image_url:libAddState.pendingSource.imageUrl})})}catch{}libAddState.pendingSource=null,setLibAddSourcePreview({}),(_b2=$("lib-add-panel"))==null||_b2.classList.remove("open"),toast("Voice saved: "+voiceId,"success"),setLibAddStatus("Voice saved"),await loadVoiceLibrary2(),openSavedLibraryVoice(voiceId)}catch(e){toast("Save failed: "+e.message,"error"),setLibAddStatus("Save failed")}finally{$("lib-add-save").disabled=!1}}),$("show-disabled-cb").addEventListener("change",()=>{$("disabled-info").style.display=$("show-disabled-cb").checked?"":"none",renderVoiceList2()}),["library-filter-lang","library-filter-sex","library-filter-type","library-filter-rating"].forEach(id=>{var _a2;(_a2=$(id))==null||_a2.addEventListener("change",()=>{readLibraryFilters(),renderVoiceList2()})}),(_e=$("library-filter-text"))==null||_e.addEventListener("input",debounce(()=>{readLibraryFilters(),renderVoiceList2()},180)),(_f=$("library-filter-tag"))==null||_f.addEventListener("change",function(){window._voiceTagFilter=this.value||null,renderVoiceList2()}),(_g=$("library-filter-group"))==null||_g.addEventListener("change",function(){window._voiceGroupFilter=this.value||null,renderVoiceList2()}),(_h=$("library-clear-filters"))==null||_h.addEventListener("click",()=>{window._voiceTagFilter=null,window._voiceGroupFilter=null;const tagSel=$("library-filter-tag"),groupSel=$("library-filter-group");tagSel&&(tagSel.value=""),groupSel&&(groupSel.value=""),clearLibraryFilters()}),(_i=$("library-tts-backend-select"))==null||_i.addEventListener("change",()=>{var _a2;status("Library TTS engine: "+(((_a2=backendById(libraryTtsBackend()))==null?void 0:_a2.label)||libraryTtsBackend()))});function renderVoiceList2(){var _a2,_b2,_c2,_d2,_e2,_f2;const showDisabled=$("show-disabled-cb").checked,list=$("voice-list");list.innerHTML="",renderVoiceGroupsBar(),populateLibraryFilters(),readLibraryFilters();const _gtBtn=$("voice-group-tag-btn");if(_gtBtn){_gtBtn.classList.toggle("active",!!window._voiceGroupByTag);const ic=_gtBtn.querySelector(".mdi");ic&&(ic.className="mdi mdi-folder"+(window._voiceGroupByTag?"-open":"")+"-outline")}const _tvBtn=$("voice-table-view-btn");if(_tvBtn){_tvBtn.classList.toggle("active",!!window._voiceTableView),(_a2=document.querySelector(".voices-workbench"))==null||_a2.classList.toggle("table-view",!!window._voiceTableView);const editBtn=document.getElementById("voice-table-edit-btn");editBtn&&(editBtn.style.display=window._voiceTableView?"inline-flex":"none",editBtn.classList.toggle("active",!!window._voiceTableEditMode))}const cat=window._voiceSidebarCat||"all",enabledOk=v=>showDisabled||v.enabled!==!1;let filtered=_voices2.filter(v=>cat==="cloned"?enabledOk(v)&&v.has_ref&&v.origin!=="designed":cat==="designed"?enabledOk(v)&&(v.origin==="designed"||!v.has_ref):cat==="favorites"?enabledOk(v)&&(v.rating||0)>=4:cat==="hidden"?v.enabled===!1:enabledOk(v));window._voiceGroupFilter&&(filtered=filtered.filter(v=>(v.group||"").trim()===window._voiceGroupFilter)),window._voiceTagFilter&&(filtered=filtered.filter(v=>String(v.tag||"").split(",").map(t=>t.trim()).includes(window._voiceTagFilter)));const visibleCount=filtered.length;filtered=filtered.filter(libraryFilterMatch);const filterCount=filtered.length;_libraryIssueFilter&&(filtered=filtered.filter(v=>libraryIssueMatch(v))),$("voice-count").textContent=filtered.length+" / "+_voices2.length+" voices",filtered=filtered.slice().sort((a,b)=>{const av=getSortValue(a,_sortField),bv=getSortValue(b,_sortField);return avbv?_sortDir:0});const pageSignature=JSON.stringify([cat,showDisabled,window._voiceGroupFilter||"",window._voiceTagFilter||"",_libraryIssueFilter||"",_sortField,_sortDir,!!window._voiceTableView,_libraryFilters]);window._voicePageSignature!==pageSignature&&(window._voicePageSignature=pageSignature,window._voicePage=1);const totalFiltered=filtered.length,pageSize=typeof window.ttvcIsLowMemoryDevice=="function"&&window.ttvcIsLowMemoryDevice()?25:50,page=Math.max(1,Number(window._voicePage)||1),pageEnd=Math.min(totalFiltered,page*pageSize),pageItems=filtered.slice(0,pageEnd);if(updateLibraryInsights(),_libraryIssueFilter){const note=document.createElement("div");note.className="library-filter-note",note.innerHTML=`${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}`,note.querySelector("button").addEventListener("click",()=>setLibraryIssueFilter("")),list.appendChild(note)}if(!filtered.length){if(_voices2.length===0){const emptyEl=document.createElement("div");emptyEl.className="voices-empty-state",emptyEl.innerHTML=`
diff --git a/static/js/vocal-isolation.js b/static/js/vocal-isolation.js new file mode 100644 index 0000000..9f9198e --- /dev/null +++ b/static/js/vocal-isolation.js @@ -0,0 +1,34 @@ +// Optional "Remove background music" for URL/YouTube imports. Button appears only when the +// server reports features.vocal_isolation. Original stays loadable via the toggle (before/after). +function setupVocalIsolation({ btn, toggle, msg, getId, getDur, apply }) { + const b = $(btn), t = $(toggle), m = $(msg); + let pair = null, showing = 'clean'; + const say = text => { m.textContent = text; status(text); }; + fetch('/api/version').then(r => r.json()).then(v => { if (v?.features?.vocal_isolation) b.hidden = false; }).catch(() => {}); + const label = () => { t.textContent = showing === 'clean' ? 'Play original' : 'Play without music'; }; + b.addEventListener('click', async () => { + const id = getId(); + if (!id) { say('Load audio first, then remove the background music.'); return; } + b.disabled = true; t.hidden = true; + say('Removing background music. This can take a few minutes.'); + try { + const r = await fetch('/api/isolate-vocals', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ id }) }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || r.statusText); + pair = { orig:id, origDur:getDur(), clean:d.id, cleanDur:d.duration }; + showing = 'clean'; apply(d.id, d.duration); + t.hidden = false; label(); + say('Background music removed. Use the button next to it to compare with the original.'); + } catch (e) { + say('Could not remove background music: ' + e.message); + toast('Vocal isolation failed: ' + e.message, 'error'); + } finally { b.disabled = false; } + }); + t.addEventListener('click', () => { + if (!pair) return; + showing = showing === 'clean' ? 'orig' : 'clean'; + if (showing === 'clean') apply(pair.clean, pair.cleanDur); else apply(pair.orig, pair.origDur); + label(); + say(showing === 'clean' ? 'Playing the version without background music.' : 'Playing the original audio.'); + }); +} diff --git a/static/js/voice-clone.js b/static/js/voice-clone.js index 388f691..974d675 100644 --- a/static/js/voice-clone.js +++ b/static/js/voice-clone.js @@ -1133,3 +1133,6 @@ Object.assign(root.TTVC.voiceClone, { 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) }); diff --git a/static/js/voice-library.js b/static/js/voice-library.js index 1a17bf1..1fa4498 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -1646,6 +1646,9 @@ $('lib-add-url-btn').addEventListener('click', () => { }; }); +setupVocalIsolation({ btn:'lib-iso-btn', toggle:'lib-iso-toggle', msg:'lib-iso-msg', + getId:() => libAddState.id, getDur:() => libAddState.duration, apply:(id, dur) => loadLibAddAudio(id, dur, 'Audio') }); + $('lib-add-rec-start').addEventListener('click', async () => { try { await ensureLibAddMicMonitor(); diff --git a/static/module-manifest.json b/static/module-manifest.json index ac0d85c..d2ff0d7 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -11,6 +11,7 @@ "image-cache", "integrations", "routing", + "vocal-isolation", "voice-clone", "voice-library", "tts-preview", diff --git a/static/sections/s-clone.html b/static/sections/s-clone.html index ea60a58..93f4773 100644 --- a/static/sections/s-clone.html +++ b/static/sections/s-clone.html @@ -83,6 +83,11 @@
+
+ + +
+
diff --git a/static/sections/s-voices.html b/static/sections/s-voices.html index 053660e..bd73c58 100644 --- a/static/sections/s-voices.html +++ b/static/sections/s-voices.html @@ -232,6 +232,11 @@ +
+ + +
+
diff --git a/tests/test_vocal_isolation.py b/tests/test_vocal_isolation.py new file mode 100644 index 0000000..f10e050 --- /dev/null +++ b/tests/test_vocal_isolation.py @@ -0,0 +1,119 @@ +"""vocal isolation: flag off, unavailable, success, timeout, cleanup (Demucs mocked).""" +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from pydub import AudioSegment +from pydub.generators import Sine + +from core import vocal_isolation as vi +from core.registry import TEMP_DIR + + +def wav(path, ms=1000): + Sine(300).to_audio_segment(ms).export(str(path), format="wav") + return path + + +class FakeProc: + """Stands in for demucs: writes /htdemucs//vocals.wav, then exits.""" + def __init__(self, args, **kw): + self.never_ends = FakeProc.never_ends + out = Path(args[args.index("-o") + 1]) + if not self.never_ends and FakeProc.write: + d = out / "htdemucs" / Path(args[-1]).stem + d.mkdir(parents=True) + wav(d / "vocals.wav") + self.returncode = None if self.never_ends else FakeProc.rc + never_ends = False + write = True + rc = 0 + def poll(self): + return self.returncode + def kill(self): + self.returncode = -9 + def wait(self): + return self.returncode + + +def env(**kw): + base = {"VOCAL_ISOLATION": "1", "VOCAL_ISOLATION_CMD": "/bin/true"} + base.update(kw) + return mock.patch.dict(os.environ, base) + + +class VocalIsolationTests(unittest.TestCase): + def setUp(self): + self.src = wav(Path(tempfile.mkdtemp()) / "in.wav") + FakeProc.never_ends, FakeProc.write, FakeProc.rc = False, True, 0 + self.w = mock.patch.object(vi, "_weights_present", return_value=True) + self.w.start() + self.addCleanup(self.w.stop) + + def test_flag_off(self): + with mock.patch.dict(os.environ, {"VOCAL_ISOLATION": ""}): + self.assertFalse(vi.available()) + with self.assertRaises(vi.VocalIsolationError) as c: + vi.isolate_vocals(self.src) + self.assertEqual(c.exception.status, 503) + + def test_unavailable_without_weights_or_binary(self): + with env(), mock.patch.object(vi, "_weights_present", return_value=False): + self.assertFalse(vi.available()) + with self.assertRaises(vi.VocalIsolationError) as c: + vi.isolate_vocals(self.src) + self.assertEqual(c.exception.status, 503) + with env(VOCAL_ISOLATION_CMD=""), mock.patch("shutil.which", return_value=None): + self.assertFalse(vi.available()) + + def test_success_and_cleanup(self): + with env(), mock.patch("subprocess.Popen", FakeProc): + self.assertTrue(vi.available()) + out = vi.isolate_vocals(self.src) + self.assertTrue(out.exists()) + self.assertEqual(AudioSegment.from_file(str(out)).frame_rate, 24000) + self.assertTrue(self.src.exists()) # original untouched + self.assertEqual(list(TEMP_DIR.glob("vocal_*")), []) + self.assertEqual(list(TEMP_DIR.glob("*_vocals.*")), []) + + def test_timeout_kills_and_cleans(self): + FakeProc.never_ends = True + with env(VOCAL_ISOLATION_TIMEOUT="0.3"), mock.patch("subprocess.Popen", FakeProc): + with self.assertRaises(vi.VocalIsolationError) as c: + vi.isolate_vocals(self.src) + self.assertEqual(c.exception.status, 504) + self.assertEqual(list(TEMP_DIR.glob("vocal_*")), []) + + def test_cancel(self): + import threading + FakeProc.never_ends = True + ev = threading.Event(); ev.set() + with env(), mock.patch("subprocess.Popen", FakeProc): + with self.assertRaises(vi.VocalIsolationError) as c: + vi.isolate_vocals(self.src, ev) + self.assertEqual(c.exception.status, 499) + + def test_too_long_and_failure_and_busy(self): + with env(VOCAL_ISOLATION_MAX_SECONDS="0.5"): + with self.assertRaises(vi.VocalIsolationError) as c: + vi.isolate_vocals(self.src) + self.assertEqual(c.exception.status, 413) + FakeProc.write, FakeProc.rc = False, 1 + with env(), mock.patch("subprocess.Popen", FakeProc): + with self.assertRaises(vi.VocalIsolationError) as c: + vi.isolate_vocals(self.src) + self.assertEqual(c.exception.status, 500) + self.assertEqual(list(TEMP_DIR.glob("vocal_*")), []) + with env(), vi._busy: + with self.assertRaises(vi.VocalIsolationError) as c: + vi.isolate_vocals(self.src) + self.assertEqual(c.exception.status, 409) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vocal-isolation.spec.js b/tests/vocal-isolation.spec.js new file mode 100644 index 0000000..9ecc8b2 --- /dev/null +++ b/tests/vocal-isolation.spec.js @@ -0,0 +1,51 @@ +// @ts-check +// "Remove background music" in the URL import card (all /api routes mocked). +const { test, expect } = require('@playwright/test'); + +async function open(page, available) { + await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } })); + await page.route('**/api/voices*', route => route.fulfill({ json: [] })); + await page.route('**/api/version', route => route.fulfill({ json: { version: 'x', features: { vocal_isolation: available } } })); + await page.route('**/api/audio/*', route => route.fulfill({ status: 404, body: '' })); + await page.route('**/static/dist/main.min.js*', r => r.abort()); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); +} + +test('button hidden when the server cannot isolate', async ({ page }) => { + await open(page, false); + await expect(page.locator('#lib-iso-btn')).toHaveAttribute('hidden', ''); + await expect(page.locator('#yt-iso-btn')).toHaveAttribute('hidden', ''); +}); + +test('isolate, compare with original, and show errors', async ({ page }) => { + await open(page, true); + /** @type {any[]} */ const calls = []; + let fail = false; + await page.route('**/api/isolate-vocals', route => { + calls.push(route.request().postDataJSON()); + return fail ? route.fulfill({ status: 503, json: { detail: 'Vocal isolation is turned off' } }) + : route.fulfill({ json: { id: 'clean1', duration: 4 } }); + }); + await page.evaluate(() => window.navTo('s-voices')); + const btn = page.locator('#lib-iso-btn'); + await expect(btn).not.toHaveAttribute('hidden', ''); + const msg = page.locator('#lib-iso-msg'); + await expect(msg).toHaveAttribute('aria-live', 'polite'); + await btn.evaluate(el => el.click()); + await expect(msg).toContainText('Load audio first'); + await page.evaluate(() => { libAddState.id = 'orig1'; libAddState.duration = 6; }); + await btn.evaluate(el => el.click()); + await expect(msg).toContainText('Background music removed'); + expect(calls).toEqual([{ id: 'orig1' }]); + expect(await page.evaluate(() => libAddState.id)).toBe('clean1'); + const toggle = page.locator('#lib-iso-toggle'); + await expect(toggle).toHaveText('Play original'); + await toggle.evaluate(el => el.click()); + expect(await page.evaluate(() => libAddState.id)).toBe('orig1'); + await expect(toggle).toHaveText('Play without music'); + fail = true; + await btn.evaluate(el => el.click()); + await expect(msg).toContainText('turned off'); +});