diff --git a/CHANGELOG.md b/CHANGELOG.md index f90fdcc..d03625b 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 +- **LibriVox and Wikimedia Commons voice sources.** New tab in Get Voices Online searches the official LibriVox feed and the Commons MediaWiki API through `GET /api/voice-sources/{librivox|wikimedia}?q=&lang=` (fixed hosts, query cap 80, 10 results, 30 s timeout, 10 min cache, 1 s throttle, descriptive User-Agent, result URLs host-checked). Results show licence and a preview; "Import clip" trims (start/length) and stores provenance (LibriVox: public domain + check-the-reader note; Wikimedia: licence + attribution). `archive.org` and `upload.wikimedia.org` added to the audio proxy allowlist. Tests: `tests/test_voice_sources_new.py`, `tests/voice-sources-new.spec.js`. See `architecture/voice-sources.md`. - **Provenance tags on exported audio.** Magazine MP3 downloads, `audio.zip` and the MP3s inside the EPUB get ID3 tags (`synthetic_speech=true`, `generator`, `generator_version`, `generated_date`, comment; voice name only on opt-in) via one ffmpeg stream copy of a temp copy; stored audio is untouched. Per-export checkboxes on the magazine export row ("Mark as synthetic speech", "include voice name"), default from new setting `export_provenance_default` (on). `POST /api/export/provenance` and `python -m core.export_provenance file.mp3` read the tags back. Inaudible AudioSeal watermarking is researched (MIT code and weights, torch needed, weak message recovery after MP3/AAC) but only a stub `watermark(path)` that reports unavailable. Spoken disclosure is not implemented yet. Tests: `tests/test_watermark_tags.py`, `tests/magazine.spec.js`. See `architecture/watermarking.md`. - **Inline text tags for TTS (setting `inline_tags_enabled`, default OFF).** `[pause 500ms]`/`[pause 1.2s]` (max 10 s each, 60 s per text), `[spell]USA[/spell]` (German letter names for German text), `[slow]..[/slow]` / `[fast]..[/fast]` (0.85 / 1.15 via ffmpeg atempo, innermost wins, unclosed ends at paragraph). Known control tags (e.g. `[laugh]`) are stripped, never spoken; `[1]` and `[sic]` stay. Rendered in `POST /api/tts-preview` (also used by audiobook line previews), `/v1/audio/speech` (non-streaming), `/speak` and the MCP speak tool through one shared `ssml_lite.render_with_tags`; stripped only (never spoken) in streaming, spoken chat replies and magazine MP3 (timings stay exact). Settings -> General switch (accessible, DE/EN) and a syntax hint under the Try It Out and Read Aloud text boxes. Tests: `tests/test_ssml_lite.py`, `tests/test_inline_tags_paths.py`, `tests/inline-tags.spec.js`. - **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`. diff --git a/Manual.md b/Manual.md index d8ef41b..1d90b14 100644 --- a/Manual.md +++ b/Manual.md @@ -45,6 +45,17 @@ Errors: damaged, tampered, oversized or unexpected files are rejected with a mes Mobile / accessibility: both buttons are at least 44 px high and keyboard operable; the file chooser is the browser's own. Offline: works fully offline. Privacy: a bundle contains the voice, its source/consent notes and any consent recording; share it only with people you trust. +## LibriVox and Wikimedia Commons voices + +Purpose: find short speech samples with an open licence and import a clip as a voice. +Prerequisites: Voice library; internet access from the server. +Steps: Get Voices Online -> tab "LibriVox & Wikimedia"; choose source, type a term (LibriVox matches the start of a title; the language filter works for LibriVox only), press Search (can take about 15 s). Play the preview, set clip start and length (seconds), press "Import clip". +Result: the clip is saved as a voice; its "Source & consent" shows source page, licence and, for Wikimedia, the attribution text. LibriVox is recorded as public domain with a reminder to check the reader's licence; Wikimedia as licensed (or public domain). +Errors: shown in the status line under the search box (bad input, source unavailable, trim outside the audio). +Licences: CC BY / CC BY-SA need attribution when you publish results; read `architecture/voice-sources.md` before publishing anything cloned from these voices. +Mobile/accessibility: 44 px controls, labelled fields, status is announced, previews are native audio players. +Offline: needs internet. Privacy: your search term goes to librivox.org / archive.org or commons.wikimedia.org / upload.wikimedia.org; nothing else. + ## Voice source & consent Purpose: record where a voice comes from and why you may use it (own voice, speaker consented, licensed, public domain, unknown). diff --git a/architecture/voice-sources.md b/architecture/voice-sources.md new file mode 100644 index 0000000..aca203d --- /dev/null +++ b/architecture/voice-sources.md @@ -0,0 +1,30 @@ +# Voice sources: LibriVox and Wikimedia Commons + +Status: implemented. Code: `core/open_voice_sources.py`, routes in `routes/sources.py`, UI in `static/js/voice-sources.js` (tab "LibriVox & Wikimedia" in Get Voices Online). + +## Verified API facts (checked 2026-10-04, docs and live calls) +LibriVox +- `GET https://librivox.org/api/feed/audiobooks?title=^&format=json&extended=1&limit=N`. A bare `title=` is an EXACT match (404 `{"error":...}` otherwise); the `^` prefix gives a prefix match. No language parameter: the language is filtered client-side on `language` ("English"). `extended=1` adds `sections[]` with `listen_url` (archive.org MP3, 64 kbps), `playtime` (s) and `readers[]`. +- The docs state no rate limit and no terms. The extended feed is slow (about 14 s for 5-25 books), so timeout is 30 s, limit 15, results cached 10 min, one upstream call per second. +- Licence (librivox.org/pages/public-domain): recordings are donated to the public domain, use without permission or attribution allowed (credit appreciated). Caveats: guaranteed public domain only under US law; source texts are pre-1923 works; translations are separate works; readers are volunteers and the page of a project may show a reader-specific note, so the UI/provenance note tells the user to check the reader's declared licence. A reader's voice is still a person's voice: public domain is not consent for voice cloning of a recognisable person; the provenance basis is a record, not a legal clearance. +- Audio is hosted on archive.org (`www.archive.org/download/...` redirects to `iaNNN.us.archive.org`). + +Wikimedia Commons +- `GET https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=filetype:audio &prop=imageinfo&iiprop=url|extmetadata|mediatype&maxlag=5`. Per-file `extmetadata`: `LicenseShortName`, `LicenseUrl`, `Artist` (HTML, stripped), `AttributionRequired`. `inlanguage:` does not filter audio files, so `lang` is ignored for Wikimedia. +- Etiquette (mediawiki.org API:Etiquette): descriptive User-Agent with contact info is mandatory, requests serial, use `maxlag`, back off on errors; clients can be blocked without notice. Our UA is `TTSVoiceCreator/1.0 (self-hosted voice library; official-API client)` (no personal data; Wikimedia prefers a contact URL/email, set one via a future env var if blocked or high volume). +- Licences are per file. Attribution duties: CC BY and CC BY-SA require author, title, licence (with link) and a note of changes (trimming/cloning counts); CC BY-SA also requires derivatives under the same licence (a cloned voice model is arguably a derivative: treat as unresolved, prefer CC0/public domain/CC BY for anything published). Public domain/CC0: none. Some files carry non-copyright restrictions (personality rights). + +## NOT safe / not done +- No HTML scraping of librivox.org, archive.org or commons pages; JSON APIs only. +- No user-supplied URLs: API hosts are constants; every URL from a response must be https on an allowlisted host (`archive.org`, `upload.wikimedia.org`, page hosts `librivox.org`, `commons.wikimedia.org`) or the item is dropped. `quick-import-voice` with `source_kind` re-checks the audio host. +- Redirects are not followed for API calls; generic `/api/proxy-audio` (existing) follows redirects and now also allows the two audio hosts. +- Files without a licence in `extmetadata` are dropped. The licence string is trusted from Commons metadata and can be wrong; the user must still check. +- Licence/attribution values sent by the browser at import are only length-capped, not re-fetched (single-user LAN tool). + +## Endpoints +`GET /api/voice-sources/{librivox|wikimedia}?q=&lang=` : `q` required, max 80 chars, control chars stripped; `lang` 2-3 letters; max 10 items; 400 on bad input, 502 with a generic message on upstream failure. Item: `{source, title, reader, author, language, duration (s), licence, licence_url, source_url, audio_url, attribution}`. Privacy: the query goes to librivox.org / commons.wikimedia.org; previews and import go to archive.org / upload.wikimedia.org (via this server). + +`POST /api/quick-import-voice` accepts `source_kind` (`librivox`|`wikimedia`), `licence`, `attribution`, `page_url`, `start`, `length` (clip, 3-120 s, default 0/30). Provenance: LibriVox -> `public_domain`, source = book page, note says to check the reader's licence. Wikimedia -> `licensed` (or `public_domain` when licence says public domain/CC0), licence, note `Attribution: ...`; `meta.attribution` also stored. + +## Cache and throttle +In-process dict, TTL 600 s, max 200 entries (evict oldest); per-source lock with at least 1 s between upstream calls. Lost on restart; fine for one container. diff --git a/core/open_voice_sources.py b/core/open_voice_sources.py new file mode 100644 index 0000000..fe4ae83 --- /dev/null +++ b/core/open_voice_sources.py @@ -0,0 +1,196 @@ +"""LibriVox and Wikimedia Commons voice search (official APIs only, fixed hosts). See architecture/voice-sources.md.""" +from __future__ import annotations + +import html +import re +import threading +import time +from urllib.parse import urlsplit + +import requests + +from core.provenance import validate_provenance + +LIBRIVOX_API = "https://librivox.org/api/feed/audiobooks" +COMMONS_API = "https://commons.wikimedia.org/w/api.php" +USER_AGENT = "TTSVoiceCreator/1.0 (self-hosted voice library; official-API client)" # no personal data +MAX_Q = 80 +MAX_RESULTS = 10 +MAX_BYTES = 8 * 1024 * 1024 +TIMEOUT = 30 # the LibriVox extended feed takes ~14 s +CACHE_TTL = 600 +CACHE_MAX = 200 +MIN_INTERVAL = 1.0 # seconds between upstream calls per source (MediaWiki asks for serial requests) + +# host suffixes allowed in result URLs (checked on every URL taken from an API response) +AUDIO_HOSTS = ("archive.org", "upload.wikimedia.org") +PAGE_HOSTS = ("librivox.org", "archive.org", "commons.wikimedia.org") +SOURCES = ("librivox", "wikimedia") +LANG_NAMES = { + "en": "English", "de": "German", "fr": "French", "es": "Spanish", "it": "Italian", "pt": "Portuguese", + "nl": "Dutch", "pl": "Polish", "ru": "Russian", "ja": "Japanese", "zh": "Chinese", "sv": "Swedish", +} +_CTRL = re.compile(r"[\x00-\x1f\x7f]") +_TAGS = re.compile(r"<[^>]*>") + +_cache: dict[tuple, tuple[float, list]] = {} +_last_call: dict[str, float] = {} +_locks = {s: threading.Lock() for s in SOURCES} +_cache_lock = threading.Lock() + + +class SourceError(Exception): + """Bad input (400) or upstream failure (502); message is safe to show.""" + def __init__(self, msg: str, status: int = 400): + super().__init__(msg) + self.status = status + + +def host_ok(url: str, suffixes: tuple[str, ...]) -> bool: + try: + p = urlsplit(str(url)) + h = (p.hostname or "").lower() + except ValueError: + return False + return p.scheme == "https" and not p.username and not p.password and any(h == s or h.endswith("." + s) for s in suffixes) + + +def clean_query(q: str) -> str: + q = _CTRL.sub(" ", str(q or "")).strip() + q = re.sub(r"\s+", " ", q) + if not q: + raise SourceError("q is required") + if len(q) > MAX_Q: + raise SourceError(f"q is too long (max {MAX_Q})") + return q + + +def clean_lang(lang: str) -> str: + lang = str(lang or "").strip().lower() + if lang and not re.fullmatch(r"[a-z]{2,3}", lang): + raise SourceError("lang must be a 2-3 letter code") + return lang + + +def _text(value, limit=200) -> str: + return _CTRL.sub(" ", html.unescape(_TAGS.sub("", str(value or "")))).strip()[:limit] + + +def _get_json(url: str, params: dict) -> dict: + wait = 0.0 + src = "librivox" if url == LIBRIVOX_API else "wikimedia" + with _locks[src]: + wait = MIN_INTERVAL - (time.monotonic() - _last_call.get(src, 0.0)) + if wait > 0: + time.sleep(wait) + _last_call[src] = time.monotonic() + try: + r = requests.get(url, params=params, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}, + timeout=TIMEOUT, allow_redirects=False, stream=True) + if r.status_code == 404 and src == "librivox": + return {} + r.raise_for_status() + body = b"" + for chunk in r.iter_content(65536): + body += chunk + if len(body) > MAX_BYTES: + raise SourceError("Upstream response too large", 502) + import json + return json.loads(body.decode("utf-8", "replace")) + except SourceError: + raise + except Exception: + raise SourceError(f"{src} is unavailable right now", 502) + + +def _librivox_items(q: str, lang: str) -> list[dict]: + data = _get_json(LIBRIVOX_API, {"title": "^" + q, "format": "json", "extended": 1, "limit": 15}) # "^" = prefix match; bare title is exact + want = LANG_NAMES.get(lang, "").lower() + out = [] + for b in (data.get("books") or []) if isinstance(data, dict) else []: + if not isinstance(b, dict): + continue + if want and str(b.get("language") or "").lower() != want: + continue + secs = [s for s in (b.get("sections") or []) if isinstance(s, dict) and host_ok(s.get("listen_url") or "", AUDIO_HOSTS)] + src_url = b.get("url_librivox") or "" + if not secs or not host_ok(src_url, PAGE_HOSTS): + continue + s = secs[0] + readers = ", ".join(_text(r.get("display_name"), 60) for r in (s.get("readers") or []) if isinstance(r, dict)) + authors = ", ".join(_text(f"{a.get('first_name', '')} {a.get('last_name', '')}", 60) for a in (b.get("authors") or []) if isinstance(a, dict)) + try: + dur = int(s.get("playtime") or 0) + except (TypeError, ValueError): + dur = 0 + out.append({ + "source": "librivox", "title": _text(f"{b.get('title')} - {s.get('title')}"), "reader": readers, "author": authors, + "language": _text(b.get("language"), 40), "duration": dur, + "licence": "Public domain (LibriVox, USA)", "licence_url": "https://librivox.org/pages/public-domain/", + "source_url": src_url, "audio_url": s["listen_url"], "attribution": "", + }) + if len(out) >= MAX_RESULTS: + break + return out + + +def _wikimedia_items(q: str) -> list[dict]: + data = _get_json(COMMONS_API, { + "action": "query", "format": "json", "generator": "search", "gsrnamespace": 6, "gsrlimit": 20, + "gsrsearch": f"filetype:audio {q}", "prop": "imageinfo", "iiprop": "url|extmetadata|mediatype", "maxlag": 5, + }) + pages = ((data.get("query") or {}).get("pages") or {}) if isinstance(data, dict) else {} + out = [] + for p in sorted((x for x in pages.values() if isinstance(x, dict)), key=lambda x: x.get("index", 0)): + info = (p.get("imageinfo") or [{}])[0] + meta = info.get("extmetadata") or {} + mv = lambda k: (meta.get(k) or {}).get("value", "") + lic = _text(mv("LicenseShortName"), 80) + audio, page = info.get("url") or "", info.get("descriptionurl") or "" + if info.get("mediatype") != "AUDIO" or not lic or not host_ok(audio, AUDIO_HOSTS) or not host_ok(page, PAGE_HOSTS): + continue + licurl = mv("LicenseUrl") if host_ok(mv("LicenseUrl"), ("creativecommons.org", "wikimedia.org")) else "" + title = _text(re.sub(r"^File:|\.[A-Za-z0-9]+$", "", str(p.get("title") or ""))) + artist = _text(mv("Artist"), 120) + out.append({ + "source": "wikimedia", "title": title, "reader": artist, "author": artist, "language": "", + "duration": round(float(info.get("duration") or 0)), "licence": lic, "licence_url": licurl, + "source_url": page, "audio_url": audio, + "attribution": _text(f"{artist + ' / ' if artist else ''}{title}, {lic}, via Wikimedia Commons", 300), + }) + if len(out) >= MAX_RESULTS: + break + return out + + +def search(source: str, q: str, lang: str = "") -> list[dict]: + if source not in SOURCES: + raise SourceError("unknown source") + q, lang = clean_query(q), clean_lang(lang) + key = (source, q.lower(), lang if source == "librivox" else "") + now = time.monotonic() + with _cache_lock: + hit = _cache.get(key) + if hit and now - hit[0] < CACHE_TTL: + return hit[1] + items = _librivox_items(q, lang) if source == "librivox" else _wikimedia_items(q) + with _cache_lock: + if len(_cache) >= CACHE_MAX: + _cache.pop(min(_cache, key=lambda k: _cache[k][0])) + _cache[key] = (now, items) + return items + + +def import_provenance_for(kind: str, source_url: str, licence: str, attribution: str) -> dict: + """Provenance prefill for an import from one of these sources (raises ValueError on bad input).""" + if kind == "librivox": + return validate_provenance({ + "basis": "public_domain", "source": source_url, "licence": "Public domain (LibriVox)", + "note": "LibriVox recordings are donated to the public domain (guaranteed in the USA only). " + "Check the reader's declared licence on the LibriVox page before use."}) + if kind == "wikimedia": + pd = re.search(r"public domain|\bcc0\b|\bpd\b", licence, re.I) + return validate_provenance({ + "basis": "public_domain" if pd else "licensed", "source": source_url, "licence": licence, + "note": ("Attribution: " + attribution)[:1000]}) + raise ValueError("unknown source_kind") diff --git a/routes/sources.py b/routes/sources.py index e0b7735..4ba6ba0 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -153,6 +153,8 @@ _PROXY_AUDIO_DOMAINS: set[str] = { "sample-files.com", "platform.r2.fish.audio", "fish.audio", + "archive.org", + "upload.wikimedia.org", } _GDRIVE_MIME_EXT = { "audio/mpeg": "mp3", "audio/mp3": "mp3", @@ -533,6 +535,15 @@ async def voice_sources_custom(request: Request): return _voice_sources_payload(data.get("urls") if isinstance(data, dict) else None) +@router.get("/api/voice-sources/{source}") +async def open_voice_source_search(source: str, q: str = "", lang: str = ""): + from core import open_voice_sources as ovs + try: + return {"source": source, "items": await asyncio.to_thread(ovs.search, source, q, lang)} + except ovs.SourceError as e: + raise HTTPException(e.status, str(e)) + + @router.post("/api/import-source-audio") async def import_source_audio(request: Request): data = await request.json() @@ -594,6 +605,16 @@ async def proxy_audio(url: str): async def quick_import_voice(request: Request): data = await request.json() audio_url = _source_import_url(str(data.get("audio_url") or "")) + source_kind = str(data.get("source_kind") or "") + if source_kind: + from core import open_voice_sources as ovs + if not ovs.host_ok(audio_url, ovs.AUDIO_HOSTS): + raise HTTPException(400, "audio_url host is not allowed for this source") + try: + kind_prov = ovs.import_provenance_for( + source_kind, str(data.get("page_url") or ""), str(data.get("licence") or ""), str(data.get("attribution") or "")) + except ValueError as e: + raise HTTPException(400, str(e)) voice_id = re.sub(r"[^A-Za-z0-9_\-\.]", "_", str(data.get("voice_id") or "").strip())[:80] if not voice_id: raise HTTPException(400, "voice_id is required") @@ -619,6 +640,17 @@ async def quick_import_voice(request: Request): raise HTTPException(413, "Downloaded audio is too large") f.write(chunk) wav = _to_wav_24k(dest) + if source_kind: # sample clip: trim to [start, start+length] (length 3..120 s) + from pydub import AudioSegment + try: + t0 = max(0.0, float(data.get("start") or 0)) + ln = min(120.0, max(3.0, float(data.get("length") or 30))) + except (TypeError, ValueError): + raise HTTPException(400, "start/length must be numbers") + seg = AudioSegment.from_wav(str(wav))[int(t0 * 1000):int((t0 + ln) * 1000)] + if len(seg) < 1000: + raise HTTPException(400, "Trim range is outside the audio") + seg.export(str(wav), format="wav") except HTTPException: raise except Exception as e: @@ -653,7 +685,11 @@ async def quick_import_voice(request: Request): if data.get(field) is not None: meta[field] = str(data.get(field) or "").strip()[:500] from core.provenance import import_provenance - meta["provenance"] = import_provenance(str(data.get("page_url") or audio_url)) # basis stays "unknown" + if source_kind: + meta["provenance"] = kind_prov + meta["attribution"] = str(data.get("attribution") or "")[:500] + else: + meta["provenance"] = import_provenance(str(data.get("page_url") or audio_url)) # basis stays "unknown" if auto_prepare and not transcript: meta["reference_transcript_status"] = "pending" meta["import_preparation"] = "queued" diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 481fece..9c599cc 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -184,8 +184,8 @@ `,document.body.appendChild(ov);const box=ov.querySelector(".audiobook-box"),fromI=ov.querySelector(".sb-from"),toI=ov.querySelector(".sb-to"),est=ov.querySelector(".sb-estimate"),updEst=()=>{const f=Math.max(0,parseInt(fromI.value,10)||0),t=Math.max(f,parseInt(toI.value,10)||f);est.textContent=`${ids.length} voices \xD7 ${t-f+1} seeds = up to ${ids.length*(t-f+1)} samples`};fromI.addEventListener("input",updEst),toI.addEventListener("input",updEst),updEst(),ov.querySelector("#sb-cancel-cfg").addEventListener("click",()=>ov.remove()),ov.querySelector("#sb-start").addEventListener("click",()=>{const from=Math.max(0,parseInt(fromI.value,10)||1),to=Math.max(from,parseInt(toI.value,10)||8);_seedBatchRun(ids,from,to,box,ov)})}async function _seedBatchRun(ids,from,to,box,ov){const totalJobs=ids.length*(to-from+1);box.innerHTML=`
Batch seed generation
Starting\u2026
-
`;let cancel=!1;box.querySelector("#sb-cancel").addEventListener("click",()=>{cancel=!0});const fill=box.querySelector("#sb-fill"),msg=box.querySelector("#sb-msg");let done=0,made=0,cached=0,failed=0;for(let vi=0;vi0?headerDur:Math.max(0,(blob.size-44)/(24e3*2)),clipped=String(resp.headers.get("X-TTS-Audio-Clipped")||"").toLowerCase()==="true",rtf=dur>0?genSec/dur:0;await _sfDbPut({key,cacheVersion:SF_CACHE_VERSION,voiceId,backend,textHash:th,seed,blob,dur,genSec,rtf,clipped,ts:Date.now()}),made++}catch{failed++}done++,fill.style.width=done/totalJobs*100+"%"}}ov.remove(),typeof toast=="function"&&toast(cancel?`Stopped \u2014 ${made} generated, ${cached} already cached`:`Batch done \u2014 ${made} generated, ${cached} cached${failed?`, ${failed} failed`:""}`,cancel?"error":"success")}typeof $=="function"&&((_a=$("seed-batch-all-btn"))==null||_a.addEventListener("click",seedFinderBatchAll)),Object.assign(root,{attachSeedFinder:attachSeedFinder2,seedFinderBatchAll}),root.TTVC=root.TTVC||{},root.TTVC.seedFinder=root.TTVC.seedFinder||{},Object.assign(root.TTVC.seedFinder,{attach:attachSeedFinder2,batchAll:seedFinderBatchAll})}(window),function(root){var _a,_b,_c;const DEFAULT_VOICE_SOURCE_URLS=["https://aiartes.com/voiceai","https://sample-files.com/downloads/audio/wav/voice-sample.wav","https://freesound.org/people/Scott%20Simpson/","https://lanceblairvo.com/raw-voiceover-samples/","https://github.com/yaph/tts-samples/tree/main/mp3","https://github.com/jim-schwoebel/voice_datasets"],VOICE_SOURCE_STORAGE_KEY="ttsvc-getvoices-sources";let _voiceSourcePayload=null,_voiceSourceItems=[];function sourceTextareaValueFromDefaults(){return DEFAULT_VOICE_SOURCE_URLS.join(` -`)}function initGetVoiceSourcesEditor(){const box=$("getvoices-sources");!box||box.dataset.ready||(box.value=localStorage.getItem(VOICE_SOURCE_STORAGE_KEY)||sourceTextareaValueFromDefaults(),box.dataset.ready="1",box.addEventListener("input",()=>{localStorage.setItem(VOICE_SOURCE_STORAGE_KEY,box.value),_voiceSourcePayload=null,$("getvoices-status").textContent="Source list changed."}))}function getEditableVoiceSourceUrls(){var _a2;return initGetVoiceSourcesEditor(),(((_a2=$("getvoices-sources"))==null?void 0:_a2.value)||"").split(/\r?\n/).map(line=>line.trim()).filter(line=>line&&!line.startsWith("#"))}function getVoiceSourceItems(){return(_voiceSourcePayload&&_voiceSourcePayload.sources||[]).flatMap(src=>(src.items||[]).map(item=>({...item,_sourceName:src.name,_sourceHomepage:src.homepage})))}function voiceSourceSearchText(item){return[item.name,item.kind,item.category,item.language,item.gender,item.description,item.source,item._sourceName].join(" ").toLowerCase()}function setOptions(selectId,values,allLabel){const sel=$(selectId);if(!sel)return;const current=sel.value||"all";sel.innerHTML=``+values.map(value=>``).join(""),sel.value=values.includes(current)?current:"all"}function renderGetVoices(){var _a2,_b2,_c2,_d,_e,_f;initGetVoiceSourcesEditor();const list=$("getvoices-list"),summary=$("getvoices-summary");if(!list||!summary)return;const payload=_voiceSourcePayload||{sources:[],total:0,direct_audio:0,errors:[]},sources=payload.sources||[],sourceFilter=((_a2=$("getvoices-source-filter"))==null?void 0:_a2.value)||"all",languageFilter=((_b2=$("getvoices-language-filter"))==null?void 0:_b2.value)||"all",genderFilter=((_c2=$("getvoices-gender-filter"))==null?void 0:_c2.value)||"all",filetypeFilter=((_d=$("getvoices-filetype-filter"))==null?void 0:_d.value)||"all",q=(((_e=$("getvoices-search"))==null?void 0:_e.value)||"").trim().toLowerCase(),directOnly=!!((_f=$("getvoices-direct-only"))!=null&&_f.checked);_voiceSourceItems=getVoiceSourceItems(),summary.innerHTML=[[`${payload.total||0}`,"Items found"],[`${payload.direct_audio||0}`,"Direct audio"],[`${sources.length}`,"Sources OK"],[`${(payload.errors||[]).length}`,"Errors"]].map(([value,label])=>`
${escHtml(value)}${escHtml(label)}
`).join(""),setOptions("getvoices-source-filter",sources.map(src=>src.id).filter(Boolean),"Source: all");const sourceSelect=$("getvoices-source-filter");sourceSelect&&[...sourceSelect.options].forEach(option=>{if(option.value==="all")return;const src=sources.find(s=>s.id===option.value);src&&(option.textContent=src.name||src.id)});const languages=[...new Set(_voiceSourceItems.map(item=>item.language||"Unknown"))].sort((a,b)=>a.localeCompare(b)),genders=[...new Set(_voiceSourceItems.map(item=>item.gender||"Unknown"))].sort((a,b)=>a.localeCompare(b)),filetypes=[...new Set(_voiceSourceItems.map(item=>(item.file_type||(item.direct_audio?"audio":"page")).toUpperCase()))].sort((a,b)=>a.localeCompare(b));setOptions("getvoices-language-filter",languages,"Language: all"),setOptions("getvoices-gender-filter",genders,"Sex: all"),setOptions("getvoices-filetype-filter",filetypes,"Filetype: all");let items=_voiceSourceItems.filter(item=>{if(sourceFilter!=="all"&&item.source_id!==sourceFilter||languageFilter!=="all"&&(item.language||"Unknown")!==languageFilter||genderFilter!=="all"&&(item.gender||"Unknown")!==genderFilter)return!1;const itemFiletype=(item.file_type||(item.direct_audio?"audio":"page")).toUpperCase();return!(filetypeFilter!=="all"&&itemFiletype!==filetypeFilter||directOnly&&!item.direct_audio||q&&!voiceSourceSearchText(item).includes(q))});const shown=items.slice(0,240),more=items.length-shown.length;if(!shown.length){const errorText=(payload.errors||[]).map(e=>`${e.source}: ${e.detail}`).join(" | ");list.innerHTML=`

No matching sources found.${errorText?" Source errors: "+escHtml(errorText):""}

`;return}list.innerHTML=shown.map(item=>{const thumb=item.image_url?``:'
',audio=item.audio_url?``:"",audioLink=item.audio_url?`Open audio`:"",canGetVoice=!!(item.import_url||item.audio_url),importUrl=item.import_url||item.audio_url,getVoice=canGetVoice?``:"",type=item.file_type?`${escHtml(String(item.file_type).toUpperCase())}`:"",language=item.language?`${escHtml(item.language)}`:"",gender=item.gender?`${escHtml(item.gender)}`:"";return`
+
`;let cancel=!1;box.querySelector("#sb-cancel").addEventListener("click",()=>{cancel=!0});const fill=box.querySelector("#sb-fill"),msg=box.querySelector("#sb-msg");let done=0,made=0,cached=0,failed=0;for(let vi=0;vi0?headerDur:Math.max(0,(blob.size-44)/(24e3*2)),clipped=String(resp.headers.get("X-TTS-Audio-Clipped")||"").toLowerCase()==="true",rtf=dur>0?genSec/dur:0;await _sfDbPut({key,cacheVersion:SF_CACHE_VERSION,voiceId,backend,textHash:th,seed,blob,dur,genSec,rtf,clipped,ts:Date.now()}),made++}catch{failed++}done++,fill.style.width=done/totalJobs*100+"%"}}ov.remove(),typeof toast=="function"&&toast(cancel?`Stopped \u2014 ${made} generated, ${cached} already cached`:`Batch done \u2014 ${made} generated, ${cached} cached${failed?`, ${failed} failed`:""}`,cancel?"error":"success")}typeof $=="function"&&((_a=$("seed-batch-all-btn"))==null||_a.addEventListener("click",seedFinderBatchAll)),Object.assign(root,{attachSeedFinder:attachSeedFinder2,seedFinderBatchAll}),root.TTVC=root.TTVC||{},root.TTVC.seedFinder=root.TTVC.seedFinder||{},Object.assign(root.TTVC.seedFinder,{attach:attachSeedFinder2,batchAll:seedFinderBatchAll})}(window),function(root){var _a,_b,_c,_d,_e;const DEFAULT_VOICE_SOURCE_URLS=["https://aiartes.com/voiceai","https://sample-files.com/downloads/audio/wav/voice-sample.wav","https://freesound.org/people/Scott%20Simpson/","https://lanceblairvo.com/raw-voiceover-samples/","https://github.com/yaph/tts-samples/tree/main/mp3","https://github.com/jim-schwoebel/voice_datasets"],VOICE_SOURCE_STORAGE_KEY="ttsvc-getvoices-sources";let _voiceSourcePayload=null,_voiceSourceItems=[];function sourceTextareaValueFromDefaults(){return DEFAULT_VOICE_SOURCE_URLS.join(` +`)}function initGetVoiceSourcesEditor(){const box=$("getvoices-sources");!box||box.dataset.ready||(box.value=localStorage.getItem(VOICE_SOURCE_STORAGE_KEY)||sourceTextareaValueFromDefaults(),box.dataset.ready="1",box.addEventListener("input",()=>{localStorage.setItem(VOICE_SOURCE_STORAGE_KEY,box.value),_voiceSourcePayload=null,$("getvoices-status").textContent="Source list changed."}))}function getEditableVoiceSourceUrls(){var _a2;return initGetVoiceSourcesEditor(),(((_a2=$("getvoices-sources"))==null?void 0:_a2.value)||"").split(/\r?\n/).map(line=>line.trim()).filter(line=>line&&!line.startsWith("#"))}function getVoiceSourceItems(){return(_voiceSourcePayload&&_voiceSourcePayload.sources||[]).flatMap(src=>(src.items||[]).map(item=>({...item,_sourceName:src.name,_sourceHomepage:src.homepage})))}function voiceSourceSearchText(item){return[item.name,item.kind,item.category,item.language,item.gender,item.description,item.source,item._sourceName].join(" ").toLowerCase()}function setOptions(selectId,values,allLabel){const sel=$(selectId);if(!sel)return;const current=sel.value||"all";sel.innerHTML=``+values.map(value=>``).join(""),sel.value=values.includes(current)?current:"all"}function renderGetVoices(){var _a2,_b2,_c2,_d2,_e2,_f;initGetVoiceSourcesEditor();const list=$("getvoices-list"),summary=$("getvoices-summary");if(!list||!summary)return;const payload=_voiceSourcePayload||{sources:[],total:0,direct_audio:0,errors:[]},sources=payload.sources||[],sourceFilter=((_a2=$("getvoices-source-filter"))==null?void 0:_a2.value)||"all",languageFilter=((_b2=$("getvoices-language-filter"))==null?void 0:_b2.value)||"all",genderFilter=((_c2=$("getvoices-gender-filter"))==null?void 0:_c2.value)||"all",filetypeFilter=((_d2=$("getvoices-filetype-filter"))==null?void 0:_d2.value)||"all",q=(((_e2=$("getvoices-search"))==null?void 0:_e2.value)||"").trim().toLowerCase(),directOnly=!!((_f=$("getvoices-direct-only"))!=null&&_f.checked);_voiceSourceItems=getVoiceSourceItems(),summary.innerHTML=[[`${payload.total||0}`,"Items found"],[`${payload.direct_audio||0}`,"Direct audio"],[`${sources.length}`,"Sources OK"],[`${(payload.errors||[]).length}`,"Errors"]].map(([value,label])=>`
${escHtml(value)}${escHtml(label)}
`).join(""),setOptions("getvoices-source-filter",sources.map(src=>src.id).filter(Boolean),"Source: all");const sourceSelect=$("getvoices-source-filter");sourceSelect&&[...sourceSelect.options].forEach(option=>{if(option.value==="all")return;const src=sources.find(s=>s.id===option.value);src&&(option.textContent=src.name||src.id)});const languages=[...new Set(_voiceSourceItems.map(item=>item.language||"Unknown"))].sort((a,b)=>a.localeCompare(b)),genders=[...new Set(_voiceSourceItems.map(item=>item.gender||"Unknown"))].sort((a,b)=>a.localeCompare(b)),filetypes=[...new Set(_voiceSourceItems.map(item=>(item.file_type||(item.direct_audio?"audio":"page")).toUpperCase()))].sort((a,b)=>a.localeCompare(b));setOptions("getvoices-language-filter",languages,"Language: all"),setOptions("getvoices-gender-filter",genders,"Sex: all"),setOptions("getvoices-filetype-filter",filetypes,"Filetype: all");let items=_voiceSourceItems.filter(item=>{if(sourceFilter!=="all"&&item.source_id!==sourceFilter||languageFilter!=="all"&&(item.language||"Unknown")!==languageFilter||genderFilter!=="all"&&(item.gender||"Unknown")!==genderFilter)return!1;const itemFiletype=(item.file_type||(item.direct_audio?"audio":"page")).toUpperCase();return!(filetypeFilter!=="all"&&itemFiletype!==filetypeFilter||directOnly&&!item.direct_audio||q&&!voiceSourceSearchText(item).includes(q))});const shown=items.slice(0,240),more=items.length-shown.length;if(!shown.length){const errorText=(payload.errors||[]).map(e=>`${e.source}: ${e.detail}`).join(" | ");list.innerHTML=`

No matching sources found.${errorText?" Source errors: "+escHtml(errorText):""}

`;return}list.innerHTML=shown.map(item=>{const thumb=item.image_url?``:'
',audio=item.audio_url?``:"",audioLink=item.audio_url?`Open audio`:"",canGetVoice=!!(item.import_url||item.audio_url),importUrl=item.import_url||item.audio_url,getVoice=canGetVoice?``:"",type=item.file_type?`${escHtml(String(item.file_type).toUpperCase())}`:"",language=item.language?`${escHtml(item.language)}`:"",gender=item.gender?`${escHtml(item.gender)}`:"";return`
${thumb}
@@ -202,7 +202,14 @@
${getVoice?``:""} -
`}).join("")+(more>0?`

${more} more matches. Narrow the search or filters to see them.

`:"")}async function loadGetVoices(force=!1){if(initGetVoiceSourcesEditor(),_voiceSourcePayload&&!force){renderGetVoices();return}const urls=getEditableVoiceSourceUrls(),list=$("getvoices-list");if(!urls.length){list&&(list.innerHTML='

Add at least one source URL, then scrape again.

'),$("getvoices-status").textContent="No sources.";return}list&&(list.innerHTML=loadingMarkup("Scraping voice sources",`Fetching ${urls.length} source${urls.length===1?"":"s"} from the editable list.`,6)),$("getvoices-status").textContent="Scraping...",$("getvoices-refresh-btn").disabled=!0;try{const r=await fetch("/api/voice-sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({urls})});if(!r.ok){const text=await r.text().catch(()=>"");let message=r.statusText||`HTTP ${r.status}`;try{message=JSON.parse(text).detail||message}catch{text&&(message=text.slice(0,160))}throw new Error(message)}_voiceSourcePayload=await r.json(),renderGetVoices();const errors=(_voiceSourcePayload.errors||[]).length;$("getvoices-status").textContent=`${_voiceSourcePayload.total||0} found${errors?`, ${errors} source errors`:""}`}catch(e){list&&(list.innerHTML=`

Scrape failed: ${escHtml(e.message)}

Check that the TTS Voice Creator backend is restarted and that at least one source URL is reachable.

`),$("getvoices-status").textContent="Scrape failed",toast("Voice source scrape failed: "+e.message,"error")}finally{$("getvoices-refresh-btn").disabled=!1}}["getvoices-search","getvoices-source-filter","getvoices-language-filter","getvoices-gender-filter","getvoices-filetype-filter","getvoices-direct-only"].forEach(id=>{const el=$(id);el&&el.addEventListener(id==="getvoices-search"?"input":"change",renderGetVoices)}),(_a=$("getvoices-refresh-btn"))==null||_a.addEventListener("click",()=>loadGetVoices(!0)),(_b=$("getvoices-reset-sources-btn"))==null||_b.addEventListener("click",()=>{const box=$("getvoices-sources");box&&(box.value=sourceTextareaValueFromDefaults(),localStorage.setItem(VOICE_SOURCE_STORAGE_KEY,box.value),_voiceSourcePayload=null,renderGetVoices(),$("getvoices-status").textContent="Source list reset.")});const SOURCE_LANGUAGE_CODES={english:"EN",german:"DE",deutsch:"DE",french:"FR",spanish:"ES",japanese:"JA",korean:"KO",italian:"IT",portuguese:"PT",russian:"RU",arabic:"AR",polish:"PL",dutch:"NL",swedish:"SV",turkish:"TR",hindi:"HI",chinese:"ZH"};function sourceLanguageCode(language){const raw=String(language||"").trim();return/^[A-Za-z]{2}$/.test(raw)?raw.toUpperCase():SOURCE_LANGUAGE_CODES[raw.toLowerCase()]||"EN"}function sourceGenderCode(gender,name=""){const text=`${gender||""} ${name||""}`.toLowerCase();return/female|woman|girl|\bf\b/.test(text)?"F":/male|man|boy|\bm\b/.test(text)?"M":"N"}function suggestedVoiceIdFromSourceName(name,language="EN",gender="N"){const base=String(name||"SourceVoice").normalize("NFKD").replace(/[\u0300-\u036f]/g,"").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,48)||"SourceVoice";return`${language||"EN"}_${gender||"N"}_${base}`}function setLibAddSourcePreview2(meta={}){const sourceBox=document.querySelector(".lib-add-source-box");if(!sourceBox)return;let preview=$("lib-add-source-preview");if(preview||(preview=document.createElement("div"),preview.id="lib-add-source-preview",preview.className="lib-add-source-preview",sourceBox.appendChild(preview)),!meta.name&&!meta.imageUrl){preview.classList.remove("open"),preview.innerHTML="";return}const image=meta.imageUrl?``:'
';preview.innerHTML=`${image}
${escHtml(meta.name||"Source voice")}${escHtml([meta.language,meta.gender,meta.kind].filter(Boolean).join(" \xB7 ")||"Source metadata will be saved with the voice")}
`,preview.classList.add("open")}async function getSourceVoiceInLibrary(meta){if(!meta.url){toast("This source has no direct audio URL","error");return}const lang=sourceLanguageCode(meta.language),gender=sourceGenderCode(meta.gender,meta.name),voiceId=suggestedVoiceIdFromSourceName(meta.name,lang,gender);toast(`Importing ${meta.name||"voice"}\u2026`,"info");try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audio_url:meta.url,voice_id:voiceId,transcript:""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();let srcTag="online";try{meta.pageUrl&&(srcTag=new URL(meta.pageUrl).hostname.replace(/^www\./,"").split(".")[0]||"online")}catch{}typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:meta.name||d.voice_id,tag:srcTag,group:srcTag,origin:"cloned",gender:(gender||"").toUpperCase(),note:(meta.description||"").slice(0,180)}).catch(()=>{}),meta.imageUrl&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:meta.imageUrl})}).catch(()=>{}),_pendingSelectId=d.voice_id,navTo("s-voices"),_libraryLoadPromise=null,await loadVoiceLibrary(),toast(`Saved as ${d.voice_id}`,"success")}catch(e){toast("Import failed: "+e.message,"error")}}(_c=$("getvoices-list"))==null||_c.addEventListener("click",async e=>{const getBtn=e.target.closest(".get-source-voice");if(getBtn){await getSourceVoiceInLibrary({url:getBtn.dataset.url||"",name:getBtn.dataset.name||"",imageUrl:getBtn.dataset.image||"",language:getBtn.dataset.language||"",gender:getBtn.dataset.gender||"",kind:getBtn.dataset.kind||"",description:getBtn.dataset.description||"",pageUrl:getBtn.dataset.page||""});return}const btn=e.target.closest(".copy-source-url");btn&&(await copyText(btn.dataset.url||""),toast("Source URL copied","success"))}),Object.assign(root,{DEFAULT_VOICE_SOURCE_URLS,VOICE_SOURCE_STORAGE_KEY,_voiceSourcePayload,_voiceSourceItems,sourceTextareaValueFromDefaults,initGetVoiceSourcesEditor,getEditableVoiceSourceUrls,getVoiceSourceItems,voiceSourceSearchText,setOptions,renderGetVoices,loadGetVoices,SOURCE_LANGUAGE_CODES,sourceLanguageCode,sourceGenderCode,suggestedVoiceIdFromSourceName,setLibAddSourcePreview:setLibAddSourcePreview2,getSourceVoiceInLibrary}),root.TTVC=root.TTVC||{},root.TTVC.voiceSources=root.TTVC.voiceSources||{},Object.assign(root.TTVC.voiceSources,{list:getVoiceSourceItems,load:loadGetVoices,import:getSourceVoiceInLibrary,sourceLanguageCode,sourceGenderCode})}(window),function(){var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l;const $2=id=>document.getElementById(id),grid=$2("fa-grid");if(!grid)return;let _page=1,_audio=null,_playingCard=null;const esc=s=>String(s==null?"":s).replace(/&/g,"&").replace(//g,">").replace(/"/g,""");function fmtCount(n){return n=+n||0,n>=1e6?(n/1e6).toFixed(1).replace(/\.0$/,"")+"M":n>=1e3?(n/1e3).toFixed(1).replace(/\.0$/,"")+"K":String(n)}const FLAG={EN:"\u{1F1EC}\u{1F1E7}",DE:"\u{1F1E9}\u{1F1EA}",FR:"\u{1F1EB}\u{1F1F7}",ES:"\u{1F1EA}\u{1F1F8}",IT:"\u{1F1EE}\u{1F1F9}",PT:"\u{1F1F5}\u{1F1F9}",NL:"\u{1F1F3}\u{1F1F1}",JA:"\u{1F1EF}\u{1F1F5}",ZH:"\u{1F1E8}\u{1F1F3}",KO:"\u{1F1F0}\u{1F1F7}",RU:"\u{1F1F7}\u{1F1FA}",AR:"\u{1F1F8}\u{1F1E6}",PL:"\u{1F1F5}\u{1F1F1}"};function status2(msg,show=!0){const bar=$2("fa-status-bar"),txt=$2("fa-status-text");bar&&(bar.hidden=!show),txt&&(txt.textContent=msg||"")}function activeVals(group){return[...document.querySelectorAll(`#fa-filter-pop .fa-pchips[data-group="${group}"] .fa-pchip.active`)].map(c=>c.dataset.val).filter(Boolean)}function filterCount(){var _a2;return activeVals("gender").length+activeVals("age").length+activeVals("tag").length+((_a2=$2("fa-tag"))!=null&&_a2.value.trim()?1:0)}function updateFilterBadge(){const n=filterCount(),b=$2("fa-filter-badge"),btn=$2("fa-filter-btn");b&&(b.textContent=String(n),b.hidden=n===0),btn==null||btn.classList.toggle("active",n>0)}function params(){var _a2,_b2,_c2,_d2;const p=new URLSearchParams({page:String(_page),page_size:"24"}),s=(_a2=$2("fa-search"))==null?void 0:_a2.value.trim();s&&p.set("search",s);const l=(_b2=$2("fa-lang"))==null?void 0:_b2.value;l&&p.set("language",l);const sort=(_c2=$2("fa-sort"))==null?void 0:_c2.value;sort&&p.set("sort_by",sort);const g=activeVals("gender")[0];g&&p.set("gender",g);const a=activeVals("age")[0];a&&p.set("age",a),activeVals("tag").forEach(t2=>p.append("tag",t2));const t=(_d2=$2("fa-tag"))==null?void 0:_d2.value.trim();return t&&p.append("tag",t),p}function card(v){const can=!!v.sample_audio,meta=[v.language?`${FLAG[v.language]||"\u{1F310}"} ${esc(v.language)}`:"",v.gender?`${esc(v.gender)}`:"",v.age?`${esc(v.age)}`:""].join(""),avatar=v.image?``:"";return`
+
`}).join("")+(more>0?`

${more} more matches. Narrow the search or filters to see them.

`:"")}async function loadGetVoices(force=!1){if(initGetVoiceSourcesEditor(),_voiceSourcePayload&&!force){renderGetVoices();return}const urls=getEditableVoiceSourceUrls(),list=$("getvoices-list");if(!urls.length){list&&(list.innerHTML='

Add at least one source URL, then scrape again.

'),$("getvoices-status").textContent="No sources.";return}list&&(list.innerHTML=loadingMarkup("Scraping voice sources",`Fetching ${urls.length} source${urls.length===1?"":"s"} from the editable list.`,6)),$("getvoices-status").textContent="Scraping...",$("getvoices-refresh-btn").disabled=!0;try{const r=await fetch("/api/voice-sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({urls})});if(!r.ok){const text=await r.text().catch(()=>"");let message=r.statusText||`HTTP ${r.status}`;try{message=JSON.parse(text).detail||message}catch{text&&(message=text.slice(0,160))}throw new Error(message)}_voiceSourcePayload=await r.json(),renderGetVoices();const errors=(_voiceSourcePayload.errors||[]).length;$("getvoices-status").textContent=`${_voiceSourcePayload.total||0} found${errors?`, ${errors} source errors`:""}`}catch(e){list&&(list.innerHTML=`

Scrape failed: ${escHtml(e.message)}

Check that the TTS Voice Creator backend is restarted and that at least one source URL is reachable.

`),$("getvoices-status").textContent="Scrape failed",toast("Voice source scrape failed: "+e.message,"error")}finally{$("getvoices-refresh-btn").disabled=!1}}["getvoices-search","getvoices-source-filter","getvoices-language-filter","getvoices-gender-filter","getvoices-filetype-filter","getvoices-direct-only"].forEach(id=>{const el=$(id);el&&el.addEventListener(id==="getvoices-search"?"input":"change",renderGetVoices)}),(_a=$("getvoices-refresh-btn"))==null||_a.addEventListener("click",()=>loadGetVoices(!0)),(_b=$("getvoices-reset-sources-btn"))==null||_b.addEventListener("click",()=>{const box=$("getvoices-sources");box&&(box.value=sourceTextareaValueFromDefaults(),localStorage.setItem(VOICE_SOURCE_STORAGE_KEY,box.value),_voiceSourcePayload=null,renderGetVoices(),$("getvoices-status").textContent="Source list reset.")});let _osItems=[];function osDuration(sec){return sec=Math.round(sec||0),sec?`${Math.floor(sec/60)}:${String(sec%60).padStart(2,"0")}`:""}function renderOpenSources(){$("os-list").innerHTML=_osItems.map((it,i)=>`
+
${escHtml(it.title)}${escHtml([it.reader,it.language,osDuration(it.duration)].filter(Boolean).join(" \xB7 "))}
+
Licence: ${it.licence_url?`${escHtml(it.licence)}`:escHtml(it.licence)}
+ +
+ Source page + +
`).join("")}async function searchOpenSources(ev){ev==null||ev.preventDefault();const st=$("os-status"),btn=$("os-search-btn"),q=($("os-q").value||"").trim();if(!q){st.textContent="Enter a search term.";return}btn.disabled=!0,st.textContent="Searching...";try{const p=new URLSearchParams({q,lang:$("os-lang").value}),r=await fetch(`/api/voice-sources/${encodeURIComponent($("os-source").value)}?${p}`),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);_osItems=d.items||[],renderOpenSources(),st.textContent=`${_osItems.length} result${_osItems.length===1?"":"s"}.`}catch(e){_osItems=[],renderOpenSources(),st.textContent="Search failed: "+e.message}finally{btn.disabled=!1}}async function importOpenSource(it){const st=$("os-status"),lang=sourceLanguageCode(it.language||$("os-lang").value||"English"),body={audio_url:it.audio_url,voice_id:suggestedVoiceIdFromSourceName(it.title,lang,"N"),transcript:"",lang,source_kind:it.source,page_url:it.source_url,licence:it.licence,attribution:it.attribution||"",name:it.title,group:it.source,origin:"cloned",start:+$("os-start").value||0,length:+$("os-len").value||30,note:`${it.reader||""} ${it.licence}`.trim()};st.textContent="Importing...";try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);st.textContent=`Saved as ${d.voice_id} (source and licence stored).`,toast(`Saved as ${d.voice_id}`,"success"),_libraryLoadPromise=null}catch(e){st.textContent="Import failed: "+e.message,toast("Import failed: "+e.message,"error")}}(_c=$("os-form"))==null||_c.addEventListener("submit",searchOpenSources),(_d=$("os-list"))==null||_d.addEventListener("click",e=>{const b=e.target.closest(".os-import");b&&_osItems[+b.dataset.i]&&importOpenSource(_osItems[+b.dataset.i])});const SOURCE_LANGUAGE_CODES={english:"EN",german:"DE",deutsch:"DE",french:"FR",spanish:"ES",japanese:"JA",korean:"KO",italian:"IT",portuguese:"PT",russian:"RU",arabic:"AR",polish:"PL",dutch:"NL",swedish:"SV",turkish:"TR",hindi:"HI",chinese:"ZH"};function sourceLanguageCode(language){const raw=String(language||"").trim();return/^[A-Za-z]{2}$/.test(raw)?raw.toUpperCase():SOURCE_LANGUAGE_CODES[raw.toLowerCase()]||"EN"}function sourceGenderCode(gender,name=""){const text=`${gender||""} ${name||""}`.toLowerCase();return/female|woman|girl|\bf\b/.test(text)?"F":/male|man|boy|\bm\b/.test(text)?"M":"N"}function suggestedVoiceIdFromSourceName(name,language="EN",gender="N"){const base=String(name||"SourceVoice").normalize("NFKD").replace(/[\u0300-\u036f]/g,"").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,48)||"SourceVoice";return`${language||"EN"}_${gender||"N"}_${base}`}function setLibAddSourcePreview2(meta={}){const sourceBox=document.querySelector(".lib-add-source-box");if(!sourceBox)return;let preview=$("lib-add-source-preview");if(preview||(preview=document.createElement("div"),preview.id="lib-add-source-preview",preview.className="lib-add-source-preview",sourceBox.appendChild(preview)),!meta.name&&!meta.imageUrl){preview.classList.remove("open"),preview.innerHTML="";return}const image=meta.imageUrl?``:'
';preview.innerHTML=`${image}
${escHtml(meta.name||"Source voice")}${escHtml([meta.language,meta.gender,meta.kind].filter(Boolean).join(" \xB7 ")||"Source metadata will be saved with the voice")}
`,preview.classList.add("open")}async function getSourceVoiceInLibrary(meta){if(!meta.url){toast("This source has no direct audio URL","error");return}const lang=sourceLanguageCode(meta.language),gender=sourceGenderCode(meta.gender,meta.name),voiceId=suggestedVoiceIdFromSourceName(meta.name,lang,gender);toast(`Importing ${meta.name||"voice"}\u2026`,"info");try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audio_url:meta.url,voice_id:voiceId,transcript:""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();let srcTag="online";try{meta.pageUrl&&(srcTag=new URL(meta.pageUrl).hostname.replace(/^www\./,"").split(".")[0]||"online")}catch{}typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:meta.name||d.voice_id,tag:srcTag,group:srcTag,origin:"cloned",gender:(gender||"").toUpperCase(),note:(meta.description||"").slice(0,180)}).catch(()=>{}),meta.imageUrl&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:meta.imageUrl})}).catch(()=>{}),_pendingSelectId=d.voice_id,navTo("s-voices"),_libraryLoadPromise=null,await loadVoiceLibrary(),toast(`Saved as ${d.voice_id}`,"success")}catch(e){toast("Import failed: "+e.message,"error")}}(_e=$("getvoices-list"))==null||_e.addEventListener("click",async e=>{const getBtn=e.target.closest(".get-source-voice");if(getBtn){await getSourceVoiceInLibrary({url:getBtn.dataset.url||"",name:getBtn.dataset.name||"",imageUrl:getBtn.dataset.image||"",language:getBtn.dataset.language||"",gender:getBtn.dataset.gender||"",kind:getBtn.dataset.kind||"",description:getBtn.dataset.description||"",pageUrl:getBtn.dataset.page||""});return}const btn=e.target.closest(".copy-source-url");btn&&(await copyText(btn.dataset.url||""),toast("Source URL copied","success"))}),Object.assign(root,{DEFAULT_VOICE_SOURCE_URLS,VOICE_SOURCE_STORAGE_KEY,_voiceSourcePayload,_voiceSourceItems,sourceTextareaValueFromDefaults,initGetVoiceSourcesEditor,getEditableVoiceSourceUrls,getVoiceSourceItems,voiceSourceSearchText,setOptions,renderGetVoices,loadGetVoices,SOURCE_LANGUAGE_CODES,sourceLanguageCode,sourceGenderCode,suggestedVoiceIdFromSourceName,setLibAddSourcePreview:setLibAddSourcePreview2,getSourceVoiceInLibrary,searchOpenSources,importOpenSource}),root.TTVC=root.TTVC||{},root.TTVC.voiceSources=root.TTVC.voiceSources||{},Object.assign(root.TTVC.voiceSources,{list:getVoiceSourceItems,load:loadGetVoices,import:getSourceVoiceInLibrary,sourceLanguageCode,sourceGenderCode})}(window),function(){var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l;const $2=id=>document.getElementById(id),grid=$2("fa-grid");if(!grid)return;let _page=1,_audio=null,_playingCard=null;const esc=s=>String(s==null?"":s).replace(/&/g,"&").replace(//g,">").replace(/"/g,""");function fmtCount(n){return n=+n||0,n>=1e6?(n/1e6).toFixed(1).replace(/\.0$/,"")+"M":n>=1e3?(n/1e3).toFixed(1).replace(/\.0$/,"")+"K":String(n)}const FLAG={EN:"\u{1F1EC}\u{1F1E7}",DE:"\u{1F1E9}\u{1F1EA}",FR:"\u{1F1EB}\u{1F1F7}",ES:"\u{1F1EA}\u{1F1F8}",IT:"\u{1F1EE}\u{1F1F9}",PT:"\u{1F1F5}\u{1F1F9}",NL:"\u{1F1F3}\u{1F1F1}",JA:"\u{1F1EF}\u{1F1F5}",ZH:"\u{1F1E8}\u{1F1F3}",KO:"\u{1F1F0}\u{1F1F7}",RU:"\u{1F1F7}\u{1F1FA}",AR:"\u{1F1F8}\u{1F1E6}",PL:"\u{1F1F5}\u{1F1F1}"};function status2(msg,show=!0){const bar=$2("fa-status-bar"),txt=$2("fa-status-text");bar&&(bar.hidden=!show),txt&&(txt.textContent=msg||"")}function activeVals(group){return[...document.querySelectorAll(`#fa-filter-pop .fa-pchips[data-group="${group}"] .fa-pchip.active`)].map(c=>c.dataset.val).filter(Boolean)}function filterCount(){var _a2;return activeVals("gender").length+activeVals("age").length+activeVals("tag").length+((_a2=$2("fa-tag"))!=null&&_a2.value.trim()?1:0)}function updateFilterBadge(){const n=filterCount(),b=$2("fa-filter-badge"),btn=$2("fa-filter-btn");b&&(b.textContent=String(n),b.hidden=n===0),btn==null||btn.classList.toggle("active",n>0)}function params(){var _a2,_b2,_c2,_d2;const p=new URLSearchParams({page:String(_page),page_size:"24"}),s=(_a2=$2("fa-search"))==null?void 0:_a2.value.trim();s&&p.set("search",s);const l=(_b2=$2("fa-lang"))==null?void 0:_b2.value;l&&p.set("language",l);const sort=(_c2=$2("fa-sort"))==null?void 0:_c2.value;sort&&p.set("sort_by",sort);const g=activeVals("gender")[0];g&&p.set("gender",g);const a=activeVals("age")[0];a&&p.set("age",a),activeVals("tag").forEach(t2=>p.append("tag",t2));const t=(_d2=$2("fa-tag"))==null?void 0:_d2.value.trim();return t&&p.append("tag",t),p}function card(v){const can=!!v.sample_audio,meta=[v.language?`${FLAG[v.language]||"\u{1F310}"} ${esc(v.language)}`:"",v.gender?`${esc(v.gender)}`:"",v.age?`${esc(v.age)}`:""].join(""),avatar=v.image?``:"";return`
${avatar} ${can?'':""} @@ -220,7 +227,7 @@ ${can?'':""}
-
`}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a2,_b2;const v=items[i];(_a2=el.querySelector(".fa-play"))==null||_a2.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b2=el.querySelector(".fa-import"))==null||_b2.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(typeof _umlautSafe=="function"?_umlautSafe(v.title||"fishaudio"):v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a=$2("fa-fetch"))==null||_a.addEventListener("click",()=>{_page=1,browse()}),(_b=$2("fa-search"))==null||_b.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c=$2("fa-tag"))==null||_c.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d=$2("fa-lang"))==null||_d.addEventListener("change",()=>{_page=1,browse()}),(_e=$2("fa-sort"))==null||_e.addEventListener("change",()=>{_page=1,browse()}),(_f=$2("fa-prev"))==null||_f.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g=$2("fa-next"))==null||_g.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i=$2("fa-filter-close"))==null||_i.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j=$2("fa-tag"))==null||_j.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k=$2("fa-filter-apply"))==null||_k.addEventListener("click",applyFilters),(_l=$2("fa-filter-reset"))==null||_l.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}(),function(){const KEY="ttsvc_voice_library_cache_v2";let inFlight=null;function read(){try{const value=JSON.parse(sessionStorage.getItem(KEY)||"null");return!value||!Array.isArray(value.voices)||!Number.isFinite(value.savedAt)?null:Date.now()-value.savedAt<=3e4?value.voices:null}catch{return null}}function write(voices){try{sessionStorage.setItem(KEY,JSON.stringify({savedAt:Date.now(),voices}))}catch{}}function invalidate(){try{sessionStorage.removeItem(KEY)}catch{}}async function fetchVoices(options={}){const refresh=options===!0||options.refresh===!0;if(!refresh){const cached=read();if(cached)return cached}return inFlight||(inFlight=fetch("/api/voices"+(refresh?"?refresh=1":""),{cache:"no-store"}).then(async response=>{if(!response.ok){const error=await response.json().catch(()=>({}));throw new Error(error.detail||response.statusText)}const voices=await response.json();if(!Array.isArray(voices))throw new Error("Voice library response was not an array");return write(voices),voices}).finally(()=>{inFlight=null}),inFlight)}window.fetchVoiceLibrary=fetchVoices,window.invalidateVoiceLibraryCache=invalidate}(),function(){var _a;const list=document.getElementById("background-jobs-list"),refresh=document.getElementById("background-jobs-refresh"),diagnostics=document.getElementById("resource-diagnostics"),diagnosticsRefresh=document.getElementById("resource-diagnostics-refresh");if(!list||!refresh)return;const labels={transcription:"Transcription",voice_design:"Voice design",casting:"Voice casting",consistent_images:"Consistent images"};let loading=!1,diagnosticsLoading=!1;function node(value,fallback="\u2014"){const item=document.createElement("span");return item.textContent=value==null||value===""?fallback:String(value),item}function statusClass(job){return job.cancelled||job.stage==="cancelled"?"is-cancelled":job.error||job.stage==="error"||job.stage==="failed"?"is-error":job.done?"is-done":"is-active"}function statusLabel(job){return job.cancelled||job.stage==="cancelled"?"Cancelled":job.error||job.stage==="error"||job.stage==="failed"?"Failed":job.done?"Completed":job.stage==="queued"?"Queued":"Running"}function cancelEndpoint(kind,id){const paths={transcription:"/api/transcribe/cancel/",voice_design:"/api/voice-design/cancel/",casting:"/api/match-characters-voices/cancel/",consistent_images:"/api/character-generate-consistent-images/cancel/"};return paths[kind]?paths[kind]+encodeURIComponent(id):""}function retryEndpoint(kind,id){const paths={transcription:"/api/transcribe/retry/",voice_design:"/api/voice-design/retry/",casting:"/api/match-characters-voices/retry/",consistent_images:"/api/character-generate-consistent-images/retry/"};return paths[kind]&&id?paths[kind]+encodeURIComponent(id):""}function render(jobs){if(list.replaceChildren(),!jobs.length){const empty=node("No background jobs yet. Start a transcription, voice design, cast, or image workflow to see it here.");empty.className="perf-history-empty",list.appendChild(empty);return}jobs.forEach(job=>{const row=document.createElement("div");row.className="background-job-row "+statusClass(job);const main=document.createElement("div");main.className="background-job-main";const title=document.createElement("strong");title.textContent=labels[job.kind]||job.kind||"Background job",main.appendChild(title);const id=document.createElement("code");id.textContent=String(job.job_id||"").slice(0,12),main.appendChild(id);const detail=node(job.error||job.detail||job.stage);detail.className="background-job-detail",main.appendChild(detail);const meta=node(`${statusLabel(job)} \xB7 ${Number(job.elapsed||0).toFixed(1)}s`);meta.className="background-job-meta",main.appendChild(meta);const storedResult=job.result&&typeof job.result=="object"?job.result:{},artifactList=[];storedResult.artifact&&artifactList.push(storedResult.artifact),Array.isArray(storedResult.artifacts)&&artifactList.push(...storedResult.artifacts),artifactList.filter(item=>item&&typeof item.url=="string"&&item.url.startsWith("/api/jobs/")).forEach(item=>{const link=document.createElement("a");link.className="background-job-artifact",link.href=item.url,link.target="_blank",link.rel="noopener",link.textContent=`Download ${item.kind||"result"}`,main.appendChild(link)}),row.appendChild(main);const actions=document.createElement("div");if(actions.className="background-job-actions",job.done&&job.error&&job.retry_available){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Retry",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(retryEndpoint(job.kind,job.job_id),{method:"POST"});if(!response.ok){const detail2=await response.json().catch(()=>({}));throw new Error(detail2.detail||`HTTP ${response.status}`)}await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not retry job: "+error.message,"error")}}),actions.appendChild(button)}if(!job.done&&!job.cancelled&&job.job_id){const endpoint=cancelEndpoint(job.kind,job.job_id);if(endpoint){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Cancel",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(endpoint,{method:"POST"});if(!response.ok)throw new Error(`HTTP ${response.status}`);await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not cancel job: "+error.message,"error")}}),actions.appendChild(button)}}const note=node("Reopen source to retry");note.className="background-job-retry-note",note.title="Inputs are not retained for replay, by design.",(!job.done||!job.error||!job.retry_available)&&actions.appendChild(note),row.appendChild(actions),list.appendChild(row)})}async function load(){if(!loading){loading=!0,refresh.disabled=!0;try{const response=await fetch("/api/jobs?limit=50",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const payload=await response.json();render(Array.isArray(payload.jobs)?payload.jobs:[])}catch(error){list.replaceChildren();const empty=node("Background job history unavailable: "+error.message);empty.className="perf-history-empty",list.appendChild(empty)}finally{loading=!1,refresh.disabled=!1}}}function formatBytes(value){const bytes=Number(value);return Number.isFinite(bytes)?bytes<1024*1024?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1024/1024).toFixed(1)} MiB`:"\u2014"}async function loadDiagnostics(){var _a2,_b,_c,_d,_e,_f;if(!(!diagnostics||diagnosticsLoading)){diagnosticsLoading=!0,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!0);try{const response=await fetch("/api/diagnostics",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const server=await response.json(),browser=await new Promise(resolve=>requestAnimationFrame(()=>{var _a3,_b2,_c2,_d2,_e2,_f2,_g,_h;return resolve({domNodes:document.getElementsByTagName("*").length,audioElements:document.querySelectorAll("audio").length,jsHeapBytes:((_a3=performance.memory)==null?void 0:_a3.usedJSHeapSize)||null,imageCacheEntries:(_e2=(_d2=(_c2=(_b2=window.TTVC)==null?void 0:_b2.imageCache)==null?void 0:_c2.size)==null?void 0:_d2.call(_c2))!=null?_e2:null,rehearserAudioEntries:(_h=(_g=(_f2=window.rehState)==null?void 0:_f2.synthCache)==null?void 0:_g.size)!=null?_h:null,voiceRecords:Array.isArray(window._voices)?window._voices.length:null})}));diagnostics.replaceChildren(),[["Server RSS",`${server.server_rss_mb} MiB`],["Config data",formatBytes((_a2=server.config_disk)==null?void 0:_a2.used_bytes)],["Config free",formatBytes((_b=server.config_disk)==null?void 0:_b.free_bytes)],["Temp data",formatBytes((_c=server.temp_disk)==null?void 0:_c.used_bytes)],["Container memory",`${formatBytes((_d=server.container_memory)==null?void 0:_d.current_bytes)} / ${formatBytes((_e=server.container_memory)==null?void 0:_e.max_bytes)}`],["Artifact cache",formatBytes((_f=server.cache_storage)==null?void 0:_f.background_artifacts_bytes)],["Persisted jobs",server.background_jobs],["Image cache entries",browser.imageCacheEntries],["Rehearser audio entries",browser.rehearserAudioEntries],["Voice records",browser.voiceRecords],["DOM nodes",browser.domNodes],["Audio elements",browser.audioElements],["JS heap",formatBytes(browser.jsHeapBytes)]].forEach(([label,value])=>{const item=document.createElement("div");item.className="resource-diagnostic-item";const name=document.createElement("span");name.textContent=label;const valueNode=document.createElement("strong");valueNode.textContent=value==null?"\u2014":String(value),item.append(name,valueNode),diagnostics.appendChild(item)});const operations=Array.isArray(server.largest_operations)?server.largest_operations:[];if(operations.length){const heading=document.createElement("div");heading.className="resource-diagnostic-subhead",heading.textContent="Largest recent operations",diagnostics.appendChild(heading),operations.forEach(operation=>{const item=document.createElement("div");item.className="resource-diagnostic-item";const name=document.createElement("span");name.textContent=`${operation.kind} \xB7 ${operation.status}`;const valueNode=document.createElement("strong");valueNode.textContent=`${Number(operation.elapsed||0).toFixed(1)}s`,item.append(name,valueNode),diagnostics.appendChild(item)})}}catch(error){diagnostics.replaceChildren();const item=node("Diagnostics unavailable: "+error.message);item.className="perf-history-empty",diagnostics.appendChild(item)}finally{diagnosticsLoading=!1,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!1)}}}refresh.addEventListener("click",load),diagnosticsRefresh==null||diagnosticsRefresh.addEventListener("click",loadDiagnostics),window.jobsOnShow=function(){load(),loadDiagnostics()},(_a=document.getElementById("s-performance"))!=null&&_a.classList.contains("is-active")&&window.jobsOnShow(),setInterval(()=>{var _a2;(_a2=document.getElementById("s-performance"))!=null&&_a2.classList.contains("is-active")&&(load(),loadDiagnostics())},1e4)}(),function(){"use strict";var root=window.TTVC=window.TTVC||{},entries=new Map,visible=new Set,observer="IntersectionObserver"in window?new IntersectionObserver(function(records){records.forEach(function(record){var img=record.target;if(record.isIntersecting){visible.add(img);var source=img.dataset.ttvcSrc;source&&!img.getAttribute("src")&&img.setAttribute("src",source),entries.delete(img),entries.set(img,Date.now())}else visible.delete(img)}),evict()},{rootMargin:"160px"}):null;function limit(){return typeof window.ttvcIsLowMemoryDevice=="function"&&window.ttvcIsLowMemoryDevice()?24:64}function evict(){for(entries.forEach(function(_,img2){img2.isConnected||entries.delete(img2)});entries.size>limit();){var candidate=null;for(var img of entries.keys())if(!visible.has(img)){candidate=img;break}if(!candidate)break;entries.delete(candidate),candidate.getAttribute("src")&&(candidate.dataset.ttvcSrc=candidate.getAttribute("src")),candidate.removeAttribute("src")}}function observeImages(container){container&&(container.querySelectorAll("img").forEach(function(img){!img.dataset.ttvcSrc&&img.getAttribute("src")&&(img.dataset.ttvcSrc=img.getAttribute("src")),observer&&!img.dataset.ttvcObserved&&(img.dataset.ttvcObserved="1",observer.observe(img)),entries.delete(img),entries.set(img,Date.now())}),evict())}root.imageCache={observe:observeImages,evict,size:function(){return entries.size}},window.ttvcObserveImages=observeImages}(),function(root){var _a,_b,_c;function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url2(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints2(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds2(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds2()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds2();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds2(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+(typeof _umlautSafe=="function"?_umlautSafe(name):name).replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets2(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url2(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints2();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds2(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS +
`}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a2,_b2;const v=items[i];(_a2=el.querySelector(".fa-play"))==null||_a2.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b2=el.querySelector(".fa-import"))==null||_b2.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(typeof _umlautSafe=="function"?_umlautSafe(v.title||"fishaudio"):v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a=$2("fa-fetch"))==null||_a.addEventListener("click",()=>{_page=1,browse()}),(_b=$2("fa-search"))==null||_b.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c=$2("fa-tag"))==null||_c.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d=$2("fa-lang"))==null||_d.addEventListener("change",()=>{_page=1,browse()}),(_e=$2("fa-sort"))==null||_e.addEventListener("change",()=>{_page=1,browse()}),(_f=$2("fa-prev"))==null||_f.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g=$2("fa-next"))==null||_g.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i=$2("fa-filter-close"))==null||_i.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j=$2("fa-tag"))==null||_j.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k=$2("fa-filter-apply"))==null||_k.addEventListener("click",applyFilters),(_l=$2("fa-filter-reset"))==null||_l.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",open:"open-sources-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}(),function(){const KEY="ttsvc_voice_library_cache_v2";let inFlight=null;function read(){try{const value=JSON.parse(sessionStorage.getItem(KEY)||"null");return!value||!Array.isArray(value.voices)||!Number.isFinite(value.savedAt)?null:Date.now()-value.savedAt<=3e4?value.voices:null}catch{return null}}function write(voices){try{sessionStorage.setItem(KEY,JSON.stringify({savedAt:Date.now(),voices}))}catch{}}function invalidate(){try{sessionStorage.removeItem(KEY)}catch{}}async function fetchVoices(options={}){const refresh=options===!0||options.refresh===!0;if(!refresh){const cached=read();if(cached)return cached}return inFlight||(inFlight=fetch("/api/voices"+(refresh?"?refresh=1":""),{cache:"no-store"}).then(async response=>{if(!response.ok){const error=await response.json().catch(()=>({}));throw new Error(error.detail||response.statusText)}const voices=await response.json();if(!Array.isArray(voices))throw new Error("Voice library response was not an array");return write(voices),voices}).finally(()=>{inFlight=null}),inFlight)}window.fetchVoiceLibrary=fetchVoices,window.invalidateVoiceLibraryCache=invalidate}(),function(){var _a;const list=document.getElementById("background-jobs-list"),refresh=document.getElementById("background-jobs-refresh"),diagnostics=document.getElementById("resource-diagnostics"),diagnosticsRefresh=document.getElementById("resource-diagnostics-refresh");if(!list||!refresh)return;const labels={transcription:"Transcription",voice_design:"Voice design",casting:"Voice casting",consistent_images:"Consistent images"};let loading=!1,diagnosticsLoading=!1;function node(value,fallback="\u2014"){const item=document.createElement("span");return item.textContent=value==null||value===""?fallback:String(value),item}function statusClass(job){return job.cancelled||job.stage==="cancelled"?"is-cancelled":job.error||job.stage==="error"||job.stage==="failed"?"is-error":job.done?"is-done":"is-active"}function statusLabel(job){return job.cancelled||job.stage==="cancelled"?"Cancelled":job.error||job.stage==="error"||job.stage==="failed"?"Failed":job.done?"Completed":job.stage==="queued"?"Queued":"Running"}function cancelEndpoint(kind,id){const paths={transcription:"/api/transcribe/cancel/",voice_design:"/api/voice-design/cancel/",casting:"/api/match-characters-voices/cancel/",consistent_images:"/api/character-generate-consistent-images/cancel/"};return paths[kind]?paths[kind]+encodeURIComponent(id):""}function retryEndpoint(kind,id){const paths={transcription:"/api/transcribe/retry/",voice_design:"/api/voice-design/retry/",casting:"/api/match-characters-voices/retry/",consistent_images:"/api/character-generate-consistent-images/retry/"};return paths[kind]&&id?paths[kind]+encodeURIComponent(id):""}function render(jobs){if(list.replaceChildren(),!jobs.length){const empty=node("No background jobs yet. Start a transcription, voice design, cast, or image workflow to see it here.");empty.className="perf-history-empty",list.appendChild(empty);return}jobs.forEach(job=>{const row=document.createElement("div");row.className="background-job-row "+statusClass(job);const main=document.createElement("div");main.className="background-job-main";const title=document.createElement("strong");title.textContent=labels[job.kind]||job.kind||"Background job",main.appendChild(title);const id=document.createElement("code");id.textContent=String(job.job_id||"").slice(0,12),main.appendChild(id);const detail=node(job.error||job.detail||job.stage);detail.className="background-job-detail",main.appendChild(detail);const meta=node(`${statusLabel(job)} \xB7 ${Number(job.elapsed||0).toFixed(1)}s`);meta.className="background-job-meta",main.appendChild(meta);const storedResult=job.result&&typeof job.result=="object"?job.result:{},artifactList=[];storedResult.artifact&&artifactList.push(storedResult.artifact),Array.isArray(storedResult.artifacts)&&artifactList.push(...storedResult.artifacts),artifactList.filter(item=>item&&typeof item.url=="string"&&item.url.startsWith("/api/jobs/")).forEach(item=>{const link=document.createElement("a");link.className="background-job-artifact",link.href=item.url,link.target="_blank",link.rel="noopener",link.textContent=`Download ${item.kind||"result"}`,main.appendChild(link)}),row.appendChild(main);const actions=document.createElement("div");if(actions.className="background-job-actions",job.done&&job.error&&job.retry_available){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Retry",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(retryEndpoint(job.kind,job.job_id),{method:"POST"});if(!response.ok){const detail2=await response.json().catch(()=>({}));throw new Error(detail2.detail||`HTTP ${response.status}`)}await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not retry job: "+error.message,"error")}}),actions.appendChild(button)}if(!job.done&&!job.cancelled&&job.job_id){const endpoint=cancelEndpoint(job.kind,job.job_id);if(endpoint){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Cancel",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(endpoint,{method:"POST"});if(!response.ok)throw new Error(`HTTP ${response.status}`);await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not cancel job: "+error.message,"error")}}),actions.appendChild(button)}}const note=node("Reopen source to retry");note.className="background-job-retry-note",note.title="Inputs are not retained for replay, by design.",(!job.done||!job.error||!job.retry_available)&&actions.appendChild(note),row.appendChild(actions),list.appendChild(row)})}async function load(){if(!loading){loading=!0,refresh.disabled=!0;try{const response=await fetch("/api/jobs?limit=50",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const payload=await response.json();render(Array.isArray(payload.jobs)?payload.jobs:[])}catch(error){list.replaceChildren();const empty=node("Background job history unavailable: "+error.message);empty.className="perf-history-empty",list.appendChild(empty)}finally{loading=!1,refresh.disabled=!1}}}function formatBytes(value){const bytes=Number(value);return Number.isFinite(bytes)?bytes<1024*1024?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1024/1024).toFixed(1)} MiB`:"\u2014"}async function loadDiagnostics(){var _a2,_b,_c,_d,_e,_f;if(!(!diagnostics||diagnosticsLoading)){diagnosticsLoading=!0,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!0);try{const response=await fetch("/api/diagnostics",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const server=await response.json(),browser=await new Promise(resolve=>requestAnimationFrame(()=>{var _a3,_b2,_c2,_d2,_e2,_f2,_g,_h;return resolve({domNodes:document.getElementsByTagName("*").length,audioElements:document.querySelectorAll("audio").length,jsHeapBytes:((_a3=performance.memory)==null?void 0:_a3.usedJSHeapSize)||null,imageCacheEntries:(_e2=(_d2=(_c2=(_b2=window.TTVC)==null?void 0:_b2.imageCache)==null?void 0:_c2.size)==null?void 0:_d2.call(_c2))!=null?_e2:null,rehearserAudioEntries:(_h=(_g=(_f2=window.rehState)==null?void 0:_f2.synthCache)==null?void 0:_g.size)!=null?_h:null,voiceRecords:Array.isArray(window._voices)?window._voices.length:null})}));diagnostics.replaceChildren(),[["Server RSS",`${server.server_rss_mb} MiB`],["Config data",formatBytes((_a2=server.config_disk)==null?void 0:_a2.used_bytes)],["Config free",formatBytes((_b=server.config_disk)==null?void 0:_b.free_bytes)],["Temp data",formatBytes((_c=server.temp_disk)==null?void 0:_c.used_bytes)],["Container memory",`${formatBytes((_d=server.container_memory)==null?void 0:_d.current_bytes)} / ${formatBytes((_e=server.container_memory)==null?void 0:_e.max_bytes)}`],["Artifact cache",formatBytes((_f=server.cache_storage)==null?void 0:_f.background_artifacts_bytes)],["Persisted jobs",server.background_jobs],["Image cache entries",browser.imageCacheEntries],["Rehearser audio entries",browser.rehearserAudioEntries],["Voice records",browser.voiceRecords],["DOM nodes",browser.domNodes],["Audio elements",browser.audioElements],["JS heap",formatBytes(browser.jsHeapBytes)]].forEach(([label,value])=>{const item=document.createElement("div");item.className="resource-diagnostic-item";const name=document.createElement("span");name.textContent=label;const valueNode=document.createElement("strong");valueNode.textContent=value==null?"\u2014":String(value),item.append(name,valueNode),diagnostics.appendChild(item)});const operations=Array.isArray(server.largest_operations)?server.largest_operations:[];if(operations.length){const heading=document.createElement("div");heading.className="resource-diagnostic-subhead",heading.textContent="Largest recent operations",diagnostics.appendChild(heading),operations.forEach(operation=>{const item=document.createElement("div");item.className="resource-diagnostic-item";const name=document.createElement("span");name.textContent=`${operation.kind} \xB7 ${operation.status}`;const valueNode=document.createElement("strong");valueNode.textContent=`${Number(operation.elapsed||0).toFixed(1)}s`,item.append(name,valueNode),diagnostics.appendChild(item)})}}catch(error){diagnostics.replaceChildren();const item=node("Diagnostics unavailable: "+error.message);item.className="perf-history-empty",diagnostics.appendChild(item)}finally{diagnosticsLoading=!1,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!1)}}}refresh.addEventListener("click",load),diagnosticsRefresh==null||diagnosticsRefresh.addEventListener("click",loadDiagnostics),window.jobsOnShow=function(){load(),loadDiagnostics()},(_a=document.getElementById("s-performance"))!=null&&_a.classList.contains("is-active")&&window.jobsOnShow(),setInterval(()=>{var _a2;(_a2=document.getElementById("s-performance"))!=null&&_a2.classList.contains("is-active")&&(load(),loadDiagnostics())},1e4)}(),function(){"use strict";var root=window.TTVC=window.TTVC||{},entries=new Map,visible=new Set,observer="IntersectionObserver"in window?new IntersectionObserver(function(records){records.forEach(function(record){var img=record.target;if(record.isIntersecting){visible.add(img);var source=img.dataset.ttvcSrc;source&&!img.getAttribute("src")&&img.setAttribute("src",source),entries.delete(img),entries.set(img,Date.now())}else visible.delete(img)}),evict()},{rootMargin:"160px"}):null;function limit(){return typeof window.ttvcIsLowMemoryDevice=="function"&&window.ttvcIsLowMemoryDevice()?24:64}function evict(){for(entries.forEach(function(_,img2){img2.isConnected||entries.delete(img2)});entries.size>limit();){var candidate=null;for(var img of entries.keys())if(!visible.has(img)){candidate=img;break}if(!candidate)break;entries.delete(candidate),candidate.getAttribute("src")&&(candidate.dataset.ttvcSrc=candidate.getAttribute("src")),candidate.removeAttribute("src")}}function observeImages(container){container&&(container.querySelectorAll("img").forEach(function(img){!img.dataset.ttvcSrc&&img.getAttribute("src")&&(img.dataset.ttvcSrc=img.getAttribute("src")),observer&&!img.dataset.ttvcObserved&&(img.dataset.ttvcObserved="1",observer.observe(img)),entries.delete(img),entries.set(img,Date.now())}),evict())}root.imageCache={observe:observeImages,evict,size:function(){return entries.size}},window.ttvcObserveImages=observeImages}(),function(root){var _a,_b,_c;function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url2(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints2(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds2(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds2()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds2();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds2(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+(typeof _umlautSafe=="function"?_umlautSafe(name):name).replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets2(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url2(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints2();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds2(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS API base URL: ${v1} API key: dummy Model: qwen3-tts diff --git a/static/js/fishaudio-browser.js b/static/js/fishaudio-browser.js index 29235b6..9971f4a 100644 --- a/static/js/fishaudio-browser.js +++ b/static/js/fishaudio-browser.js @@ -245,7 +245,7 @@ (function () { const tabs = document.getElementById('gvo-tabs'); if (!tabs) return; - const map = { direct: 'tab-getvoices', fish: 'fa-browser-card', eleven: 'el-browser-card' }; + const map = { direct: 'tab-getvoices', fish: 'fa-browser-card', open: 'open-sources-card', eleven: 'el-browser-card' }; function show(src) { if (!map[src]) src = 'direct'; Object.entries(map).forEach(([k, id]) => { diff --git a/static/js/voice-sources.js b/static/js/voice-sources.js index eb97f7e..180c1fa 100644 --- a/static/js/voice-sources.js +++ b/static/js/voice-sources.js @@ -192,6 +192,62 @@ $('getvoices-reset-sources-btn')?.addEventListener('click', () => { renderGetVoices(); $('getvoices-status').textContent = 'Source list reset.'; }); +// -- LibriVox / Wikimedia Commons (server-side official-API search) ---------- +let _osItems = []; +function osDuration(sec) { sec = Math.round(sec || 0); return sec ? `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}` : ''; } +function renderOpenSources() { + $('os-list').innerHTML = _osItems.map((it, i) => `
+
${escHtml(it.title)}${escHtml([it.reader, it.language, osDuration(it.duration)].filter(Boolean).join(' · '))}
+
Licence: ${it.licence_url ? `${escHtml(it.licence)}` : escHtml(it.licence)}
+ +
+ Source page + +
`).join(''); +} +async function searchOpenSources(ev) { + ev?.preventDefault(); + const st = $('os-status'), btn = $('os-search-btn'); + const q = ($('os-q').value || '').trim(); + if (!q) { st.textContent = 'Enter a search term.'; return; } + btn.disabled = true; st.textContent = 'Searching...'; + try { + const p = new URLSearchParams({q, lang: $('os-lang').value}); + const r = await fetch(`/api/voice-sources/${encodeURIComponent($('os-source').value)}?${p}`); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || r.statusText); + _osItems = d.items || []; + renderOpenSources(); + st.textContent = `${_osItems.length} result${_osItems.length === 1 ? '' : 's'}.`; + } catch (e) { + _osItems = []; renderOpenSources(); st.textContent = 'Search failed: ' + e.message; + } finally { btn.disabled = false; } +} +async function importOpenSource(it) { + const st = $('os-status'); + const lang = sourceLanguageCode(it.language || $('os-lang').value || 'English'); + const body = { + audio_url: it.audio_url, voice_id: suggestedVoiceIdFromSourceName(it.title, lang, 'N'), transcript: '', lang, + source_kind: it.source, page_url: it.source_url, licence: it.licence, attribution: it.attribution || '', + name: it.title, group: it.source, origin: 'cloned', start: +$('os-start').value || 0, length: +$('os-len').value || 30, + note: `${it.reader || ''} ${it.licence}`.trim(), + }; + st.textContent = 'Importing...'; + try { + const r = await fetch('/api/quick-import-voice', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || r.statusText); + st.textContent = `Saved as ${d.voice_id} (source and licence stored).`; + toast(`Saved as ${d.voice_id}`, 'success'); + _libraryLoadPromise = null; + } catch (e) { st.textContent = 'Import failed: ' + e.message; toast('Import failed: ' + e.message, 'error'); } +} +$('os-form')?.addEventListener('submit', searchOpenSources); +$('os-list')?.addEventListener('click', e => { + const b = e.target.closest('.os-import'); + if (b && _osItems[+b.dataset.i]) importOpenSource(_osItems[+b.dataset.i]); +}); + const SOURCE_LANGUAGE_CODES = { english:'EN', german:'DE', deutsch:'DE', french:'FR', spanish:'ES', japanese:'JA', korean:'KO', italian:'IT', portuguese:'PT', russian:'RU', arabic:'AR', polish:'PL', dutch:'NL', swedish:'SV', @@ -304,7 +360,7 @@ Object.assign(root, { sourceTextareaValueFromDefaults, initGetVoiceSourcesEditor, getEditableVoiceSourceUrls, getVoiceSourceItems, voiceSourceSearchText, setOptions, renderGetVoices, loadGetVoices, SOURCE_LANGUAGE_CODES, sourceLanguageCode, sourceGenderCode, suggestedVoiceIdFromSourceName, - setLibAddSourcePreview, getSourceVoiceInLibrary, + setLibAddSourcePreview, getSourceVoiceInLibrary, searchOpenSources, importOpenSource, }); root.TTVC = root.TTVC || {}; root.TTVC.voiceSources = root.TTVC.voiceSources || {}; diff --git a/static/sections/s-studio.html b/static/sections/s-studio.html index d68ee49..47596e9 100644 --- a/static/sections/s-studio.html +++ b/static/sections/s-studio.html @@ -10,6 +10,7 @@
+
@@ -43,6 +44,21 @@ + +
diff --git a/static/style.css b/static/style.css index 7bc4897..faf737c 100644 --- a/static/style.css +++ b/static/style.css @@ -6026,3 +6026,8 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night .insp-prov-input:focus-visible, .insp-prov-panel button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .insp-prov-panel button { min-height: 44px; min-width: 44px; } .insp-prov-rec { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } + +/* LibriVox / Wikimedia Commons search card: 44px touch targets */ +#open-sources-card .getvoices-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +#open-sources-card button, #open-sources-card input, #open-sources-card select { min-height: 44px; } +#open-sources-card .voice-source-actions a { display: inline-flex; align-items: center; min-height: 44px; } diff --git a/tests/test_voice_sources_new.py b/tests/test_voice_sources_new.py new file mode 100644 index 0000000..b3c514f --- /dev/null +++ b/tests/test_voice_sources_new.py @@ -0,0 +1,139 @@ +"""LibriVox / Wikimedia Commons voice sources: validation, caps, host checks, mapping, provenance, cache/throttle (HTTP mocked).""" +import asyncio +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])) +os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-vs-test-config-")) +from core import open_voice_sources as ovs +import routes.sources as src + + +def book(i=1, lang="English", url="https://www.archive.org/download/x/a.mp3"): + return {"title": f"Book {i}", "language": lang, "url_librivox": f"https://librivox.org/b{i}/", + "authors": [{"first_name": "Jane", "last_name": "Austen"}], + "sections": [{"title": "Ch 1", "listen_url": url, "playtime": "600", "readers": [{"display_name": "Ann"}]}]} + + +def commons(**over): + info = {"url": "https://upload.wikimedia.org/a.opus", "descriptionurl": "https://commons.wikimedia.org/wiki/File:A.opus", + "mediatype": "AUDIO", "duration": 12.4, + "extmetadata": {"LicenseShortName": {"value": "CC BY-SA 4.0"}, "Artist": {"value": "Bob & Co"}, + "LicenseUrl": {"value": "https://creativecommons.org/licenses/by-sa/4.0"}}} + info.update(over) + return {"query": {"pages": {"1": {"title": "File:A.opus", "index": 1, "imageinfo": [info]}}}} + + +class Base(unittest.TestCase): + def setUp(self): + ovs._cache.clear() + ovs._last_call.clear() + p = mock.patch.object(ovs.time, "sleep") + p.start() + self.addCleanup(p.stop) + + +class ValidationTests(Base): + def test_query_and_lang(self): + for bad in ("", " ", "x" * 81): + with self.assertRaises(ovs.SourceError): + ovs.clean_query(bad) + self.assertEqual(ovs.clean_query(" a\x00 \n b "), "a b") + with self.assertRaises(ovs.SourceError): + ovs.clean_lang("en;rm") + with self.assertRaises(ovs.SourceError): + ovs.search("evil", "x") + + def test_host_check(self): + self.assertTrue(ovs.host_ok("https://ia80.us.archive.org/x.mp3", ovs.AUDIO_HOSTS)) + for bad in ("http://archive.org/x", "https://evilarchive.org/x", "https://archive.org.evil.com/x", + "https://u:p@archive.org/x", "https://127.0.0.1/x", "file:///etc/passwd"): + self.assertFalse(ovs.host_ok(bad, ovs.AUDIO_HOSTS), bad) + + +class MappingTests(Base): + def test_librivox_mapping_filter_cap_and_bad_hosts(self): + books = [book(i) for i in range(15)] + [book(99, url="https://evil.example/a.mp3"), book(98, lang="German")] + with mock.patch.object(ovs, "_get_json", return_value={"books": books}) as g: + items = ovs.search("librivox", "pride", "en") + self.assertEqual(len(items), ovs.MAX_RESULTS) + self.assertEqual(g.call_args[0][0], ovs.LIBRIVOX_API) + it = items[0] + self.assertEqual((it["reader"], it["author"], it["language"], it["duration"]), ("Ann", "Jane Austen", "English", 600)) + self.assertTrue(it["licence_url"].startswith("https://librivox.org/")) + self.assertTrue(all("evil" not in i["audio_url"] and i["language"] == "English" for i in items)) + + def test_wikimedia_mapping(self): + with mock.patch.object(ovs, "_get_json", return_value=commons()): + it = ovs.search("wikimedia", "speech")[0] + self.assertEqual((it["title"], it["reader"], it["licence"], it["duration"]), ("A", "Bob & Co", "CC BY-SA 4.0", 12)) + self.assertIn("Bob & Co / A, CC BY-SA 4.0", it["attribution"]) + + def test_wikimedia_drops_unsafe_or_unlicensed(self): + for over in ({"url": "https://evil.example/a.opus"}, {"mediatype": "VIDEO"}, {"extmetadata": {}}): + ovs._cache.clear() + with mock.patch.object(ovs, "_get_json", return_value=commons(**over)): + self.assertEqual(ovs.search("wikimedia", "speech"), []) + + +class ProvenanceTests(Base): + def test_librivox(self): + p = ovs.import_provenance_for("librivox", "https://librivox.org/b/", "", "") + self.assertEqual(p["basis"], "public_domain") + self.assertIn("reader's declared licence", p["note"]) + self.assertEqual(p["source"], "https://librivox.org/b/") + + def test_wikimedia(self): + p = ovs.import_provenance_for("wikimedia", "https://commons.wikimedia.org/wiki/File:A.opus", "CC BY 4.0", "Bob / A") + self.assertEqual((p["basis"], p["licence"]), ("licensed", "CC BY 4.0")) + self.assertIn("Bob / A", p["note"]) + self.assertEqual(ovs.import_provenance_for("wikimedia", "https://c.org/", "Public domain", "")["basis"], "public_domain") + with self.assertRaises(ValueError): + ovs.import_provenance_for("other", "", "", "") + + +class CacheThrottleTests(Base): + def test_cache_hit_skips_upstream(self): + with mock.patch.object(ovs, "_get_json", return_value={"books": [book()]}) as g: + ovs.search("librivox", "Pride"); ovs.search("librivox", "pride") + self.assertEqual(g.call_count, 1) + + def test_throttle_sleeps_and_errors_are_generic(self): + resp = mock.Mock(status_code=200) + resp.raise_for_status.return_value = None + resp.iter_content.return_value = [b'{"books": []}'] + with mock.patch.object(ovs.requests, "get", return_value=resp) as g: + ovs._get_json(ovs.LIBRIVOX_API, {}); ovs._get_json(ovs.LIBRIVOX_API, {}) + self.assertTrue(ovs.time.sleep.called) + self.assertFalse(g.call_args.kwargs["allow_redirects"]) + self.assertIn("User-Agent", g.call_args.kwargs["headers"]) + with mock.patch.object(ovs.requests, "get", side_effect=OSError("secret host")): + with self.assertRaises(ovs.SourceError) as cm: + ovs._get_json(ovs.COMMONS_API, {}) + self.assertNotIn("secret", str(cm.exception)) + + +class RouteTests(Base): + def test_search_route_errors_and_allowlist(self): + with self.assertRaises(src.HTTPException) as cm: + asyncio.run(src.open_voice_source_search("librivox", "")) + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("archive.org", src._PROXY_AUDIO_DOMAINS) + self.assertIn("upload.wikimedia.org", src._PROXY_AUDIO_DOMAINS) + + def test_quick_import_rejects_foreign_host(self): + req = mock.Mock() + async def js(): return {"audio_url": "https://example.com/a.mp3", "voice_id": "x", "source_kind": "librivox"} + req.json = js + with mock.patch.object(src, "_source_import_url", side_effect=lambda u: u): + with self.assertRaises(src.HTTPException) as cm: + asyncio.run(src.quick_import_voice(req)) + self.assertEqual(cm.exception.status_code, 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/voice-sources-new.spec.js b/tests/voice-sources-new.spec.js new file mode 100644 index 0000000..ff54adc --- /dev/null +++ b/tests/voice-sources-new.spec.js @@ -0,0 +1,43 @@ +// @ts-check +// LibriVox / Wikimedia search + import in "Get Voices Online" (mocked routes only). +const { test, expect } = require('@playwright/test'); + +const ITEMS = [{ source: 'librivox', title: 'Pride - Ch 1', reader: 'Ann', author: 'Jane Austen', language: 'English', duration: 600, + licence: 'Public domain (LibriVox, USA)', licence_url: 'https://librivox.org/pages/public-domain/', source_url: 'https://librivox.org/b/', + audio_url: 'https://www.archive.org/download/x/a.mp3', attribution: '' }]; + +test('search shows licence, import sends provenance fields', async ({ page }) => { + let imported = null, searched = ''; + await page.route('**/api/voice-sources/librivox*', r => { searched = r.request().url(); return r.fulfill({ json: { source: 'librivox', items: ITEMS } }); }); + await page.route('**/api/proxy-audio*', r => r.fulfill({ body: '', contentType: 'audio/mpeg' })); + await page.route('**/api/quick-import-voice', r => { imported = r.request().postDataJSON(); return r.fulfill({ json: { voice_id: 'EN_N_Pride' } }); }); + await page.route('**/api/voices*', r => r.fulfill({ json: [] })); + await page.route('**/api/settings*', r => r.fulfill({ json: {} })); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.navTo === 'function' && !!document.getElementById('os-form'), null, { timeout: 15_000 }); + await page.evaluate(() => window.navTo('s-studio')); + await page.locator('.gvo-tab[data-src="open"]').click(); + await page.fill('#os-q', 'pride'); + await page.press('#os-q', 'Enter'); + await expect(page.locator('#os-list')).toContainText('Public domain (LibriVox, USA)'); + expect(searched).toContain('q=pride'); + const b = page.locator('.os-import'); + const h = await b.evaluate(el => el.getBoundingClientRect().height); + expect(h).toBeGreaterThanOrEqual(44); + await b.click(); + await expect(page.locator('#os-status')).toContainText('Saved as EN_N_Pride'); + expect(imported).toMatchObject({ source_kind: 'librivox', page_url: 'https://librivox.org/b/', start: 0, length: 30 }); +}); + +test('search error is shown in the status region', async ({ page }) => { + await page.route('**/api/voice-sources/wikimedia*', r => r.fulfill({ status: 502, json: { detail: 'wikimedia is unavailable right now' } })); + await page.route('**/api/voices*', r => r.fulfill({ json: [] })); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.navTo === 'function' && !!document.getElementById('os-form'), null, { timeout: 15_000 }); + await page.evaluate(() => window.navTo('s-studio')); + await page.locator('.gvo-tab[data-src="open"]').click(); + await page.selectOption('#os-source', 'wikimedia'); + await page.fill('#os-q', 'speech'); + await page.evaluate(() => document.getElementById('os-form').requestSubmit()); + await expect(page.locator('#os-status')).toContainText('unavailable'); +});