diff --git a/CHANGELOG.md b/CHANGELOG.md
index bca66f1..7eb4371 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,99 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.18.10] — 2026-07-29
+
+### Fixed
+- **A voice's pinned seed (set via the Seed Finder / Batch Seeds tools) was saved but never actually used — for two independent reasons.** (1) The seed gets written to the voice's own metadata, but every normal generation call (Try It Out, Rehearsal, audiobook export) built its request without ever reading that value back — only the Seed Finder's own one-off benchmarking codepath looked at it. (2) Separately, the internal "voice_design_playback" stability profile (added in 1.18.4 specifically so ongoing dialogue wouldn't hardcode `seed: 0` over every per-voice pin) wasn't recognized by the backend-name normalizer, which silently coerced it back to "voice_clone" — reintroducing exactly the hardcoded `seed: 0` it was designed to avoid. Together these meant a designed voice could still sound noticeably different (including gender-adjacent timbre drift) from one generation to the next even after "pinning" a seed for it — the pin was completely inert. Confirmed live post-fix: a Voice Design request for a voice with a saved seed now actually carries that seed. Every voice_clone/voice_design/customvoice/streaming request falls back to the voice's own saved seed when the request doesn't already specify one explicitly.
+- **"Apply character persona" silently did nothing when the selected voice had no persona text saved** (persona is a field you type manually on the Voice Inspector page — it's never auto-filled from a character sheet) — indistinguishable from the feature being broken. The checkbox is now disabled with an explanatory tooltip when the voice has no persona, and the backend returns a clear error instead of a silent no-op if it's checked anyway.
+- **Chunked preview generation (auto-enabled for text over 200 characters) dropped "Apply character persona" entirely**, regardless of whether the checkbox was checked — it was hardcoded off for that code path only.
+
+## [1.18.9] — 2026-07-29
+
+### Fixed
+- **Highpass/lowpass filters (the new Telephone/Radio bandpass control from 1.18.7) were far too weak to be audible** — confirmed via spectral analysis: pedalboard's filters are single-pole (~6 dB/octave), so the residual harmonic distortion left over from compression (see below) still poked straight through a single pass, leaving the "outside the passband" energy roughly unchanged or even higher than the original. Each highpass/lowpass now cascades 3 internal stages (~18 dB/octave) — confirmed live: content below/above the cutoff is now reduced by 7-14 dB instead of being essentially untouched.
+
+## [1.18.8] — 2026-07-29
+
+### Fixed
+- **The compressor's makeup gain (added in 1.18.5) was clipping.** Confirmed live via spectral analysis: makeup gain routinely pushed peaks to ~1.9 (well past the ±1.0 digital ceiling), and the final hard clip in the effects pipeline sliced that overshoot off — producing broadband harmonic distortion that swamped the actual intended effect. This is why the Telephone/Radio presets still sounded like "no difference": the distortion, not the intended band-limiting, was dominating the output. Replaced the hard clip with a proper limiter after makeup gain, so peaks are caught smoothly instead of sliced.
+
+## [1.18.7] — 2026-07-29
+
+### Added
+- **Telephone/Radio effect presets now actually band-limit the audio** — a new "Bandpass (telephone/radio)" control (high-pass + low-pass cutoff sliders) was added to the Try It Out effects panel and wired into both presets. Compression alone (even correctly calibrated) doesn't sound like "a phone call" — the defining trait is narrow frequency range, which the presets never applied and the UI never exposed.
+
+## [1.18.6] — 2026-07-29
+
+### Fixed
+- **The last fix for "Try It Out" showing only generic Voice Design presets instead of your own voices made things worse — it went from "wrong voices" to "no voices found."** Voice Design's raw discovery endpoint only ever lists its own bundled presets, never any custom voice — so filtering that list against the user's own library (the same technique that correctly works for Voice Clone/Streaming) always produced an empty intersection. Fixed properly: for Voice Design, the dropdown is populated directly from the user's own active voice library instead of trying to filter the engine's useless discovery list at all.
+
+## [1.18.5] — 2026-07-29
+
+### Fixed
+- **Browser Back/Forward didn't work inside the app at all** — every section change called `history.replaceState`, which overwrites the SAME single history entry instead of adding a new one, so the browser had nothing from the app's own navigation to step through. Clicking Back skipped straight past the whole app to whatever page was open before it. Switched to `pushState`; the existing hashchange listener already handled Back/Forward correctly, it just never had real history entries to respond to.
+- **"Try It Out"'s Voice Design dropdown only ever showed the engine's own built-in presets** (vd_british_male, vd_german_male, ...) — never any of your own designed voices, even after designing 70+ characters for a book. Now filtered to your own voice library, same as Voice Clone/Streaming already were.
+- **Audio effects had no audible impact** — root-caused live: every effects preset's compressor threshold sat ABOVE where our own TTS output actually lives (all voices are normalized to ~-20dBFS; presets ranged from -10dB to -18dB), so a compressor threshold-gated to louder signal had almost nothing to act on — confirmed the "Telephone" preset changed a real speech clip's loudness by under 0.1%. Lowered every preset's threshold to actually engage with real output, and added makeup gain after compression (a plain compressor with no makeup gain only ever shaves peaks quieter — it never produces the louder, "punchier" sound people associate with compression, which is why even a correctly-engaging compressor was hard to notice).
+
+## [1.18.4] — 2026-07-29
+
+### Fixed
+- **Several characters were split across multiple speaker labels with inconsistent — sometimes wrongly-gendered — voices**: the underlying book text refers to the same person by different names/titles/epithets at different points (a vampire lord called "Roger," "Zerwas," and others; a character nicknamed "Irre" who is really "Uriens"; a guard referred to once as "Wächter" and once as "Wächter des Turmes"), and each distinct label had gotten its own independent cast entry and voice. Confirmed live: merged 8 such groups onto one consistent voice each. Four of them ("Inquisitor," "Jägerin," "Linosch," "Sharraz" — 61 lines total) had NO voice assigned at all, meaning that dialogue was being silently dropped from the audiobook entirely, not just mis-voiced.
+- **Emotion stopped coming through audibly right after the accent-reinforcement fix landed** — root cause: Qwen3-TTS's own prompting guidance warns it doesn't reliably follow multiple conflicting instructions in one prompt, favoring one over another. The accent clause was being placed FIRST in every line's instruct, making it the most prominent instruction on every single line and likely crowding out the (shorter) emotion tag that followed it. Reordered so emotion leads and the accent reminder trails, instead of the other way around.
+
+## [1.18.3] — 2026-07-28
+
+### Fixed
+- **The anti-American-accent instruction only ever applied to the one-time call that designs a new voice — never to any of the actual lines it goes on to read.** Root cause: the accent clause was built fresh at design time and layered on top of the character's saved voice-quality description, but never written back into that saved description — so every ONGOING line's instruct (built from the saved profile) carried zero accent guidance. Since each synthesis call is stateless, the model has no memory of the original design call's instructions; omitting the clause here meant real narration got none of it at all, only the initial creation did. The same accent clause now gets added to every line's instruct, not just the design call — Stage playback, Synth all, and the audiobook export all benefit automatically since they share the one `_buildInstruct` function.
+
+## [1.18.2] — 2026-07-28
+
+### Fixed
+- **Seed Finder's default test sentence for German (and every other) voices was itself a mid-sentence mix of German and English** — confirmed as the actual cause of "the seeds are horrible, that's a mixture of English and German": the `DE`, `EN`, and "mixed torture-test" constants were all literally the same string, including a full English sentence ("The system administrator successfully configured the customized Docker stacks...") baked into the "German" default. Replaced with genuinely single-language defaults per voice, and — better — the voice's own saved reference transcript (real book content) is now used first when available, since that's exactly what the voice will actually read.
+- **Designed-voice playback never told the engine what language to expect, unlike every other backend** — left at "Auto" (auto-detect) for every single line, which is least reliable on short dialogue lines. Now passes the voice's own language explicitly, same as Voice Clone/Streaming/CustomVoice already do.
+
+### Investigated
+- Tested whether an already-designed voice could be served through the Voice Clone engine instead (the officially-documented path for consistent multi-line reuse) — the clone engine doesn't recognize a Voice-Design-only voice_id at all (confirmed live, before and after a full backend restart), so this would require a proper conversion step this app doesn't yet implement. Not pursued further this round; noted for a future pass.
+
+## [1.18.1] — 2026-07-27
+
+### Fixed
+- **The previous loudness fix (normalizing every clip independently to one target level) had a real side effect: it erased a voice's own whisper-vs-shout dynamics along with fixing the cross-voice level gap.** Confirmed live: a whispered line came out LOUDER than its own neutral reading once both were pushed to the same target — exactly backwards. Replaced with a fixed per-voice gain offset (the voice's own already-computed reference gain from Calc dB, applied uniformly to every line from that voice) — this shifts each voice's baseline to match others without touching how loud one line is relative to another from the same voice.
+- Audited all 72 designed voices used in "Die Entdeckung" for the anti-American-accent instruction added earlier this session — 34 of them (nearly half) predated that fix and never got it. All 34 are being redesigned with the current prompt builder.
+
+## [1.18.0] — 2026-07-27
+
+### Fixed
+- **Emotion tags barely registered on designed voices** — root cause: the casting pass tags emotions in the book's own language (e.g. German "fordernd", "entschlossen"), but the instruct sentence wrapping it was hardcoded English ("Speak in a fordernd manner."), dropping a German word into an English carrier sentence — a much weaker signal than a natural sentence in one language. The template now matches the target voice's own language (inferred from its `DE_`/`EN_`/... id prefix), applied everywhere an instruct gets built: Stage playback, Synth all, re-synthesis, Train mode, and — the one that actually matters for a finished audiobook — the export path itself, which had drifted out of sync with the others and was still building English-only instructs.
+- **The Narrator sounded noticeably louder than designed-voice characters in a finished export** — nothing in the synthesis or merge pipeline ever loudness-matched clips from different backends against each other. Every freshly-synthesized clip is now normalized to the same target used for voice reference files; a new endpoint also normalizes an already-built cache in place (pure audio processing, no resynthesis) so an existing book doesn't need a multi-hour re-synth just to fix levels.
+
+### Added
+- Per-clip and whole-book-cache loudness normalization endpoints (`/api/audio/normalize-wav`, `/api/line-audio/{book}/normalize`).
+
+## [1.17.99] — 2026-07-27
+
+### Fixed
+- **Designed voices could occasionally speak nonsense or repeat text mid-line, unlike cloned voices** — root-caused by comparing our own request payloads against the actual Qwen3-TTS API: the app already applies a stability profile (temperature/top_p) to every Voice Clone request to keep it consistent, but Voice Design's stability profile was deliberately left empty — correct for the ONE-TIME call that designs a brand-new voice (you want fresh randomness there, or every character sounds the same), but that same empty profile was also being used for every ONGOING line of dialogue read by an already-designed voice, which needs the opposite: consistency, not randomness. Split into two separate profiles — voice creation stays unconstrained, but reading a line from an existing designed voice now gets the same stability treatment as Voice Clone. Verified live: the same line synthesized 5 times in a row went from inconsistent/occasionally-wrong to a perfect transcription match every time, with emotional instructions (tested angry vs. whisper vs. neutral) still working correctly.
+- All 72 designed voices in "Die Entdeckung" were pinned to a specific seed via an automated hunt (tries a few seeds per voice, keeps whichever one round-trip-verifies correctly) rather than left on a random seed per call — the other major source of the "sometimes fine, sometimes not" inconsistency.
+- Added a matching "Voice Design (reading a line)" params field in Settings, alongside the existing "Voice Design (creating a new voice)" one, so the two are no longer silently sharing one config slot.
+
+## [1.17.98] — 2026-07-26
+
+### Added
+- **Voice round-trip verification**: synthesizes a voice's own reference line (or a language-appropriate default), transcribes the result back with Whisper, and compares it word-for-word to the original text. A duration/wpm-only benchmark can't tell "read the line correctly" from "repeated it twice" or "said something unrelated" — both can produce a perfectly normal-looking duration and pass every prior check; this actually checks the words. New "Verify (STT)" button in the Voice Library toolbar runs it over the active/selected voices and lists exactly which ones failed and what they said instead.
+- The same round-trip check now runs automatically as part of every voice design/redesign attempt — a low-scoring attempt is rejected and retried just like a clipped or implausible-wpm one, so a bad design no longer needs to be caught by ear after the fact.
+
+## [1.17.97] — 2026-07-26
+
+### Fixed
+- **A designed voice (no reference WAV) failed outright — every time, no retry possible — whenever a bulk action picked the wrong backend for it.** Root cause found in three places at once: "Precompute" and "Batch seeds" both used one globally-selected backend for every voice in a batch, and the per-voice Seed Finder didn't offer Voice Design as an option at all — so a selection that was entirely designed voices (like this book's 24-character redesign) got "Precomputed 0 embedding(s), 72 skipped/failed" and a batch-seed run that failed every single job. Every synthesis path in the app (Stage playback, Synth all, Audiobook export, Precompute, Batch seeds, Seed Finder) now resolves the correct backend per voice automatically — a designed voice always routes to Voice Design regardless of what's globally selected; every other voice still respects it.
+- Added a "Voice Design" option to the per-voice Seed Finder's backend selector (it only offered Voice Clone/Streaming before), auto-selected when opening a designed voice.
+
+## [1.17.96] — 2026-07-26
+
+### Fixed
+- **A bulk synth/export job (Synth all, Audiobook) could lose lines outright to "Failed to fetch" with zero retry** — confirmed live: a ~2000-line export lost 346 lines this way. Same root cause as the earlier voice-design fix: a GPU-contended TTS backend container can be mid-restart for a handful of seconds, and a job hitting the TTS endpoint hundreds of times in a row will reliably catch that window at least once. The core `fetchTtsPreviewBlob` helper (used by every synthesis path in the app) now retries a couple of times with a short backoff on a raw connection failure before giving up — a real HTTP error response is still surfaced immediately, only a connection that never got a response at all is retried.
+
## [1.17.95] — 2026-07-26
### Fixed
diff --git a/VERSION b/VERSION
index c0e9b0b..0150af1 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.17.95
+1.18.10
diff --git a/core/config.py b/core/config.py
index 8721b44..12993db 100644
--- a/core/config.py
+++ b/core/config.py
@@ -75,6 +75,23 @@ _TTS_STABILITY_BY_BACKEND_DEFAULT = {
# own natural randomization per call, same as the other creative-voice
# backends below.
"voice_design": {},
+ # BUT distinct from the above: once a voice has already been designed
+ # and saved, every ONGOING line of dialogue that reuses it is a
+ # voice_clone-style read of a fixed identity, not a fresh design — it
+ # needs the SAME stability as voice_clone, not voice_design's
+ # deliberately-unconstrained randomness. Both used to share the single
+ # "voice_design" backend key, so every line of an audiobook synthesized
+ # with a designed voice ran with fully unconstrained temperature/top_p —
+ # confirmed live as the actual cause of designed voices (unlike cloned
+ # ones, which already got the stability block) occasionally repeating
+ # text or drifting into unrelated words mid-book.
+ # No "seed" key here, unlike the other stability defaults: the whole
+ # point of the per-voice Seed Finder / Batch Seeds tools is to pin a
+ # SPECIFIC seed per designed voice (stored server-side against that
+ # voice_id) — a hardcoded seed=0 in every request would silently
+ # override that per-voice choice on every single line. Temperature/top_p
+ # alone already gives real stability without fighting the per-voice pin.
+ "voice_design_playback": {k: v for k, v in _TTS_STABILITY_DEFAULT.items() if k != "seed"},
"nvidia_magpie": {},
"nvidia_zeroshot": {},
"nvidia_flow": {},
@@ -160,7 +177,16 @@ def _clean_preview_backend(value: str) -> str:
def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict:
if not _settings_bool(settings.get("tts_stability_enabled"), True):
return {}
- backend = _clean_preview_backend(backend)
+ # "voice_design_playback" is a pseudo-backend used only as a stability-
+ # profile lookup key (the actual HTTP calls still go to the voice_design
+ # engine) — it isn't a real routing target, so _clean_preview_backend
+ # doesn't recognize it and was silently coercing it back to "voice_clone".
+ # Confirmed live: that meant every designed-voice playback line picked up
+ # voice_clone's persisted seed:0 default instead of voice_design_playback's
+ # deliberately seed-less profile, permanently overriding every per-voice
+ # pinned seed with a hardcoded 0. Only normalize real backend names.
+ if backend not in _TTS_STABILITY_BY_BACKEND_DEFAULT:
+ backend = _clean_preview_backend(backend)
by_backend = settings.get("tts_extra_params_by_backend")
if isinstance(by_backend, str):
try:
diff --git a/core/tts_helpers.py b/core/tts_helpers.py
index f938838..8870a8b 100644
--- a/core/tts_helpers.py
+++ b/core/tts_helpers.py
@@ -23,7 +23,34 @@ from core.constants import (
_MAX_TTS_OUTPUT_SECONDS,
)
from core.audio import _to_wav_24k, _duration as _dur
-from core.voice import _find_voice_audio, _read_reference_text
+from core.voice import _find_voice_audio, _read_reference_text, _load_meta
+
+
+def _apply_voice_pinned_seed(payload: dict, voice: str, settings: dict) -> dict:
+ """Fall back to a voice's own saved seed (set via the Seed Finder tool) when
+ the request didn't already pin one explicitly.
+
+ Confirmed live: a voice's pinned seed was saved to its meta.json but never
+ read back anywhere outside the Seed Finder's own one-off benchmarking
+ codepath — every normal generation (Try It Out, Rehearsal, audiobook
+ export) left the seed unset, so all that per-voice seed-hunting work had
+ zero effect on real output. `"seed" not in payload` at this point means no
+ caller-level override (e.g. Seed Finder itself testing a candidate) came
+ through _apply_tts_extra_params, so this only ever fills a gap, never
+ clobbers an explicit choice.
+ """
+ if "seed" in payload:
+ return payload
+ try:
+ scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
+ wav = _find_voice_audio(voice, scan_dir)
+ if wav is not None:
+ seed = _load_meta(wav).get("seed")
+ if seed is not None:
+ payload["seed"] = int(seed)
+ except Exception:
+ pass
+ return payload
# ── Language helpers ──────────────────────────────────────────────────────────
@@ -128,6 +155,8 @@ def _tts_request_config(
if lang:
payload["language"] = lang
_apply_tts_extra_params(payload, settings, _eff_backend)
+ if _eff_backend in ("voice_clone", "streaming", "customvoice"):
+ _apply_voice_pinned_seed(payload, voice, settings)
return endpoint, payload, tts_hdrs
@@ -275,7 +304,12 @@ def _voice_design_voice_request_audio(
payload["instruct"] = instruct.strip()
if language and language != "Auto":
payload["language"] = language
- _apply_tts_extra_params(payload, settings, "voice_design")
+ # "voice_design_playback", not "voice_design" — this is reading an
+ # ALREADY-saved voice back for a line of dialogue, not creating a new
+ # one, so it needs voice_clone-grade stability, not design-time
+ # randomness. See the config-side comment for the full story.
+ _apply_tts_extra_params(payload, settings, "voice_design_playback")
+ _apply_voice_pinned_seed(payload, voice, settings)
resp = _post_tts_with_fallback(f"{vd_url}/v1/audio/speech", payload, vd_hdrs, timeout=180)
resp.raise_for_status()
audio = resp.content
@@ -441,7 +475,16 @@ def _preview_request_audio(
"customvoice",
)
if backend == "voice_design":
- return _voice_design_voice_request_audio(voice, text, settings, instruct)
+ # Every OTHER backend passes an explicit language derived from the
+ # voice's own id prefix (see _tts_request_config above) — this one
+ # left it at the default "Auto" for every single line, letting the
+ # engine guess from scratch each time instead of being told what it
+ # already knows. Short dialogue lines (a few words) are exactly the
+ # case where language auto-detection is least reliable, and a wrong
+ # guess here plausibly contributes to the accent/pronunciation
+ # inconsistency reported for designed voices.
+ lang = _voice_language_name(voice) or "Auto"
+ return _voice_design_voice_request_audio(voice, text, settings, instruct, lang)
if backend == "nvidia_magpie":
return _tts_request_audio(
text, voice, settings, instruct,
diff --git a/routes/conversation.py b/routes/conversation.py
index 2d02c8f..0eec581 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -2120,7 +2120,7 @@ async def attribute_dialogue_stream(request: Request):
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
try:
- from pedalboard import Pedalboard, Reverb, Chorus, Delay, Compressor, Gain, HighpassFilter, LowpassFilter, PitchShift # type: ignore
+ from pedalboard import Pedalboard, Reverb, Chorus, Delay, Compressor, Gain, HighpassFilter, LowpassFilter, PitchShift, Limiter # type: ignore
import numpy as np # type: ignore
except ImportError:
raise RuntimeError("pedalboard is not installed — run: pip install pedalboard numpy")
@@ -2162,18 +2162,49 @@ def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
mix=float(p.get("mix", 0.4)),
))
elif t == "compressor":
+ threshold_db = float(p.get("threshold_db", -20.0))
+ ratio = float(p.get("ratio", 4.0))
board.append(Compressor(
- threshold_db=float(p.get("threshold_db", -20.0)),
- ratio=float(p.get("ratio", 4.0)),
+ threshold_db=threshold_db,
+ ratio=ratio,
attack_ms=float(p.get("attack_ms", 10.0)),
release_ms=float(p.get("release_ms", 100.0)),
))
+ # A pure compressor with no makeup gain only ever shaves peaks
+ # quieter — it never produces the louder, "punchier" sound people
+ # actually associate with compression (broadcast/telephone/radio
+ # effects). Confirmed live: applying the "Telephone" preset
+ # (threshold -10dB, ratio 8:1) to normal TTS speech (normalized to
+ # ~-20dBFS) changed the output by less than 0.1% RMS — with
+ # nothing pushing the result back up, a compressor engaging only
+ # on brief peaks is nearly imperceptible over a whole clip. Makeup
+ # gain restores perceived loudness to roughly what an
+ # unprocessed signal peaking at the threshold would have, which
+ # is the standard way compressors are actually used.
+ makeup_db = float(p.get("makeup_db", -threshold_db * (1 - 1 / max(ratio, 1.0)) * 0.5))
+ if makeup_db:
+ board.append(Gain(gain_db=makeup_db))
+ # Verified live: makeup gain routinely pushed peaks to ~1.9
+ # (well past ±1.0), and the hard np.clip() at the end of this
+ # function turned that into audible digital clipping —
+ # broadband harmonic distortion that swamped whatever the
+ # rest of the chain (e.g. telephone bandpass) was supposed to
+ # sound like. A limiter catches the overshoot smoothly
+ # instead of slicing it off.
+ board.append(Limiter(threshold_db=-1.0, release_ms=100.0))
elif t == "gain":
board.append(Gain(gain_db=float(p.get("gain_db", 0.0))))
elif t == "highpass":
- board.append(HighpassFilter(cutoff_frequency_hz=float(p.get("cutoff_hz", 80.0))))
+ # pedalboard's HighpassFilter is a single-pole design (~6 dB/octave)
+ # — confirmed live it was too gentle to meaningfully shape a full-
+ # bandwidth voice recording (e.g. barely touched content an octave
+ # above the cutoff). Cascading 3 independent stages gives a much
+ # steeper, actually audible roll-off (~18 dB/octave).
+ cutoff = float(p.get("cutoff_hz", 80.0))
+ board.extend(HighpassFilter(cutoff_frequency_hz=cutoff) for _ in range(3))
elif t == "lowpass":
- board.append(LowpassFilter(cutoff_frequency_hz=float(p.get("cutoff_hz", 8000.0))))
+ cutoff = float(p.get("cutoff_hz", 8000.0))
+ board.extend(LowpassFilter(cutoff_frequency_hz=cutoff) for _ in range(3))
elif t == "pitch_shift":
board.append(PitchShift(semitones=float(p.get("semitones", 0.0))))
diff --git a/routes/stt.py b/routes/stt.py
index 6b950ef..76d6154 100644
--- a/routes/stt.py
+++ b/routes/stt.py
@@ -514,7 +514,10 @@ async def transcribe_bytes(
except Exception as e:
raise HTTPException(502, f"STT error: {e}")
finally:
- cleanup = {p for p in (tmp, wav_tmp) if p is not None and p != _registry_get(source_id)}
+ # No source_id here (unlike /api/stt-benchmark below) — every path
+ # this endpoint uses is its own temp file, never a registered voice
+ # library sample, so cleanup is unconditional.
+ cleanup = {p for p in (tmp, wav_tmp) if p is not None}
for p in cleanup:
try:
p.unlink(missing_ok=True)
diff --git a/routes/tts.py b/routes/tts.py
index 9dd49e1..699b26b 100644
--- a/routes/tts.py
+++ b/routes/tts.py
@@ -20,7 +20,7 @@ from fastapi.responses import Response, StreamingResponse
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
from core.constants import (
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
- _routing_log_add, CONFIG_DIR,
+ _routing_log_add, CONFIG_DIR, _VOICE_PEAK_DBFS,
)
from core.routing import (
_load_tts_routes, _resolve_tts_route, _route_backend,
@@ -615,16 +615,22 @@ async def tts_preview(request: Request):
if data.get("apply_persona"):
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
wav = _find_voice_audio(voice, scan_dir)
- if wav:
- persona = _load_meta(wav).get("persona", "")
- if persona:
- llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
- llm_model = settings.get("llm_model") or ""
- try:
- from routes.conversation import _rewrite_with_persona_sync
- text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
- except Exception as e:
- raise HTTPException(502, f"Persona rewrite failed: {e}")
+ persona = _load_meta(wav).get("persona", "") if wav else ""
+ if not persona:
+ # Confirmed live: checking "Apply character persona" on a voice with
+ # no persona text saved (the common case — persona is a manually-
+ # typed field on the Voice Inspector page, never auto-filled) used
+ # to silently do nothing, which looked indistinguishable from the
+ # feature being broken. Fail loud instead so the user knows to set
+ # a persona first, rather than "why doesn't this work."
+ raise HTTPException(400, f"Voice '{voice}' has no character persona saved — set one on the Voice Inspector page first, or uncheck 'Apply character persona.'")
+ llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
+ llm_model = settings.get("llm_model") or ""
+ try:
+ from routes.conversation import _rewrite_with_persona_sync
+ text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
+ except Exception as e:
+ raise HTTPException(502, f"Persona rewrite failed: {e}")
try:
if backend == "voice_clone" and data.get("seed_finder") and "seed" in _overrides:
@@ -1095,6 +1101,50 @@ async def get_seed_sample(voice_name: str, seed: int):
raise HTTPException(e.response.status_code, str(e))
+@router.post("/api/audio/apply-gain")
+async def apply_gain(request: Request, gain_db: float = 0.0):
+ """Apply a FIXED gain to a single WAV clip — NOT the same as normalizing
+ each clip independently to one target level.
+
+ That was the first version of this fix, and it broke something real:
+ normalizing every clip to the same -20dBFS average erases a voice's own
+ internal loudness dynamics along with fixing the cross-voice mismatch —
+ confirmed live, synthesizing the same line "neutral" vs with a whisper
+ instruct: the whisper version came out LOUDER than neutral once each was
+ independently pushed to the same target, exactly backwards. A whisper
+ should measurably be quieter than a shout from the SAME voice; per-clip
+ auto-normalization can't preserve that, only a fixed offset can.
+
+ The caller instead looks up the voice's OWN already-computed reference
+ gain (from Calc dB / voice-loudness metadata) ONCE and applies that same
+ fixed number to every clip from that voice — shifting the whole voice's
+ baseline level to match others (fixing the Narrator-vs-designed-voice
+ gap) while leaving each line's own relative loudness — quiet vs shouted —
+ exactly as the model produced it.
+ """
+ from pydub import AudioSegment
+
+ wav_bytes = await request.body()
+ if not wav_bytes:
+ raise HTTPException(400, "Empty request body")
+ if not gain_db:
+ return Response(content=wav_bytes, media_type="audio/wav")
+ try:
+ seg = AudioSegment.from_file(io.BytesIO(wav_bytes), format="wav")
+ # Still peak-limited — a fixed gain derived from a short reference
+ # clip could clip a louder line (e.g. an already-shouted one).
+ peak = seg.max_dBFS if seg.max_dBFS != float("-inf") else None
+ g = gain_db
+ if peak is not None and peak + g > _VOICE_PEAK_DBFS:
+ g = _VOICE_PEAK_DBFS - peak
+ seg2 = seg.apply_gain(g)
+ out = io.BytesIO()
+ seg2.export(out, format="wav")
+ return Response(content=out.getvalue(), media_type="audio/wav")
+ except Exception as e:
+ raise HTTPException(400, f"Could not apply gain: {e}")
+
+
@router.post("/api/audio/encode-mp3")
async def encode_mp3(request: Request):
"""Encode a raw WAV body into MP3 at an explicit bitrate.
@@ -1207,6 +1257,45 @@ async def prune_line_audio(book: str, request: Request):
return {"ok": True, "deleted": deleted, "kept": len(keep_set)}
+@router.post("/api/line-audio/{book}/normalize")
+async def normalize_line_audio(book: str):
+ """Loudness-normalize every cached clip for this book in place, to the
+ same target used for voice reference files (_normalize_segment).
+
+ Different TTS backends (Voice Clone vs Voice Design) apparently ship
+ very different default output levels — confirmed live as the Narrator
+ (cloned) sounding noticeably louder than designed-voice characters in a
+ finished export, since nothing in the synthesis or merge pipeline ever
+ leveled clips against each other. Running this against the EXISTING
+ cache is far cheaper than resynthesizing the whole book: it's pure audio
+ processing, no TTS calls, so a ~1600-line book normalizes in well under
+ a minute instead of the hours a full resynth would take. A subsequent
+ export then hits 100% cache and just needs to merge + encode.
+
+ Registered before the generic {key} route below for the same routing
+ reason as /check and /prune.
+ """
+ from core.audio import _normalize_segment
+ from pydub import AudioSegment
+
+ book_dir = _line_audio_book_dir(book)
+ normalized = 0
+ skipped = 0
+ errors = 0
+ for f in book_dir.glob("*.wav"):
+ try:
+ seg = AudioSegment.from_file(str(f), format="wav")
+ seg2, info = _normalize_segment(seg)
+ if abs(info.get("gain_db") or 0.0) < 0.1:
+ skipped += 1
+ continue
+ seg2.export(str(f), format="wav")
+ normalized += 1
+ except Exception:
+ errors += 1
+ return {"ok": True, "normalized": normalized, "skipped": skipped, "errors": errors}
+
+
@router.post("/api/line-audio/{book}/{key}")
async def put_line_audio(book: str, key: str, request: Request):
if not _LINE_AUDIO_KEY_RE.match(key):
diff --git a/static/dist/main.min.js b/static/dist/main.min.js
index 3a3110e..3e0ca2f 100644
--- a/static/dist/main.min.js
+++ b/static/dist/main.min.js
@@ -1,4 +1,4 @@
-var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_z,_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z,__,_$,_aa,_ba,_ca,_da,_ea,_fa,_ga,_ha,_ia,_ja,_ka,_la,_ma,_na,_oa,_pa,_qa,_ra,_sa,_ta,_ua,_va,_wa,_xa,_ya,_za,_Aa,_Ba,_Ca,_Da,_Ea,_Fa,_Ga,_Ha,_Ia,_Ja,_Ka,_La,_Ma,_Na,_Oa,_Pa,_Qa,_Ra,_Sa,_Ta,_Ua,_Va,_Wa,_Xa,_Ya,_Za,__a,_$a,_ab,_bb,_cb,_db,_eb,_fb,_gb,_hb,_ib,_jb,_kb,_lb,_mb,_nb,_ob,_pb,_qb,_rb,_sb,_tb,_ub,_vb,_wb,_xb,_yb,_zb,_Ab,_Bb,_Cb,_Db,_Eb,_Fb,_Gb,_Hb,_Ib,_Jb,_Kb,_Lb,_Mb,_Nb,_Ob,_Pb,_Qb,_Rb,_Sb,_Tb,_Ub,_Vb,_Wb,_Xb,_Yb,_Zb,__b,_$b,_ac,_bc,_cc,_dc,_ec,_fc,_gc,_hc,_ic,_jc,_kc,_lc,_mc,_nc,_oc,_pc,_qc,_rc,_sc,_tc;(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
${_avatarHtml(id,26)}
${_esc(name)}
${meta?`${_esc(meta)}`:_flagSpan(v)}
@@ -69,7 +69,7 @@ var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_
- `,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)}const SEED_FINDER_TEXT_DE="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.",SEED_FINDER_TEXT_EN=SEED_FINDER_TEXT_DE,SEED_FINDER_TEXT_MIXED=SEED_FINDER_TEXT_DE;function _seedFinderDefaultText(voiceId){if(window._appSettings&&window._appSettings.seed_finder_text)return window._appSettings.seed_finder_text;const lc=(voiceId||"").toLowerCase();return lc.startsWith("de_")?SEED_FINDER_TEXT_MIXED:lc.startsWith("en_")||lc.startsWith("gb_")?SEED_FINDER_TEXT_EN:SEED_FINDER_TEXT_MIXED}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 attachSeedFinder(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")}});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 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 attachSeedFinder(voiceId,body){const panel=document.createElement("div");panel.className="opt-group seed-finder-group",panel.innerHTML=`
\u{1F3B2} Seed Finder
@@ -97,6 +97,7 @@ var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_
@@ -129,7 +130,7 @@ var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_
- `,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}`);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");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.origin==="designed"||!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
@@ -157,10 +158,10 @@ var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_
- `,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 backend="voice_clone",totalJobs=ids.length*(to-from+1);box.innerHTML=`
Batch seed generation
+ `,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=`
`,document.body.appendChild(resOv),resOv.querySelector("#vv-close").addEventListener("click",()=>resOv.remove()),resOv.addEventListener("click",e=>{e.target===resOv&&resOv.remove()}),toast(`Verified ${results.length} voice(s) \u2014 ${failed.length} failed`,failed.length?"error":"success")}),(_C=$("precompute-embeddings-btn"))==null||_C.addEventListener("click",async()=>{const backend=libraryTtsBackend(),_useSelected=_bulkSelected&&_bulkSelected.size>0,ids=_useSelected?[..._bulkSelected]:activeVoiceIds(),_scopeLabel=_useSelected?`${ids.length} selected`:`${ids.length} active`;if(!ids.length){toast("No voices to precompute","error");return}if(!confirm(`Precompute speaker embeddings for ${_scopeLabel} voice(s) via \u201C${backend}\u201D?
This warms each voice so the engine caches its .pt and first playback is instant.`))return;const btn=$("precompute-embeddings-btn");btn&&(btn.disabled=!0);const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="precompute-overlay",ov.innerHTML=`