diff --git a/CHANGELOG.md b/CHANGELOG.md index b097700..9d82aab 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 +- **Seed Finder: Auto-pick best.** `POST /api/seed-autopick {voice, text, seeds|count}` (default 8, max 16) generates candidates sequentially through the existing temporary-voice path (voices.json is always restored), scores each 0-100 (ASR word accuracy, loudness/clipping, `audio_problems`) and returns all sub-scores plus the winner. Degrades to loudness/problems when STT is unavailable. The UI highlights the winner; saving stays a click on "Use seed". Tests: `tests/test_seed_autopick.py`, `tests/seed-autopick.spec.js`. - **Voice Design: attribute chips.** Toggle chips (gender, age, pitch, pace, accent/region, tone/timbre, emotion; German + English labels) above the design prompt add or remove only their own phrase in the free-text description. The textarea stays fully free-form. Test: `tests/design-chips.spec.js`. - **Voice health: timestamped audio problems.** `core.audio.audio_problems` flags long interior silence, clipping and sudden level jumps (`problems` list with `kind`, `start_s`, `end_s`, `detail`, plus one warning per kind) in the route-test voice health; existing keys unchanged. Computed on demand and cached by file mtime, not in the voice list. Shown in the routing test result and in the Try-out voice panel (new `GET /api/voices/{id}/health`); imports store the result in meta (`audio_problems`). Tests: `tests/test_audio_problems.py`, `tests/voice-health.spec.js`. diff --git a/Manual.md b/Manual.md index c27d100..036512a 100644 --- a/Manual.md +++ b/Manual.md @@ -21,6 +21,16 @@ Living manual. Every feature updates this file in the same commit as the code. **When computed:** on import (stored in the voice's meta as `audio_problems`), and fresh on demand via `GET /api/voices/{id}/health` (cached by file modification time). The voice list only returns the stored result. **Errors:** unreadable audio yields no problems; only the first 120 s are scanned. **Privacy/offline:** local analysis only. +## Seed Finder: Auto-pick best + +**Purpose.** Finds a good seed for a voice without listening to every sample first. +**Prerequisites.** A voice with a reference clip (Voice Clone backend); a speech-to-text engine is optional. +**Steps.** Open the voice, then *Seed Finder*. Set the test sentence and the seed range (at most 16 seeds are tried), then click *Auto-pick best*. The server generates each candidate one after another, scores it and the best row is marked "Best". Play any row, then click *Use seed N* to save it. Nothing is saved automatically. +**Score (0-100).** Weighted mean of: spoken-back word accuracy (50%, text read back by speech-to-text), loudness and clipping (20%), audio problems such as long silences and sudden level jumps (30%). Weights and penalties are constants in `core/seed_score.py`. +**Errors.** If speech-to-text is down, the result says so and scores by loudness and audio problems only. A failed seed shows its error and is skipped. *Cancel* stops after the running candidate; the voice configuration is always restored. +**Mobile and accessibility.** Progress and the result are announced in a polite live region; the button is keyboard operable. German and English labels. +**Offline.** Needs the TTS server (and optionally the STT server) on your network. **Privacy.** The sentence is sent only to your own TTS/STT servers. + ## Audiobook casting (Read Aloud → Cast as Audiobook) ### Purpose diff --git a/architecture/seed-autopick.md b/architecture/seed-autopick.md new file mode 100644 index 0000000..de8c114 --- /dev/null +++ b/architecture/seed-autopick.md @@ -0,0 +1,6 @@ +# Seed auto-pick + +- `POST /api/seed-autopick` (routes/tts.py) validates input (`core/seed_score.seed_list`: explicit seeds 0-9999 or `count` 1-16, default 8), then for each seed calls `_seed_finder_request_audio` in a worker thread (shared `_SEED_FINDER_VOICE_LOCK`, voices.json restored in its `finally`), scores it in a second thread and moves on. Per-candidate timeout 240 s; a timeout ends the run (the worker still holds the lock until it restores). Client disconnect is checked between candidates (cancel = abort the fetch). +- Scoring: `core/seed_score.py` (constants at top, formula in the docstring). Sub-scores come from `routes.stt._transcribe_audio` + `_word_accuracy` (imported lazily), `core.audio._loudness_info`, `core.audio.audio_problems`. First STT failure disables ASR for the rest of the run (`degraded: true`, `stt.note`). +- Never saves. The UI confirms via the existing `/api/tts-voice-seed`. +- Limits: sequential, so 16 candidates take minutes; audio returns base64 in the JSON (about 0.5 MB each). diff --git a/core/seed_score.py b/core/seed_score.py new file mode 100644 index 0000000..801e8e5 --- /dev/null +++ b/core/seed_score.py @@ -0,0 +1,72 @@ +"""Seed auto-pick scoring: combine ASR accuracy, loudness/clipping and audio problems into 0-100. + +score = weighted mean of the available sub-scores (each 0-100). Without ASR the +accuracy weight is dropped and the rest re-normalised ("degraded" result). + accuracy = ASR round-trip word accuracy (routes.stt._word_accuracy) + loudness = 100 - |dBFS - target| * LOUD_PEN_PER_DB (capped) - CLIP_PEN if peak hits full scale + problems = 100 - sum of per-kind penalties from core.audio.audio_problems +""" +from __future__ import annotations + +W_ACCURACY = 0.5 +W_LOUDNESS = 0.2 +W_PROBLEMS = 0.3 + +LOUD_PEN_PER_DB = 4.0 # points lost per dB away from the target level +LOUD_PEN_MAX = 60.0 +CLIP_PEN = 30.0 # peak at/above CLIP_PEAK_DBFS +CLIP_PEAK_DBFS = -0.1 + +PROBLEM_PENALTY = {"silence": 25.0, "level_jump": 20.0, "clipping": 10.0} +PROBLEM_PENALTY_DEFAULT = 10.0 + +DEFAULT_COUNT = 8 +MAX_COUNT = 16 + + +def loudness_score(info: dict) -> float: + dbfs, peak, target = info.get("dbfs"), info.get("peak_dbfs"), info.get("target_dbfs", -20.0) + if dbfs is None: + return 0.0 + s = 100.0 - min(LOUD_PEN_MAX, abs(dbfs - target) * LOUD_PEN_PER_DB) + if peak is not None and peak >= CLIP_PEAK_DBFS: + s -= CLIP_PEN + return max(0.0, s) + + +def problems_score(problems: list[dict]) -> float: + pen = sum(PROBLEM_PENALTY.get(p.get("kind"), PROBLEM_PENALTY_DEFAULT) for p in problems) + return max(0.0, 100.0 - pen) + + +def combine(accuracy: float | None, loudness: float, problems: float) -> float: + parts = [(W_LOUDNESS, loudness), (W_PROBLEMS, problems)] + if accuracy is not None: + parts.append((W_ACCURACY, accuracy)) + return round(sum(w * v for w, v in parts) / sum(w for w, _ in parts), 1) + + +def pick_winner(candidates: list[dict]) -> int | None: + """Seed with the highest score (ties: lowest seed); errored candidates are skipped.""" + ok = [c for c in candidates if not c.get("error") and c.get("score") is not None] + return min(ok, key=lambda c: (-c["score"], c["seed"]))["seed"] if ok else None + + +def seed_list(seeds, count) -> list[int]: + """Validate the request: explicit seeds win, else 1..count; bounded to MAX_COUNT.""" + if seeds is not None: + if not isinstance(seeds, list) or not seeds: + raise ValueError("seeds must be a non-empty list") + out = [] + for s in seeds: + if isinstance(s, bool) or not isinstance(s, int) or not 0 <= s <= 9999: + raise ValueError("seeds must be integers 0-9999") + if s not in out: + out.append(s) + if len(out) > MAX_COUNT: + raise ValueError(f"at most {MAX_COUNT} seeds") + return out + n = DEFAULT_COUNT if count is None else count + if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= MAX_COUNT: + raise ValueError(f"count must be 1-{MAX_COUNT}") + return list(range(1, n + 1)) diff --git a/routes/tts.py b/routes/tts.py index 61da9bd..341f10a 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import base64 import io import json import logging @@ -1314,6 +1315,92 @@ async def tts_voice_seed(request: Request): raise HTTPException(e.response.status_code, detail or str(e)) +_AUTOPICK_CANDIDATE_TIMEOUT_S = 240 + + +def _autopick_score_audio(audio: bytes, text: str, settings: dict, stt_ok: bool) -> dict: + """Blocking: write the sample to a temp WAV, run ASR (if available) + loudness + problems.""" + from core.audio import _loudness_info, audio_problems + from core import seed_score as sc + tmp = TEMP_DIR / f"autopick_{uuid.uuid4().hex}.wav" + tmp.write_bytes(audio) + try: + accuracy, stt_error = None, None + if stt_ok: + from routes import stt as _stt # lazy: avoids import-order coupling + try: + transcript, _ = _stt._transcribe_audio(tmp, settings) + accuracy = _stt._word_accuracy(text, transcript) + except Exception as exc: + stt_error = str(exc)[:200] + loud = _loudness_info(tmp) + problems = audio_problems(tmp) + finally: + tmp.unlink(missing_ok=True) + loud_s, prob_s = sc.loudness_score(loud), sc.problems_score(problems) + return { + "score": sc.combine(accuracy, loud_s, prob_s), + "accuracy": None if accuracy is None else round(accuracy, 1), + "loudness_score": round(loud_s, 1), "problems_score": round(prob_s, 1), + "loudness": loud, "problems": problems, "stt_error": stt_error, + } + + +@router.post("/api/seed-autopick") +async def seed_autopick(request: Request): + """Generate N seed candidates sequentially, score them, return all plus the winner. + + Body: {"voice", "text", "seeds": [..] | "count": 1-16 (default 8)}. Never saves a seed; + the UI confirms via /api/tts-voice-seed. Stops early if the client disconnects. + """ + from core import seed_score as sc + data = await request.json() + if not isinstance(data, dict): + raise HTTPException(400, "JSON object expected") + voice, text = data.get("voice"), data.get("text") + if not isinstance(voice, str) or not voice.strip() or len(voice) > 200: + raise HTTPException(400, "voice required") + if not isinstance(text, str) or not text.strip() or len(text) > 1500: + raise HTTPException(400, "text required (max 1500 characters)") + try: + seeds = sc.seed_list(data.get("seeds"), data.get("count")) + except ValueError as exc: + raise HTTPException(400, str(exc)) + settings = _load_settings() + stt_ok, stt_note, cancelled = True, "", False + candidates: list[dict] = [] + for seed in seeds: + if await request.is_disconnected(): + cancelled = True + break + cand: dict = {"seed": seed} + try: + audio, media_type = await asyncio.wait_for( + asyncio.to_thread(_seed_finder_request_audio, text.strip(), voice, settings, seed), + _AUTOPICK_CANDIDATE_TIMEOUT_S) + cand.update(await asyncio.to_thread(_autopick_score_audio, audio, text.strip(), settings, stt_ok)) + if stt_ok and cand["accuracy"] is None: + stt_ok, stt_note = False, cand["stt_error"] or "speech-to-text unavailable" + cand["media_type"] = media_type + cand["audio_b64"] = base64.b64encode(audio).decode("ascii") + except asyncio.TimeoutError: + # The worker thread still holds the voices.json lock and restores it when done; + # starting another candidate now would only queue behind it. + candidates.append({**cand, "error": "timeout"}) + break + except Exception as exc: + logger.warning("Seed auto-pick seed %s failed: %s", seed, exc) + cand["error"] = str(exc)[:200] or "generation failed" + candidates.append(cand) + return { + "voice": voice, "winner": sc.pick_winner(candidates), "candidates": candidates, + "requested": len(seeds), "cancelled": cancelled, + "stt": {"available": stt_ok, "note": stt_note}, + "degraded": not stt_ok, + "weights": {"accuracy": sc.W_ACCURACY, "loudness": sc.W_LOUDNESS, "problems": sc.W_PROBLEMS}, + } + + @router.get("/api/seed-samples/{voice_name}") async def list_seed_samples(voice_name: str): """List pre-generated seed sample numbers for a voice (batch script output).""" diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 9aee3e5..9a383b6 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -44,6 +44,7 @@ ${escHtml(genderLabelHtml)} ${isClone?"Clone":"Design"} + ${window.provenanceBadge?window.provenanceBadge(v):""} ${inspBenchText!=="-"?` ${escHtml(inspBenchText)}`:""} ${makeStars(rating)} @@ -69,7 +70,27 @@ - `,body.appendChild(personaPanel);const personaTextarea=personaPanel.querySelector(".insp-persona-input"),personaStatus=personaPanel.querySelector(".insp-persona-status");personaPanel.querySelector(".insp-persona-save-btn").addEventListener("click",async()=>{const pText=personaTextarea.value.trim();try{await fetch("/api/voice/meta",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,persona:pText})}),v.persona=pText,personaStatus.textContent="Saved.",setTimeout(()=>{personaStatus.textContent=""},2e3),toast("Persona saved","success")}catch(e){toast("Save failed: "+e.message,"error")}});const maintTitle=body.querySelector(".opt-maintenance .opt-group-title");maintTitle&&(maintTitle.innerHTML=`Loudness Current ${escHtml(dbfs)} dBFS`),body.querySelectorAll(".opt-group").forEach(group=>{const title=group.querySelector(":scope > .opt-group-title");title&&title.addEventListener("click",()=>{group.classList.toggle("open")})}),typeof attachSeedFinder=="function"&&attachSeedFinder(voiceId,body),wrap._extracted=[activeEl?{el:activeEl,target:detailRow}:null,deleteEl?{el:deleteEl,target:detailRow}:null,noteEl?{el:noteEl,target:detailRow}:null].filter(Boolean)}root.selectVoice=selectVoice2,root.TTVC=root.TTVC||{},root.TTVC.voiceInspector=root.TTVC.voiceInspector||{},Object.assign(root.TTVC.voiceInspector,{select:selectVoice2})}(window),function(root){var _a;const SEED_FINDER_TEXT_DE="Die dreitausendf\xFCnfhundertsiebenundsechzig neuen Ger\xE4te f\xFCr das Update ben\xF6tigten eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Wartung, um die Leistung bei gro\xDFer Last zu gew\xE4hrleisten. Notiere dir den einundzwanzigsten Juni um vierzehn Uhr. Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Tr\xE4ume beginnen mit einem positiven Mindset.",SEED_FINDER_TEXT_EN="The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.",SEED_FINDER_TEXT_TORTURE="Die 3.567 neuen High-End Ger\xE4te f\xFCr das Server-Update ben\xF6tigen eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Ma\xDFnahmen, um die Performance bei gro\xDFer Last zu gew\xE4hrleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Sch\xF6ne Gr\xFC\xDFe! \u2014 Madonna - Tr\xE4ume beginnt mit einem positiven Mindset.";function _seedFinderDefaultText(voiceId){if(window._appSettings&&window._appSettings.seed_finder_text)return window._appSettings.seed_finder_text;const v=(window._voices||[]).find(x=>x.id===voiceId);if(v&&v.transcript&&v.transcript.trim())return v.transcript.trim();const lc=(voiceId||"").toLowerCase();return lc.startsWith("de_")?SEED_FINDER_TEXT_DE:lc.startsWith("en_")||lc.startsWith("gb_")?SEED_FINDER_TEXT_EN:SEED_FINDER_TEXT_DE}const SF_DB="seed-finder",SF_STORE="samples",SF_CACHE_VERSION="wav-seed-v2";function _sfHash(s){let h=0;for(let i=0;i>>0;return h.toString(36)}function _sfDbOpen(){return new Promise((res,rej)=>{const r=indexedDB.open(SF_DB,1);r.onupgradeneeded=e=>{const db=e.target.result;db.objectStoreNames.contains(SF_STORE)||db.createObjectStore(SF_STORE,{keyPath:"key"})},r.onsuccess=e=>res(e.target.result),r.onerror=e=>rej(e.target.error)})}async function _sfDbGet(key){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).get(key);r.onsuccess=e=>res(e.target.result||null),r.onerror=e=>rej(e.target.error)})}catch{return null}}async function _sfDbPut(rec){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE).put(rec);r.onsuccess=()=>res(),r.onerror=e=>rej(e.target.error)})}catch{}}async function _sfDbAllForVoice(voiceId){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const out=[],cur=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&out.push(c.value),c.continue()):res(out)},cur.onerror=e=>rej(e.target.error)})}catch{return[]}}async function _sfDbClearVoice(voiceId){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const store=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE),cur=store.openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&store.delete(c.primaryKey),c.continue()):res()},cur.onerror=e=>rej(e.target.error)})}catch{}}function attachSeedFinder2(voiceId,body){const panel=document.createElement("div");panel.className="opt-group seed-finder-group",panel.innerHTML=` + `,body.appendChild(personaPanel);const personaTextarea=personaPanel.querySelector(".insp-persona-input"),personaStatus=personaPanel.querySelector(".insp-persona-status");personaPanel.querySelector(".insp-persona-save-btn").addEventListener("click",async()=>{const pText=personaTextarea.value.trim();try{await fetch("/api/voice/meta",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,persona:pText})}),v.persona=pText,personaStatus.textContent="Saved.",setTimeout(()=>{personaStatus.textContent=""},2e3),toast("Persona saved","success")}catch(e){toast("Save failed: "+e.message,"error")}}),attachProvenancePanel(v,voiceId,body);const maintTitle=body.querySelector(".opt-maintenance .opt-group-title");maintTitle&&(maintTitle.innerHTML=`Loudness Current ${escHtml(dbfs)} dBFS`),body.querySelectorAll(".opt-group").forEach(group=>{const title=group.querySelector(":scope > .opt-group-title");title&&title.addEventListener("click",()=>{group.classList.toggle("open")})}),typeof attachSeedFinder=="function"&&attachSeedFinder(voiceId,body),wrap._extracted=[activeEl?{el:activeEl,target:detailRow}:null,deleteEl?{el:deleteEl,target:detailRow}:null,noteEl?{el:noteEl,target:detailRow}:null].filter(Boolean)}const PROV_BASES=[["unknown","Unknown"],["own_voice","My own voice"],["consented","Speaker consented"],["licensed","Licensed"],["public_domain","Public domain"]];root.provenanceBadge=v=>(v&&v.provenance&&v.provenance.basis||"unknown")==="unknown"?' Source unknown':"";function attachProvenancePanel(v,voiceId,body){const p=v.provenance||{},id="prov-"+voiceId.replace(/[^A-Za-z0-9_-]/g,"_"),panel=document.createElement("section");panel.className="insp-persona-panel insp-prov-panel",panel.setAttribute("aria-labelledby",id+"-h"),panel.innerHTML=` +
+

Source & consent

+ Where this voice comes from and why you may use it. Stored locally in this voice's metadata only; nothing is sent anywhere. Generation is not blocked by this. +
+ + + + + + + + +
+ + +
+
+ + +
`,body.appendChild(panel);const q=sel=>panel.querySelector(sel),status2=q(".insp-persona-status"),say=(msg,err)=>{status2.textContent=msg,status2.style.color=err?"var(--danger, #dc2626)":""};q(".insp-prov-save").addEventListener("click",async()=>{try{const r=await fetch(`/api/voices/${encodeURIComponent(voiceId)}/provenance`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({basis:q(`#${id}-basis`).value,source:q(`#${id}-source`).value,licence:q(`#${id}-licence`).value,note:q(`#${id}-note`).value})}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);v.provenance=d.provenance,typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),say("Saved.")}catch(e){say("Save failed: "+e.message,!0)}});const btn=q(".insp-prov-rec-btn");let rec=null;btn.addEventListener("click",async()=>{if(rec){rec.stop();return}if(!navigator.mediaDevices||!window.MediaRecorder){say("Recording is not supported in this browser. Type the consent statement in the note field instead.",!0);return}let stream;try{stream=await navigator.mediaDevices.getUserMedia({audio:!0})}catch(e){say(e&&e.name==="NotAllowedError"?"Microphone permission was denied. Allow it in the browser, or type the consent statement in the note field.":"No usable microphone found. Type the consent statement in the note field instead.",!0);return}const chunks=[];rec=new MediaRecorder(stream),rec.ondataavailable=ev=>{ev.data.size&&chunks.push(ev.data)},rec.onstop=async()=>{stream.getTracks().forEach(t=>t.stop()),rec=null,btn.textContent="Record spoken consent",btn.setAttribute("aria-pressed","false");try{const fd=new FormData;fd.append("file",new Blob(chunks,{type:"audio/webm"}),"consent.webm");const r=await fetch(`/api/voices/${encodeURIComponent(voiceId)}/consent-recording`,{method:"POST",body:fd}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);v.provenance=d.provenance;const a=q(".insp-prov-audio");a.src=`/api/voices/${encodeURIComponent(voiceId)}/consent-recording?t=${Date.now()}`,a.hidden=!1,say("Consent recording saved.")}catch(e){say("Upload failed: "+e.message,!0)}},rec.start(),btn.textContent="Stop recording",btn.setAttribute("aria-pressed","true"),say("Recording... say who you are, that you consent to this voice being cloned, and for what use.")})}root.selectVoice=selectVoice2,root.TTVC=root.TTVC||{},root.TTVC.voiceInspector=root.TTVC.voiceInspector||{},Object.assign(root.TTVC.voiceInspector,{select:selectVoice2})}(window),function(root){var _a;const SEED_FINDER_TEXT_DE="Die dreitausendf\xFCnfhundertsiebenundsechzig neuen Ger\xE4te f\xFCr das Update ben\xF6tigten eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Wartung, um die Leistung bei gro\xDFer Last zu gew\xE4hrleisten. Notiere dir den einundzwanzigsten Juni um vierzehn Uhr. Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Tr\xE4ume beginnen mit einem positiven Mindset.",SEED_FINDER_TEXT_EN="The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.",SEED_FINDER_TEXT_TORTURE="Die 3.567 neuen High-End Ger\xE4te f\xFCr das Server-Update ben\xF6tigen eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Ma\xDFnahmen, um die Performance bei gro\xDFer Last zu gew\xE4hrleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Sch\xF6ne Gr\xFC\xDFe! \u2014 Madonna - Tr\xE4ume beginnt mit einem positiven Mindset.",_sfT=s=>typeof window.t=="function"?window.t(s):s;function _seedFinderDefaultText(voiceId){if(window._appSettings&&window._appSettings.seed_finder_text)return window._appSettings.seed_finder_text;const v=(window._voices||[]).find(x=>x.id===voiceId);if(v&&v.transcript&&v.transcript.trim())return v.transcript.trim();const lc=(voiceId||"").toLowerCase();return lc.startsWith("de_")?SEED_FINDER_TEXT_DE:lc.startsWith("en_")||lc.startsWith("gb_")?SEED_FINDER_TEXT_EN:SEED_FINDER_TEXT_DE}const SF_DB="seed-finder",SF_STORE="samples",SF_CACHE_VERSION="wav-seed-v2";function _sfHash(s){let h=0;for(let i=0;i>>0;return h.toString(36)}function _sfDbOpen(){return new Promise((res,rej)=>{const r=indexedDB.open(SF_DB,1);r.onupgradeneeded=e=>{const db=e.target.result;db.objectStoreNames.contains(SF_STORE)||db.createObjectStore(SF_STORE,{keyPath:"key"})},r.onsuccess=e=>res(e.target.result),r.onerror=e=>rej(e.target.error)})}async function _sfDbGet(key){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).get(key);r.onsuccess=e=>res(e.target.result||null),r.onerror=e=>rej(e.target.error)})}catch{return null}}async function _sfDbPut(rec){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE).put(rec);r.onsuccess=()=>res(),r.onerror=e=>rej(e.target.error)})}catch{}}async function _sfDbAllForVoice(voiceId){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const out=[],cur=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&out.push(c.value),c.continue()):res(out)},cur.onerror=e=>rej(e.target.error)})}catch{return[]}}async function _sfDbClearVoice(voiceId){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const store=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE),cur=store.openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&store.delete(c.primaryKey),c.continue()):res()},cur.onerror=e=>rej(e.target.error)})}catch{}}function attachSeedFinder2(voiceId,body){const panel=document.createElement("div");panel.className="opt-group seed-finder-group",panel.innerHTML=`
\u{1F3B2} Seed Finder @@ -103,10 +124,12 @@
+
+
@@ -130,19 +153,19 @@
- `,body.appendChild(panel);let _batchLoaded=!1;panel.querySelector(".opt-group-title").addEventListener("click",()=>{panel.classList.toggle("open"),panel.classList.contains("open")&&!_batchLoaded&&(_batchLoaded=!0,_loadBatchSamples())});const textEl=panel.querySelector(".seed-finder-text"),fromEl=panel.querySelector(".seed-finder-from"),toEl=panel.querySelector(".seed-finder-to"),backendEl=panel.querySelector(".seed-finder-backend"),runBtn=panel.querySelector(".seed-finder-run-btn"),cancelBtn=panel.querySelector(".seed-finder-cancel-btn"),clearBtn=panel.querySelector(".seed-finder-clear-btn"),statusEl=panel.querySelector(".seed-finder-status"),progressEl=panel.querySelector(".seed-finder-progress"),progLabel=panel.querySelector(".seed-finder-progress-label"),progCount=panel.querySelector(".seed-finder-progress-count"),progBar=panel.querySelector(".seed-finder-bar"),resultsEl=panel.querySelector(".seed-finder-results");textEl.value=_seedFinderDefaultText(voiceId);const pinInput=panel.querySelector(".seed-finder-pin-input"),pinBtn=panel.querySelector(".seed-finder-pin-btn"),pinClear=panel.querySelector(".seed-finder-pin-clear"),pinStatus=panel.querySelector(".seed-finder-pin-status"),voiceObj=(window._voices||[]).find(v=>v.id===voiceId)||{};voiceObj.seed!==void 0&&voiceObj.seed!==null&&(pinInput.value=voiceObj.seed,pinStatus.textContent=`\u2713 Pinned seed ${voiceObj.seed}`),voiceObj.has_ref||(backendEl.value="voice_design");let _cancelled=!1,_currentAudio=null;async function _loadBatchSamples(){try{const resp=await fetch(`/api/seed-samples/${encodeURIComponent(voiceId)}`);if(!resp.ok)return;const{seeds}=await resp.json();if(!seeds||seeds.length===0)return;resultsEl.innerHTML="";const header=document.createElement("div");header.className="sfr-batch-header",header.textContent=`${seeds.length} pre-generated sample${seeds.length!==1?"s":""} from batch run \u2014 click Play to listen`,resultsEl.appendChild(header);for(const seed of seeds){const row=document.createElement("div");row.className="seed-finder-result-row sfr-batch",row.dataset.seed=seed,row.innerHTML=` + `,body.appendChild(panel);let _batchLoaded=!1;panel.querySelector(".opt-group-title").addEventListener("click",()=>{panel.classList.toggle("open"),panel.classList.contains("open")&&!_batchLoaded&&(_batchLoaded=!0,_loadBatchSamples())});const textEl=panel.querySelector(".seed-finder-text"),fromEl=panel.querySelector(".seed-finder-from"),toEl=panel.querySelector(".seed-finder-to"),backendEl=panel.querySelector(".seed-finder-backend"),runBtn=panel.querySelector(".seed-finder-run-btn"),cancelBtn=panel.querySelector(".seed-finder-cancel-btn"),clearBtn=panel.querySelector(".seed-finder-clear-btn"),statusEl=panel.querySelector(".seed-finder-status"),progressEl=panel.querySelector(".seed-finder-progress"),progLabel=panel.querySelector(".seed-finder-progress-label"),progCount=panel.querySelector(".seed-finder-progress-count"),progBar=panel.querySelector(".seed-finder-bar"),resultsEl=panel.querySelector(".seed-finder-results"),autoBtn=panel.querySelector(".seed-finder-autopick-btn"),autoStatus=panel.querySelector(".seed-finder-autopick-status");textEl.value=_seedFinderDefaultText(voiceId);const pinInput=panel.querySelector(".seed-finder-pin-input"),pinBtn=panel.querySelector(".seed-finder-pin-btn"),pinClear=panel.querySelector(".seed-finder-pin-clear"),pinStatus=panel.querySelector(".seed-finder-pin-status"),voiceObj=(window._voices||[]).find(v=>v.id===voiceId)||{};voiceObj.seed!==void 0&&voiceObj.seed!==null&&(pinInput.value=voiceObj.seed,pinStatus.textContent=`\u2713 Pinned seed ${voiceObj.seed}`),voiceObj.has_ref||(backendEl.value="voice_design");let _cancelled=!1,_currentAudio=null;async function _loadBatchSamples(){try{const resp=await fetch(`/api/seed-samples/${encodeURIComponent(voiceId)}`);if(!resp.ok)return;const{seeds}=await resp.json();if(!seeds||seeds.length===0)return;resultsEl.innerHTML="";const header=document.createElement("div");header.className="sfr-batch-header",header.textContent=`${seeds.length} pre-generated sample${seeds.length!==1?"s":""} from batch run \u2014 click Play to listen`,resultsEl.appendChild(header);for(const seed of seeds){const row=document.createElement("div");row.className="seed-finder-result-row sfr-batch",row.dataset.seed=seed,row.innerHTML=` Seed ${seed} pre-generated - `;let _cachedBlob=null;row.querySelector(".sfr-play-btn").addEventListener("click",async()=>{if(!_cachedBlob){const r=await fetch(`/api/seed-sample/${encodeURIComponent(voiceId)}/${seed}`);if(!r.ok){typeof toast=="function"&&toast("Failed to load sample","error");return}_cachedBlob=await r.blob()}_playBlob(_cachedBlob)}),row.querySelector(".sfr-use-btn").addEventListener("click",async()=>{try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}),resultsEl.appendChild(row)}}catch{}}async function _saveSeed(seed){const resp=await fetch("/api/tts-voice-seed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice:voiceId,seed})});if(!resp.ok){const e=await resp.json().catch(()=>({}));throw new Error(e.detail||resp.statusText)}const v=(window._voices||[]).find(vv=>vv.id===voiceId);v&&(v.seed=seed);const pinnedSeedEl=document.querySelector(".insp-pinned-seed");pinnedSeedEl&&(seed!=null?(pinnedSeedEl.textContent=`Pin Seed # ${seed}`,pinnedSeedEl.style.display=""):(pinnedSeedEl.style.display="none",pinnedSeedEl.textContent=""))}async function _fetchWithRetry(fn,tries=2,delay=1200){let lastErr;for(let t=0;t<=tries;t++){if(_cancelled)throw new Error("cancelled");try{return await fn()}catch(e){lastErr=e,tsetTimeout(r,delay))}}throw lastErr}function _playBlob(blob){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);const url=URL.createObjectURL(blob),audio=new Audio(url);_currentAudio=audio,audio.play().catch(()=>{}),audio.addEventListener("ended",()=>{URL.revokeObjectURL(url),_currentAudio=null})}const seedRows=new Map,_keyFor=(seed,text,backend)=>`${SF_CACHE_VERSION}|${voiceId}|${backend}|${_sfHash(text)}|${seed}`;async function _useSeed(seed,row){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}function _seedMetricText(data){const parts=[];return data.dur&&parts.push(`audio ${data.dur.toFixed(1)}s${data.clipped?" clipped":""}`),data.genSec&&parts.push(`gen ${data.genSec.toFixed(1)}s`),data.rtf&&parts.push(`${data.rtf.toFixed(2)}x`),data.cached&&parts.push("saved"),parts.join(" \xB7 ")}function _renderSeed(seed,data){const row=document.createElement("div");if(row.className="seed-finder-result-row"+(data.err?" seed-finder-result-error":""),row.dataset.seed=seed,data.err)row.innerHTML=`Seed ${seed} + `;let _cachedBlob=null;row.querySelector(".sfr-play-btn").addEventListener("click",async()=>{if(!_cachedBlob){const r=await fetch(`/api/seed-sample/${encodeURIComponent(voiceId)}/${seed}`);if(!r.ok){typeof toast=="function"&&toast("Failed to load sample","error");return}_cachedBlob=await r.blob()}_playBlob(_cachedBlob)}),row.querySelector(".sfr-use-btn").addEventListener("click",async()=>{try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}),resultsEl.appendChild(row)}}catch{}}async function _saveSeed(seed){const resp=await fetch("/api/tts-voice-seed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice:voiceId,seed})});if(!resp.ok){const e=await resp.json().catch(()=>({}));throw new Error(e.detail||resp.statusText)}const v=(window._voices||[]).find(vv=>vv.id===voiceId);v&&(v.seed=seed);const pinnedSeedEl=document.querySelector(".insp-pinned-seed");pinnedSeedEl&&(seed!=null?(pinnedSeedEl.textContent=`Pin Seed # ${seed}`,pinnedSeedEl.style.display=""):(pinnedSeedEl.style.display="none",pinnedSeedEl.textContent=""))}async function _fetchWithRetry(fn,tries=2,delay=1200){let lastErr;for(let t=0;t<=tries;t++){if(_cancelled)throw new Error("cancelled");try{return await fn()}catch(e){lastErr=e,tsetTimeout(r,delay))}}throw lastErr}function _playBlob(blob){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);const url=URL.createObjectURL(blob),audio=new Audio(url);_currentAudio=audio,audio.play().catch(()=>{}),audio.addEventListener("ended",()=>{URL.revokeObjectURL(url),_currentAudio=null})}const seedRows=new Map,_keyFor=(seed,text,backend)=>`${SF_CACHE_VERSION}|${voiceId}|${backend}|${_sfHash(text)}|${seed}`;async function _useSeed(seed,row){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}function _seedMetricText(data){const parts=[];return data.dur&&parts.push(`audio ${data.dur.toFixed(1)}s${data.clipped?" clipped":""}`),data.genSec&&parts.push(`gen ${data.genSec.toFixed(1)}s`),data.rtf&&parts.push(`${data.rtf.toFixed(2)}x`),data.cached&&parts.push("saved"),data.note&&parts.push(data.note),parts.join(" \xB7 ")}function _renderSeed(seed,data){const row=document.createElement("div");if(row.className="seed-finder-result-row"+(data.err?" seed-finder-result-error":""),row.dataset.seed=seed,data.err)row.innerHTML=`Seed ${seed} Failed: ${escHtml(String(data.err))} `,row.querySelector(".sfr-retry-btn").addEventListener("click",()=>_retrySeed(seed));else{const durStr=_seedMetricText(data);row.innerHTML=`Seed ${seed} ${escHtml(durStr)} - `,row.querySelector(".sfr-play-btn").addEventListener("click",()=>_playBlob(data.blob)),row.querySelector(".sfr-use-btn").addEventListener("click",()=>_useSeed(seed,row))}const existing=seedRows.get(seed);existing?existing.replaceWith(row):resultsEl.appendChild(row),seedRows.set(seed,row)}async function _fetchSeedSample(seed,text,backend){const body2={text,voice:voiceId,response_format:"wav",instruct:"",backend,seed,seed_finder:!0},t0=performance.now(),r=await fetch("/api/tts-preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body2)}),genSec=Math.max(0,(performance.now()-t0)/1e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const blob=await r.blob(),headerDur=parseFloat(r.headers.get("X-TTS-Audio-Duration")||""),dur=Number.isFinite(headerDur)&&headerDur>0?headerDur:Math.max(0,(blob.size-44)/(24e3*2)),clipped=String(r.headers.get("X-TTS-Audio-Clipped")||"").toLowerCase()==="true",rtf=dur>0?genSec/dur:0;return{blob,dur,genSec,rtf,clipped}}async function _generateSeed(seed,text,backend,force){const key=_keyFor(seed,text,backend);if(!force){const c=await _sfDbGet(key);if(c&&c.blob)return{blob:c.blob,dur:c.dur||0,genSec:c.genSec||0,rtf:c.rtf||0,clipped:!!c.clipped,cached:!0}}const data=await _fetchWithRetry(()=>_fetchSeedSample(seed,text,backend));return _sfDbPut({key,cacheVersion:SF_CACHE_VERSION,voiceId,backend,textHash:_sfHash(text),seed,...data,ts:Date.now()}),{...data,cached:!1}}async function _retrySeed(seed){const text=textEl.value.trim(),backend=backendEl.value,row=seedRows.get(seed),err=row&&row.querySelector(".sfr-error");err&&(err.textContent="Retrying\u2026");try{_renderSeed(seed,await _generateSeed(seed,text,backend,!0))}catch(e){_renderSeed(seed,{err:e.message||String(e)})}}async function _loadCached(){const text=textEl.value.trim(),backend=backendEl.value,th=_sfHash(text),all=(await _sfDbAllForVoice(voiceId)).filter(r=>r.cacheVersion===SF_CACHE_VERSION&&r.backend===backend&&r.textHash===th&&r.blob);if(!all.length){clearBtn.style.display="none";return}all.sort((a,b)=>a.seed-b.seed),resultsEl.innerHTML="",seedRows.clear(),all.forEach(r=>_renderSeed(r.seed,{blob:r.blob,dur:r.dur||0,genSec:r.genSec||0,rtf:r.rtf||0,clipped:!!r.clipped,cached:!0})),clearBtn.style.display="",statusEl.textContent=`${all.length} saved sample${all.length!==1?"s":""} loaded \u2014 Run to fill the rest.`}async function _runFinder(){const text=textEl.value.trim();if(!text){statusEl.textContent="Enter a test sentence.";return}const seedFrom=Math.max(0,parseInt(fromEl.value,10)||1),seedTo=Math.max(seedFrom,parseInt(toEl.value,10)||15),backend=backendEl.value,seeds=[];for(let s=seedFrom;s<=seedTo;s++)seeds.push(s);const total=seeds.length;_cancelled=!1,resultsEl.innerHTML="",seedRows.clear(),progressEl.style.display="",runBtn.style.display="none",cancelBtn.style.display="",clearBtn.style.display="none",statusEl.textContent="",progBar.style.width="0%",progCount.textContent=`0 / ${total}`,progLabel.textContent="Generating\u2026";let i=0,cached=0;for(const seed of seeds){if(_cancelled)break;progLabel.textContent=`Seed ${seed}\u2026`,progCount.textContent=`${i} / ${total}`,progBar.style.width=`${Math.round(i/total*100)}%`;try{const d=await _generateSeed(seed,text,backend,!1);d.cached&&cached++,_renderSeed(seed,d)}catch(e){if(_cancelled)break;_renderSeed(seed,{err:e.message||String(e)})}i++}progBar.style.width="100%",progCount.textContent=`${i} / ${total}`,progLabel.textContent=_cancelled?"Cancelled.":`Done \u2014 listen and pick your seed.${cached?` (${cached} from cache)`:""}`,runBtn.style.display="",cancelBtn.style.display="none",seedRows.size&&(clearBtn.style.display="")}runBtn.addEventListener("click",_runFinder),cancelBtn.addEventListener("click",()=>{_cancelled=!0}),clearBtn.addEventListener("click",async()=>{confirm("Delete the cached seed samples for this voice?")&&(await _sfDbClearVoice(voiceId),resultsEl.innerHTML="",seedRows.clear(),clearBtn.style.display="none",statusEl.textContent="Saved samples cleared.")}),_loadCached(),pinBtn.addEventListener("click",async()=>{const seed=parseInt(pinInput.value,10);if(isNaN(seed)||seed<0){pinStatus.textContent="Enter a seed number.";return}pinBtn.disabled=!0,pinStatus.textContent="Saving\u2026";try{await _saveSeed(seed),pinStatus.textContent=`\u2713 Pinned seed ${seed}`,typeof toast=="function"&&toast(`Voice ${voiceId} fixed to seed ${seed}`,"success")}catch(e){pinStatus.textContent="",typeof toast=="function"&&toast("Pin failed: "+e.message,"error")}finally{pinBtn.disabled=!1}}),pinClear.addEventListener("click",async()=>{pinClear.disabled=!0,pinStatus.textContent="Clearing\u2026";try{await _saveSeed(null),pinInput.value="",pinStatus.textContent="\u2713 Unpinned (random seed)",resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),typeof toast=="function"&&toast(`Voice ${voiceId} unpinned \u2014 random seed`,"success")}catch(e){pinStatus.textContent="",typeof toast=="function"&&toast("Unpin failed: "+e.message,"error")}finally{pinClear.disabled=!1}})}function seedFinderBatchAll(){var _a2;const _sfUseSelected=typeof _bulkSelected!="undefined"&&_bulkSelected.size>0,ids=_sfUseSelected?[..._bulkSelected]:typeof activeVoiceIds=="function"?activeVoiceIds():(window._voices||[]).filter(v=>v.enabled!==!1).map(v=>v.id);if(!ids.length){typeof toast=="function"&&toast("No voices selected","error");return}(_a2=document.getElementById("seedbatch-overlay"))==null||_a2.remove();const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="seedbatch-overlay",ov.innerHTML=`
+ `,row.querySelector(".sfr-play-btn").addEventListener("click",()=>_playBlob(data.blob)),row.querySelector(".sfr-use-btn").addEventListener("click",()=>_useSeed(seed,row))}const existing=seedRows.get(seed);existing?existing.replaceWith(row):resultsEl.appendChild(row),seedRows.set(seed,row)}async function _fetchSeedSample(seed,text,backend){const body2={text,voice:voiceId,response_format:"wav",instruct:"",backend,seed,seed_finder:!0},t0=performance.now(),r=await fetch("/api/tts-preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body2)}),genSec=Math.max(0,(performance.now()-t0)/1e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const blob=await r.blob(),headerDur=parseFloat(r.headers.get("X-TTS-Audio-Duration")||""),dur=Number.isFinite(headerDur)&&headerDur>0?headerDur:Math.max(0,(blob.size-44)/(24e3*2)),clipped=String(r.headers.get("X-TTS-Audio-Clipped")||"").toLowerCase()==="true",rtf=dur>0?genSec/dur:0;return{blob,dur,genSec,rtf,clipped}}async function _generateSeed(seed,text,backend,force){const key=_keyFor(seed,text,backend);if(!force){const c=await _sfDbGet(key);if(c&&c.blob)return{blob:c.blob,dur:c.dur||0,genSec:c.genSec||0,rtf:c.rtf||0,clipped:!!c.clipped,cached:!0}}const data=await _fetchWithRetry(()=>_fetchSeedSample(seed,text,backend));return _sfDbPut({key,cacheVersion:SF_CACHE_VERSION,voiceId,backend,textHash:_sfHash(text),seed,...data,ts:Date.now()}),{...data,cached:!1}}async function _retrySeed(seed){const text=textEl.value.trim(),backend=backendEl.value,row=seedRows.get(seed),err=row&&row.querySelector(".sfr-error");err&&(err.textContent="Retrying\u2026");try{_renderSeed(seed,await _generateSeed(seed,text,backend,!0))}catch(e){_renderSeed(seed,{err:e.message||String(e)})}}async function _loadCached(){const text=textEl.value.trim(),backend=backendEl.value,th=_sfHash(text),all=(await _sfDbAllForVoice(voiceId)).filter(r=>r.cacheVersion===SF_CACHE_VERSION&&r.backend===backend&&r.textHash===th&&r.blob);if(!all.length){clearBtn.style.display="none";return}all.sort((a,b)=>a.seed-b.seed),resultsEl.innerHTML="",seedRows.clear(),all.forEach(r=>_renderSeed(r.seed,{blob:r.blob,dur:r.dur||0,genSec:r.genSec||0,rtf:r.rtf||0,clipped:!!r.clipped,cached:!0})),clearBtn.style.display="",statusEl.textContent=`${all.length} saved sample${all.length!==1?"s":""} loaded \u2014 Run to fill the rest.`}async function _runFinder(){const text=textEl.value.trim();if(!text){statusEl.textContent="Enter a test sentence.";return}const seedFrom=Math.max(0,parseInt(fromEl.value,10)||1),seedTo=Math.max(seedFrom,parseInt(toEl.value,10)||15),backend=backendEl.value,seeds=[];for(let s=seedFrom;s<=seedTo;s++)seeds.push(s);const total=seeds.length;_cancelled=!1,resultsEl.innerHTML="",seedRows.clear(),progressEl.style.display="",runBtn.style.display="none",cancelBtn.style.display="",clearBtn.style.display="none",statusEl.textContent="",progBar.style.width="0%",progCount.textContent=`0 / ${total}`,progLabel.textContent="Generating\u2026";let i=0,cached=0;for(const seed of seeds){if(_cancelled)break;progLabel.textContent=`Seed ${seed}\u2026`,progCount.textContent=`${i} / ${total}`,progBar.style.width=`${Math.round(i/total*100)}%`;try{const d=await _generateSeed(seed,text,backend,!1);d.cached&&cached++,_renderSeed(seed,d)}catch(e){if(_cancelled)break;_renderSeed(seed,{err:e.message||String(e)})}i++}progBar.style.width="100%",progCount.textContent=`${i} / ${total}`,progLabel.textContent=_cancelled?"Cancelled.":`Done \u2014 listen and pick your seed.${cached?` (${cached} from cache)`:""}`,runBtn.style.display="",cancelBtn.style.display="none",seedRows.size&&(clearBtn.style.display="")}let _autoAbort=null;function _b64Blob(b64,type){const bin=atob(b64),u8=new Uint8Array(bin.length);for(let i=0;i{_cancelled=!0,_autoAbort&&_autoAbort.abort()},resultsEl.innerHTML="",seedRows.clear(),autoStatus.textContent=`${_sfT("Scoring")} ${seeds.length} ${_sfT("candidates\u2026 this can take a few minutes.")}`;try{const r=await fetch("/api/seed-autopick",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice:voiceId,text,seeds}),signal:_autoAbort.signal});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const res=await r.json();for(const c of res.candidates){if(c.error){_renderSeed(c.seed,{err:c.error});continue}const acc=c.accuracy===null?"":`${_sfT("words")} ${Math.round(c.accuracy)}% \xB7 `,probs=c.problems&&c.problems.length?` \xB7 ${c.problems.length} ${_sfT("audio problems")}`:"";_renderSeed(c.seed,{blob:_b64Blob(c.audio_b64,c.media_type),dur:0,note:`${_sfT("score")} ${c.score}/100 (${acc}${_sfT("level")} ${Math.round(c.loudness_score)}${probs})`})}const wRow=res.winner!==null?seedRows.get(res.winner):null;wRow&&(wRow.classList.add("sfr-winner"),wRow.style.outline="2px solid var(--accent, #4caf50)",wRow.querySelector(".sfr-seed").insertAdjacentHTML("afterend",`\u2605 ${_sfT("Best")}`),wRow.querySelector(".sfr-play-btn").focus());let msg=res.winner!==null?`${_sfT("Best seed")}: ${res.winner} (${_sfT("score")} ${res.candidates.find(c=>c.seed===res.winner).score}/100). ${_sfT("Listen, then click Use seed to save.")}`:_sfT("No candidate could be generated.");res.degraded&&(msg+=` ${_sfT("Speech-to-text unavailable: scored by level and audio problems only.")}`),res.cancelled&&(msg+=` ${_sfT("Cancelled.")}`),autoStatus.textContent=msg,seedRows.size&&(clearBtn.style.display="")}catch(e){autoStatus.textContent=e.name==="AbortError"?_sfT("Cancelled."):`${_sfT("Auto-pick failed")}: ${e.message}`}finally{autoBtn.disabled=runBtn.disabled=!1,cancelBtn.style.display="none",_autoAbort=null,cancelBtn.onclick=null}}autoBtn.addEventListener("click",_runAutoPick),runBtn.addEventListener("click",_runFinder),cancelBtn.addEventListener("click",()=>{_cancelled=!0}),clearBtn.addEventListener("click",async()=>{confirm("Delete the cached seed samples for this voice?")&&(await _sfDbClearVoice(voiceId),resultsEl.innerHTML="",seedRows.clear(),clearBtn.style.display="none",statusEl.textContent="Saved samples cleared.")}),_loadCached(),pinBtn.addEventListener("click",async()=>{const seed=parseInt(pinInput.value,10);if(isNaN(seed)||seed<0){pinStatus.textContent="Enter a seed number.";return}pinBtn.disabled=!0,pinStatus.textContent="Saving\u2026";try{await _saveSeed(seed),pinStatus.textContent=`\u2713 Pinned seed ${seed}`,typeof toast=="function"&&toast(`Voice ${voiceId} fixed to seed ${seed}`,"success")}catch(e){pinStatus.textContent="",typeof toast=="function"&&toast("Pin failed: "+e.message,"error")}finally{pinBtn.disabled=!1}}),pinClear.addEventListener("click",async()=>{pinClear.disabled=!0,pinStatus.textContent="Clearing\u2026";try{await _saveSeed(null),pinInput.value="",pinStatus.textContent="\u2713 Unpinned (random seed)",resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),typeof toast=="function"&&toast(`Voice ${voiceId} unpinned \u2014 random seed`,"success")}catch(e){pinStatus.textContent="",typeof toast=="function"&&toast("Unpin failed: "+e.message,"error")}finally{pinClear.disabled=!1}})}function seedFinderBatchAll(){var _a2;const _sfUseSelected=typeof _bulkSelected!="undefined"&&_bulkSelected.size>0,ids=_sfUseSelected?[..._bulkSelected]:typeof activeVoiceIds=="function"?activeVoiceIds():(window._voices||[]).filter(v=>v.enabled!==!1).map(v=>v.id);if(!ids.length){typeof toast=="function"&&toast("No voices selected","error");return}(_a2=document.getElementById("seedbatch-overlay"))==null||_a2.remove();const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="seedbatch-overlay",ov.innerHTML=`
Batch seed generation
Generate & cache Seed Finder samples for ${ids.length} ${_sfUseSelected?"selected":"active"} voice${ids.length!==1?"s":""}. Already-cached seeds are skipped, so it's resumable; you can cancel anytime.
@@ -491,6 +514,7 @@ This warms each voice so the engine caches its .pt and first playback is instant ${benchText!=="-"?` ${escHtml(benchText.split(" ")[0])}`:""}
${starsHtml}
${isClone?"Clone":"Design"} + ${window.provenanceBadge?window.provenanceBadge(v):""}
diff --git a/static/js/i18n.js b/static/js/i18n.js index c603928..436f27d 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -21,6 +21,22 @@ const I18N_LANGS = { // English for languages that don't have it yet (see window.t / applyI18n). const I18N_DICT = { de: { + // Seed Finder auto-pick + 'Generates up to 8 candidates, scores them and highlights the best. Nothing is saved until you click Use seed.': 'Erzeugt bis zu 8 Kandidaten, bewertet sie und hebt den besten hervor. Gespeichert wird erst nach Klick auf „Seed verwenden“.', + 'Auto-pick best': 'Besten automatisch wählen', + 'Enter a test sentence.': 'Testsatz eingeben.', + 'Scoring': 'Bewerte', + 'candidates… this can take a few minutes.': 'Kandidaten … das kann einige Minuten dauern.', + 'words': 'Wörter', + 'audio problems': 'Audioprobleme', + 'score': 'Wertung', + 'level': 'Pegel', + 'Best': 'Bester', + 'Best seed': 'Bester Seed', + 'Listen, then click Use seed to save.': 'Anhören, dann „Seed verwenden“ zum Speichern.', + 'No candidate could be generated.': 'Es konnte kein Kandidat erzeugt werden.', + 'Speech-to-text unavailable: scored by level and audio problems only.': 'Sprache-zu-Text nicht verfügbar: Wertung nur nach Pegel und Audioproblemen.', + 'Auto-pick failed': 'Automatische Wahl fehlgeschlagen', // Brand / sidebar groups 'Voice Creator': 'Voice Creator', 'Clone · Design · Deploy': 'Klonen · Entwerfen · Bereitstellen', diff --git a/static/js/seed-finder.js b/static/js/seed-finder.js index 0916ca1..379daf9 100644 --- a/static/js/seed-finder.js +++ b/static/js/seed-finder.js @@ -21,6 +21,8 @@ const SEED_FINDER_TEXT_DE = 'Die dreitausendfünfhundertsiebenundsechzig neuen G const SEED_FINDER_TEXT_EN = 'The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.'; const SEED_FINDER_TEXT_TORTURE = 'Die 3.567 neuen High-End Geräte für das Server-Update benötigen eine außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance bei großer Last zu gewährleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Schöne Grüße! — Madonna - Träume beginnt mit einem positiven Mindset.'; +const _sfT = s => (typeof window.t === 'function' ? window.t(s) : s); + function _seedFinderDefaultText(voiceId) { if (window._appSettings && window._appSettings.seed_finder_text) { return window._appSettings.seed_finder_text; @@ -114,10 +116,12 @@ function attachSeedFinder(voiceId, body) {
+
+
@@ -168,6 +172,8 @@ function attachSeedFinder(voiceId, body) { const progCount = panel.querySelector('.seed-finder-progress-count'); const progBar = panel.querySelector('.seed-finder-bar'); const resultsEl = panel.querySelector('.seed-finder-results'); + const autoBtn = panel.querySelector('.seed-finder-autopick-btn'); + const autoStatus = panel.querySelector('.seed-finder-autopick-status'); textEl.value = _seedFinderDefaultText(voiceId); @@ -313,6 +319,7 @@ function attachSeedFinder(voiceId, body) { if (data.genSec) parts.push(`gen ${data.genSec.toFixed(1)}s`); if (data.rtf) parts.push(`${data.rtf.toFixed(2)}x`); if (data.cached) parts.push('saved'); + if (data.note) parts.push(data.note); return parts.join(' · '); } @@ -431,6 +438,62 @@ function attachSeedFinder(voiceId, body) { if (seedRows.size) clearBtn.style.display = ''; } + + // ── Auto-pick best: server generates + scores N candidates; the user confirms ── + let _autoAbort = null; + function _b64Blob(b64, type) { + const bin = atob(b64), u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return new Blob([u8], { type: type || 'audio/wav' }); + } + async function _runAutoPick() { + const text = textEl.value.trim(); + if (!text) { autoStatus.textContent = _sfT('Enter a test sentence.'); return; } + const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1); + const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 8); + const seeds = []; + for (let s = seedFrom; s <= Math.min(seedTo, seedFrom + 15); s++) seeds.push(s); + _autoAbort = new AbortController(); + autoBtn.disabled = runBtn.disabled = true; + cancelBtn.style.display = ''; + cancelBtn.onclick = () => { _cancelled = true; if (_autoAbort) _autoAbort.abort(); }; + resultsEl.innerHTML = ''; seedRows.clear(); + autoStatus.textContent = `${_sfT('Scoring')} ${seeds.length} ${_sfT('candidates… this can take a few minutes.')}`; + try { + const r = await fetch('/api/seed-autopick', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voice: voiceId, text, seeds }), signal: _autoAbort.signal, + }); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const res = await r.json(); + for (const c of res.candidates) { + if (c.error) { _renderSeed(c.seed, { err: c.error }); continue; } + const acc = c.accuracy === null ? '' : `${_sfT('words')} ${Math.round(c.accuracy)}% · `; + const probs = c.problems && c.problems.length ? ` · ${c.problems.length} ${_sfT('audio problems')}` : ''; + _renderSeed(c.seed, { blob: _b64Blob(c.audio_b64, c.media_type), dur: 0, note: `${_sfT('score')} ${c.score}/100 (${acc}${_sfT('level')} ${Math.round(c.loudness_score)}${probs})` }); + } + const wRow = res.winner !== null ? seedRows.get(res.winner) : null; + if (wRow) { + wRow.classList.add('sfr-winner'); + wRow.style.outline = '2px solid var(--accent, #4caf50)'; + wRow.querySelector('.sfr-seed').insertAdjacentHTML('afterend', `★ ${_sfT('Best')}`); + wRow.querySelector('.sfr-play-btn').focus(); + } + let msg = res.winner !== null + ? `${_sfT('Best seed')}: ${res.winner} (${_sfT('score')} ${res.candidates.find(c => c.seed === res.winner).score}/100). ${_sfT('Listen, then click Use seed to save.')}` + : _sfT('No candidate could be generated.'); + if (res.degraded) msg += ` ${_sfT('Speech-to-text unavailable: scored by level and audio problems only.')}`; + if (res.cancelled) msg += ` ${_sfT('Cancelled.')}`; + autoStatus.textContent = msg; + if (seedRows.size) clearBtn.style.display = ''; + } catch (e) { + autoStatus.textContent = e.name === 'AbortError' ? _sfT('Cancelled.') : `${_sfT('Auto-pick failed')}: ${e.message}`; + } finally { + autoBtn.disabled = runBtn.disabled = false; cancelBtn.style.display = 'none'; _autoAbort = null; cancelBtn.onclick = null; + } + } + autoBtn.addEventListener('click', _runAutoPick); + runBtn.addEventListener('click', _runFinder); cancelBtn.addEventListener('click', () => { _cancelled = true; }); clearBtn.addEventListener('click', async () => { diff --git a/tests/seed-autopick.spec.js b/tests/seed-autopick.spec.js new file mode 100644 index 0000000..604fd86 --- /dev/null +++ b/tests/seed-autopick.spec.js @@ -0,0 +1,39 @@ +// @ts-check +// Seed Finder "Auto-pick best": all /api routes mocked, nothing is ever saved or generated for real. +const { test, expect } = require('@playwright/test'); + +const WAV = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA='; // empty 16-bit wav + +test('auto-pick highlights the winner; saving needs an explicit click', async ({ page }) => { + const saves = []; + const cand = (seed, score) => ({ seed, score, accuracy: 90, loudness_score: 80, problems_score: 100, problems: [], audio_b64: WAV, media_type: 'audio/wav' }); + await page.route('**/api/**', route => route.request().method() === 'GET' + ? route.fulfill({ json: [] }) : route.fulfill({ json: { ok: true } })); + await page.route('**/api/seed-autopick', route => route.fulfill({ json: { + winner: 2, degraded: true, cancelled: false, stt: { available: false, note: 'down' }, + candidates: [cand(1, 60), cand(2, 88), { seed: 3, error: 'boom' }] } })); + await page.route('**/api/tts-voice-seed', route => { saves.push(route.request().postData()); route.fulfill({ json: { ok: true } }); }); + await page.route('**/static/dist/main.min.js*', r => r.abort()); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.attachSeedFinder === 'function', null, { timeout: 15_000 }); + await page.evaluate(() => { + const host = document.createElement('div'); host.id = 'sf-host'; document.body.prepend(host); + window.attachSeedFinder('DE_test', host); + host.querySelector('.seed-finder-group').classList.add('open'); + }); + const btn = page.getByRole('button', { name: /Auto-pick best/ }); + await btn.focus(); + await page.keyboard.press('Enter'); + const status = page.locator('.seed-finder-autopick-status'); + await expect(status).toHaveAttribute('aria-live', 'polite'); + await expect(status).toContainText('Best seed: 2'); + await expect(status).toContainText('level and audio problems only'); + await expect(page.locator('.sfr-winner')).toContainText('Best'); + await expect(page.locator('.sfr-winner')).toContainText('score 88/100'); + await expect(page.locator('.seed-finder-result-error')).toContainText('boom'); + expect(saves).toHaveLength(0); + await page.locator('.sfr-winner .sfr-use-btn').click(); + await expect(page.locator('.sfr-winner .sfr-saved')).toContainText('Saved'); + expect(saves).toHaveLength(1); + expect(JSON.parse(saves[0])).toEqual({ voice: 'DE_test', seed: 2 }); +}); diff --git a/tests/test_seed_autopick.py b/tests/test_seed_autopick.py new file mode 100644 index 0000000..94d72fd --- /dev/null +++ b/tests/test_seed_autopick.py @@ -0,0 +1,111 @@ +"""Seed auto-pick: scoring, selection and the endpoint with all I/O mocked.""" +import asyncio +import os +import tempfile +import io +import sys +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-autopick-test-")) + +from fastapi import HTTPException +from pydub.generators import Sine + +from core import seed_score as sc +import routes.tts as tts + + +def wav(gain=-20, gap=False): + seg = Sine(300, sample_rate=24000).to_audio_segment(1500).apply_gain(gain) + if gap: + seg = seg + seg.__class__.silent(2000, 24000) + seg + buf = io.BytesIO() + seg.export(buf, format="wav") + return buf.getvalue() + + +class FakeReq: + def __init__(self, body, disconnect_after=None): + self.body, self.calls, self.after = body, 0, disconnect_after + + async def json(self): + return self.body + + async def is_disconnected(self): + self.calls += 1 + return self.after is not None and self.calls > self.after + + +def run(req, gen, stt): + with mock.patch.object(tts, "_seed_finder_request_audio", side_effect=gen), \ + mock.patch.object(tts, "_load_settings", return_value={}), \ + mock.patch("routes.stt._transcribe_audio", side_effect=stt): + return asyncio.run(tts.seed_autopick(req)) + + +class ScoringTests(unittest.TestCase): + def test_combine_and_degraded(self): + self.assertEqual(sc.combine(100, 100, 100), 100.0) + self.assertEqual(sc.combine(None, 100, 50), 70.0) # (0.2*100+0.3*50)/0.5 + self.assertLess(sc.loudness_score({"dbfs": -5, "peak_dbfs": 0, "target_dbfs": -20}), 20) + self.assertEqual(sc.loudness_score({"dbfs": None}), 0.0) + self.assertEqual(sc.problems_score([{"kind": "silence"}] * 5), 0.0) + + def test_winner_tiebreak_and_errors(self): + c = [{"seed": 3, "score": 80}, {"seed": 2, "score": 80}, {"seed": 1, "error": "x"}] + self.assertEqual(sc.pick_winner(c), 2) + self.assertIsNone(sc.pick_winner([{"seed": 1, "error": "x"}])) + + def test_seed_list_bounds(self): + self.assertEqual(sc.seed_list(None, None), list(range(1, 9))) + self.assertEqual(sc.seed_list([5, 5, 7], None), [5, 7]) + for bad in ((None, 17), (None, 0), ([], None), ([-1], None), (list(range(17)), None), (["a"], None)): + with self.assertRaises(ValueError): + sc.seed_list(*bad) + + +class EndpointTests(unittest.TestCase): + body = {"voice": "DE_x", "text": "hallo welt", "seeds": [1, 2, 3]} + + def test_picks_best_and_never_saves(self): + audios = {1: wav(-20, gap=True), 2: wav(-20), 3: wav(-3)} + gen = lambda text, voice, settings, seed: (audios[seed], "audio/wav") + stt = lambda *a, **k: ("hallo welt", "x") + out = run(FakeReq(self.body), gen, stt) + self.assertEqual(out["winner"], 2) + self.assertFalse(out["degraded"]) + self.assertEqual([c["seed"] for c in out["candidates"]], [1, 2, 3]) + self.assertTrue(all("audio_b64" in c and "problems" in c for c in out["candidates"])) + self.assertGreater(out["candidates"][1]["score"], out["candidates"][0]["score"]) + + def test_stt_down_degrades(self): + def stt(*a, **k): + raise RuntimeError("no stt") + out = run(FakeReq(self.body), lambda *a: (wav(), "audio/wav"), stt) + self.assertTrue(out["degraded"]) + self.assertIn("no stt", out["stt"]["note"]) + self.assertIsNotNone(out["winner"]) + self.assertIsNone(out["candidates"][0]["accuracy"]) + + def test_error_candidate_and_cancel(self): + def gen(text, voice, settings, seed): + if seed == 1: + raise RuntimeError("boom") + return wav(), "audio/wav" + out = run(FakeReq(self.body, disconnect_after=2), gen, lambda *a, **k: ("hallo welt", "x")) + self.assertEqual(out["candidates"][0]["error"], "boom") + self.assertEqual(out["winner"], 2) + self.assertTrue(out["cancelled"]) + self.assertEqual(len(out["candidates"]), 2) + + def test_invalid_input(self): + for body in ({"voice": "", "text": "a"}, {"voice": "v", "text": " "}, {"voice": "v", "text": "a", "count": 99}): + with self.assertRaises(HTTPException): + run(FakeReq(body), lambda *a: (wav(), "audio/wav"), lambda *a, **k: ("", "")) + + +if __name__ == "__main__": + unittest.main()