Fix voice stability, audio effects, and character/voice pipeline bugs

Voice consistency:
- Read back each voice's pinned seed (Seed Finder / Batch Seeds) on every
  generation. The seed was saved to voice metadata but only ever read by the
  Seed Finder's own benchmark path, so all per-voice seed pinning was inert.
- Stop coercing the "voice_design_playback" stability profile back to
  "voice_clone". The pseudo-backend key isn't a real routing target, so the
  backend-name normalizer silently rewrote it — reintroducing the hardcoded
  seed:0 that profile exists to avoid, overriding every per-voice pin.
- Apply the accent clause on every line, not just at voice-creation time,
  and reorder the instruct so emotion leads and accent trails (Qwen3-TTS
  doesn't reliably follow multiple conflicting instructions).
- Pass an explicit language to Voice Design instead of leaving it on "Auto".

Audio effects:
- Add a limiter after compressor makeup gain. Makeup gain pushed peaks to
  ~1.9, and the final hard clip turned that into broadband distortion that
  swamped the rest of the chain.
- Cascade highpass/lowpass 3 stages each (~18 dB/octave). Single-pole
  filters were too gentle to band-limit speech audibly.
- Add a Bandpass control and wire it into the Telephone/Radio presets —
  compression alone never sounded like a phone; band-limiting is the
  defining trait.

Persona / Try It Out:
- Disable "Apply character persona" with an explanatory tooltip when the
  voice has no persona saved, and error clearly server-side instead of
  silently no-op'ing. Persona is typed manually per voice, never auto-filled.
- Stop dropping applyPersona in the chunked generation path (>200 chars).
- Populate the Voice Design dropdown from the user's own library rather than
  filtering the engine's discovery list, which never contains custom voices.

Navigation and library:
- Use pushState instead of replaceState so browser Back/Forward step through
  in-app navigation instead of leaving the app entirely.
- Show real dialogue line counts in the character sidebar instead of the
  capped reference-quote count (which showed a misleading uniform "12").

Also fixes a crash in /api/transcribe-bytes that referenced an undefined
source_id in its cleanup path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-29 15:33:24 +02:00
parent ea50267c30
commit a62dd0bac1
21 changed files with 793 additions and 111 deletions

View File

@ -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 ## [1.17.95] — 2026-07-26
### Fixed ### Fixed

View File

@ -1 +1 @@
1.17.95 1.18.10

View File

@ -75,6 +75,23 @@ _TTS_STABILITY_BY_BACKEND_DEFAULT = {
# own natural randomization per call, same as the other creative-voice # own natural randomization per call, same as the other creative-voice
# backends below. # backends below.
"voice_design": {}, "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_magpie": {},
"nvidia_zeroshot": {}, "nvidia_zeroshot": {},
"nvidia_flow": {}, "nvidia_flow": {},
@ -160,7 +177,16 @@ def _clean_preview_backend(value: str) -> str:
def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict: def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict:
if not _settings_bool(settings.get("tts_stability_enabled"), True): if not _settings_bool(settings.get("tts_stability_enabled"), True):
return {} 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") by_backend = settings.get("tts_extra_params_by_backend")
if isinstance(by_backend, str): if isinstance(by_backend, str):
try: try:

View File

@ -23,7 +23,34 @@ from core.constants import (
_MAX_TTS_OUTPUT_SECONDS, _MAX_TTS_OUTPUT_SECONDS,
) )
from core.audio import _to_wav_24k, _duration as _dur 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 ────────────────────────────────────────────────────────── # ── Language helpers ──────────────────────────────────────────────────────────
@ -128,6 +155,8 @@ def _tts_request_config(
if lang: if lang:
payload["language"] = lang payload["language"] = lang
_apply_tts_extra_params(payload, settings, _eff_backend) _apply_tts_extra_params(payload, settings, _eff_backend)
if _eff_backend in ("voice_clone", "streaming", "customvoice"):
_apply_voice_pinned_seed(payload, voice, settings)
return endpoint, payload, tts_hdrs return endpoint, payload, tts_hdrs
@ -275,7 +304,12 @@ def _voice_design_voice_request_audio(
payload["instruct"] = instruct.strip() payload["instruct"] = instruct.strip()
if language and language != "Auto": if language and language != "Auto":
payload["language"] = language 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 = _post_tts_with_fallback(f"{vd_url}/v1/audio/speech", payload, vd_hdrs, timeout=180)
resp.raise_for_status() resp.raise_for_status()
audio = resp.content audio = resp.content
@ -441,7 +475,16 @@ def _preview_request_audio(
"customvoice", "customvoice",
) )
if backend == "voice_design": 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": if backend == "nvidia_magpie":
return _tts_request_audio( return _tts_request_audio(
text, voice, settings, instruct, text, voice, settings, instruct,

View File

@ -2120,7 +2120,7 @@ async def attribute_dialogue_stream(request: Request):
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes: def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
try: 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 import numpy as np # type: ignore
except ImportError: except ImportError:
raise RuntimeError("pedalboard is not installed — run: pip install pedalboard numpy") 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)), mix=float(p.get("mix", 0.4)),
)) ))
elif t == "compressor": elif t == "compressor":
threshold_db = float(p.get("threshold_db", -20.0))
ratio = float(p.get("ratio", 4.0))
board.append(Compressor( board.append(Compressor(
threshold_db=float(p.get("threshold_db", -20.0)), threshold_db=threshold_db,
ratio=float(p.get("ratio", 4.0)), ratio=ratio,
attack_ms=float(p.get("attack_ms", 10.0)), attack_ms=float(p.get("attack_ms", 10.0)),
release_ms=float(p.get("release_ms", 100.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": elif t == "gain":
board.append(Gain(gain_db=float(p.get("gain_db", 0.0)))) board.append(Gain(gain_db=float(p.get("gain_db", 0.0))))
elif t == "highpass": 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": 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": elif t == "pitch_shift":
board.append(PitchShift(semitones=float(p.get("semitones", 0.0)))) board.append(PitchShift(semitones=float(p.get("semitones", 0.0))))

View File

@ -514,7 +514,10 @@ async def transcribe_bytes(
except Exception as e: except Exception as e:
raise HTTPException(502, f"STT error: {e}") raise HTTPException(502, f"STT error: {e}")
finally: 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: for p in cleanup:
try: try:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)

View File

@ -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.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
from core.constants import ( from core.constants import (
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW, _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 ( from core.routing import (
_load_tts_routes, _resolve_tts_route, _route_backend, _load_tts_routes, _resolve_tts_route, _route_backend,
@ -615,16 +615,22 @@ async def tts_preview(request: Request):
if data.get("apply_persona"): if data.get("apply_persona"):
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
wav = _find_voice_audio(voice, scan_dir) wav = _find_voice_audio(voice, scan_dir)
if wav: persona = _load_meta(wav).get("persona", "") if wav else ""
persona = _load_meta(wav).get("persona", "") if not persona:
if persona: # Confirmed live: checking "Apply character persona" on a voice with
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") # no persona text saved (the common case — persona is a manually-
llm_model = settings.get("llm_model") or "" # typed field on the Voice Inspector page, never auto-filled) used
try: # to silently do nothing, which looked indistinguishable from the
from routes.conversation import _rewrite_with_persona_sync # feature being broken. Fail loud instead so the user knows to set
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model) # a persona first, rather than "why doesn't this work."
except Exception as e: raise HTTPException(400, f"Voice '{voice}' has no character persona saved — set one on the Voice Inspector page first, or uncheck 'Apply character persona.'")
raise HTTPException(502, f"Persona rewrite failed: {e}") 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: try:
if backend == "voice_clone" and data.get("seed_finder") and "seed" in _overrides: 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)) 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") @router.post("/api/audio/encode-mp3")
async def encode_mp3(request: Request): async def encode_mp3(request: Request):
"""Encode a raw WAV body into MP3 at an explicit bitrate. """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)} 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}") @router.post("/api/line-audio/{book}/{key}")
async def put_line_audio(book: str, key: str, request: Request): async def put_line_audio(book: str, key: str, request: Request):
if not _LINE_AUDIO_KEY_RE.match(key): if not _LINE_AUDIO_KEY_RE.match(key):

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.17.95"> <meta name="app-version" content="1.18.10">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.17.95"> <link rel="stylesheet" href="/static/style.css?v=1.18.10">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.17.95"></script> <script src="/static/loader.js?v=1.18.10"></script>
</body> </body>
</html> </html>

View File

@ -5147,7 +5147,7 @@ function audiobookIsChapter(line) {
function audiobookLineVoice(l) { function audiobookLineVoice(l) {
if (l.type === 'dialog') { if (l.type === 'dialog') {
const c = rehState.cast[l.speaker] || {}; const c = rehState.cast[l.speaker] || {};
return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion) : '') }; return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion, c.voice) : '') };
} }
return { voice: rehState.narratorVoice, instruct: '' }; return { voice: rehState.narratorVoice, instruct: '' };
} }
@ -5220,7 +5220,7 @@ async function audiobookExport() {
blob = await _lineAudioCacheGet(book, cacheKey); blob = await _lineAudioCacheGet(book, cacheKey);
} }
if (!blob) { if (!blob) {
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, rehState.backend); blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
if (cacheKey) _lineAudioCachePut(book, cacheKey, blob); if (cacheKey) _lineAudioCachePut(book, cacheKey, blob);
} }
wavClips.set(i, blob); wavClips.set(i, blob);

View File

@ -89,14 +89,14 @@ function splitTextIntoChunks(text, maxLen = 800) {
return chunks.length ? chunks : [text]; return chunks.length ? chunks : [text];
} }
async function generateChunkedTts(voice, text, backend, instruct, extra = null) { async function generateChunkedTts(voice, text, backend, instruct, extra = null, applyPersona = false) {
const chunks = splitTextIntoChunks(text); const chunks = splitTextIntoChunks(text);
const prog = $('preview-chunk-progress'); const prog = $('preview-chunk-progress');
if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}`; } if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}`; }
const blobs = []; const blobs = [];
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}`; if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}`;
blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend, false, extra)); blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend, applyPersona, extra));
} }
if (prog) prog.textContent = 'Merging…'; if (prog) prog.textContent = 'Merging…';
const merged = await mergeWavBlobs(blobs); const merged = await mergeWavBlobs(blobs);
@ -283,14 +283,35 @@ $('playlist-clear-btn')?.addEventListener('click', () => {
// ── Audio effects panel ──────────────────────────────────────────────────── // ── Audio effects panel ────────────────────────────────────────────────────
// Thresholds tuned for our OWN TTS output level, not some assumed
// unnormalized/full-scale source — every voice's audio is normalized to
// roughly -20dBFS (see _VOICE_TARGET_DBFS), and every preset here used to
// sit ABOVE that (as high as -10dB), so the compressor rarely had any
// sustained material to act on at all. Confirmed live: the "Telephone"
// preset changed a real speech clip's loudness by under 0.1% — the
// loudest sustained passages in normal TTS speech only reach around
// -15dBFS, well under a -10dB threshold; only the briefest transient
// consonant peaks ever crossed it. Shifted every threshold down to
// actually engage with real output, and paired with the new makeup-gain
// step (see _apply_audio_effects) so the result is audible, not just
// technically-different.
// Telephone/Radio used to be compressor-only — but a compressor, even one
// that actually engages, just narrows dynamic range; it doesn't produce the
// characteristic "sounds like a phone call" quality at all. That comes from
// the narrow frequency band a real phone line (or AM radio) passes — voices
// muffled, no bass, no air up top. Confirmed live: without it, "Telephone"
// was indistinguishable from a slightly-quieter original. Bandpass added to
// both presets now; it's the one change that's actually unmistakable.
const _FX_PRESETS = { const _FX_PRESETS = {
studio: { reverb: { on:true, room_size:0.6, wet:0.35 }, studio: { reverb: { on:true, room_size:0.6, wet:0.35 },
compressor: { on:true, threshold_db:-18, ratio:3 } }, compressor: { on:true, threshold_db:-24, ratio:3 } },
broadcast: { compressor: { on:true, threshold_db:-12, ratio:6 } }, broadcast: { compressor: { on:true, threshold_db:-20, ratio:6 } },
telephone: { compressor: { on:true, threshold_db:-10, ratio:8 } }, telephone: { compressor: { on:true, threshold_db:-18, ratio:8 },
bandpass: { on:true, low:300, high:3400 } },
warm: { reverb: { on:true, room_size:0.2, wet:0.15 }, warm: { reverb: { on:true, room_size:0.2, wet:0.15 },
compressor: { on:true, threshold_db:-20, ratio:2 } }, compressor: { on:true, threshold_db:-26, ratio:2 } },
radio: { compressor: { on:true, threshold_db:-14, ratio:5 } }, radio: { compressor: { on:true, threshold_db:-22, ratio:5 },
bandpass: { on:true, low:150, high:5500 } },
}; };
function fxSliderBind(sliderId, labelId, fmt) { function fxSliderBind(sliderId, labelId, fmt) {
@ -306,11 +327,13 @@ fxSliderBind('fx-comp-ratio', 'fx-comp-ratio-val', v => v + ':1');
fxSliderBind('fx-chorus-rate', 'fx-chorus-rate-val', v => parseFloat(v).toFixed(1) + ' Hz'); fxSliderBind('fx-chorus-rate', 'fx-chorus-rate-val', v => parseFloat(v).toFixed(1) + ' Hz');
fxSliderBind('fx-chorus-mix', 'fx-chorus-mix-val', v => parseFloat(v).toFixed(2)); fxSliderBind('fx-chorus-mix', 'fx-chorus-mix-val', v => parseFloat(v).toFixed(2));
fxSliderBind('fx-pitch-semi', 'fx-pitch-semi-val', v => (parseFloat(v) >= 0 ? '+' : '') + v + ' st'); fxSliderBind('fx-pitch-semi', 'fx-pitch-semi-val', v => (parseFloat(v) >= 0 ? '+' : '') + v + ' st');
fxSliderBind('fx-bandpass-low', 'fx-bandpass-low-val', v => Math.round(v) + ' Hz');
fxSliderBind('fx-bandpass-high', 'fx-bandpass-high-val', v => Math.round(v) + ' Hz');
$('effects-preset')?.addEventListener('change', () => { $('effects-preset')?.addEventListener('change', () => {
const preset = _FX_PRESETS[$('effects-preset').value]; const preset = _FX_PRESETS[$('effects-preset').value];
if (!preset) return; if (!preset) return;
['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on'].forEach(id => { const el=$(id); if(el) el.checked=false; }); ['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on','fx-bandpass-on'].forEach(id => { const el=$(id); if(el) el.checked=false; });
if (preset.reverb) { if (preset.reverb) {
$('fx-reverb-on').checked = !!preset.reverb.on; $('fx-reverb-on').checked = !!preset.reverb.on;
if (preset.reverb.room_size != null) $('fx-reverb-room').value = preset.reverb.room_size; if (preset.reverb.room_size != null) $('fx-reverb-room').value = preset.reverb.room_size;
@ -321,18 +344,24 @@ $('effects-preset')?.addEventListener('change', () => {
if (preset.compressor.threshold_db != null) $('fx-comp-thresh').value = preset.compressor.threshold_db; if (preset.compressor.threshold_db != null) $('fx-comp-thresh').value = preset.compressor.threshold_db;
if (preset.compressor.ratio != null) $('fx-comp-ratio').value = preset.compressor.ratio; if (preset.compressor.ratio != null) $('fx-comp-ratio').value = preset.compressor.ratio;
} }
['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi'] if (preset.bandpass) {
$('fx-bandpass-on').checked = !!preset.bandpass.on;
if (preset.bandpass.low != null) $('fx-bandpass-low').value = preset.bandpass.low;
if (preset.bandpass.high != null) $('fx-bandpass-high').value = preset.bandpass.high;
}
['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi','fx-bandpass-low','fx-bandpass-high']
.forEach(id => $(id)?.dispatchEvent(new Event('input'))); .forEach(id => $(id)?.dispatchEvent(new Event('input')));
}); });
$('effects-reset-btn')?.addEventListener('click', () => { $('effects-reset-btn')?.addEventListener('click', () => {
$('effects-preset').value = ''; $('effects-preset').value = '';
['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on'].forEach(id => { const el=$(id); if(el) el.checked=false; }); ['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on','fx-bandpass-on'].forEach(id => { const el=$(id); if(el) el.checked=false; });
$('fx-bandpass-low').value = '300'; $('fx-bandpass-high').value = '3400';
$('fx-reverb-room').value = '0.35'; $('fx-reverb-wet').value = '0.25'; $('fx-reverb-room').value = '0.35'; $('fx-reverb-wet').value = '0.25';
$('fx-comp-thresh').value = '-20'; $('fx-comp-ratio').value = '4'; $('fx-comp-thresh').value = '-20'; $('fx-comp-ratio').value = '4';
$('fx-chorus-rate').value = '1'; $('fx-chorus-mix').value = '0.5'; $('fx-chorus-rate').value = '1'; $('fx-chorus-mix').value = '0.5';
$('fx-pitch-semi').value = '0'; $('fx-pitch-semi').value = '0';
['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi'] ['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi','fx-bandpass-low','fx-bandpass-high']
.forEach(id => $(id)?.dispatchEvent(new Event('input'))); .forEach(id => $(id)?.dispatchEvent(new Event('input')));
}); });
@ -344,6 +373,10 @@ $('effects-apply-btn')?.addEventListener('click', async () => {
if ($('fx-compressor-on')?.checked) chain.push({ type:'compressor', params: { threshold_db: +$('fx-comp-thresh').value, ratio: +$('fx-comp-ratio').value } }); if ($('fx-compressor-on')?.checked) chain.push({ type:'compressor', params: { threshold_db: +$('fx-comp-thresh').value, ratio: +$('fx-comp-ratio').value } });
if ($('fx-chorus-on')?.checked) chain.push({ type:'chorus', params: { rate_hz: +$('fx-chorus-rate').value, mix: +$('fx-chorus-mix').value } }); if ($('fx-chorus-on')?.checked) chain.push({ type:'chorus', params: { rate_hz: +$('fx-chorus-rate').value, mix: +$('fx-chorus-mix').value } });
if ($('fx-pitch-on')?.checked) chain.push({ type:'pitch_shift', params: { semitones: +$('fx-pitch-semi').value } }); if ($('fx-pitch-on')?.checked) chain.push({ type:'pitch_shift', params: { semitones: +$('fx-pitch-semi').value } });
if ($('fx-bandpass-on')?.checked) {
chain.push({ type:'highpass', params: { cutoff_hz: +$('fx-bandpass-low').value } });
chain.push({ type:'lowpass', params: { cutoff_hz: +$('fx-bandpass-high').value } });
}
if (!chain.length) { toast('Enable at least one effect', 'error'); return; } if (!chain.length) { toast('Enable at least one effect', 'error'); return; }
const btn = $('effects-apply-btn'), st = $('effects-status'); const btn = $('effects-apply-btn'), st = $('effects-status');
btn.disabled = true; if (st) st.textContent = 'Processing…'; btn.disabled = true; if (st) st.textContent = 'Processing…';

View File

@ -990,6 +990,23 @@ function _jumpToReaderPage(pageNum) {
// ── Character detail PAGE (full-page with inline editing, replaces the modal) ─ // ── Character detail PAGE (full-page with inline editing, replaces the modal) ─
// Real dialogue-line count for a character, preferring the live count from
// whatever script/rehearsal is currently loaded (exact) and falling back to
// the LLM's own line_count estimate from casting time (approximate, but
// still a real line count — unlike sh.sources.length, which is a capped
// count of saved reference quotes with no relation to how much a character
// actually speaks).
function _charLineCount(c) {
if (window.rehState && rehState.lines && rehState.lines.length) {
const key = String(c.name || '').toUpperCase().trim();
const live = rehState.lines.filter(function (l) {
return l.type === 'dialog' && String(l.speaker || '').toUpperCase().trim() === key;
}).length;
if (live) return live;
}
return Number(c.sheet && c.sheet.line_count) || 0;
}
async function _charDetailPage(rec, allChars, opts) { async function _charDetailPage(rec, allChars, opts) {
opts = opts || {}; opts = opts || {};
// Defaults to the Library grid's own list container/back-navigation, but // Defaults to the Library grid's own list container/back-navigation, but
@ -1126,12 +1143,20 @@ async function _charDetailPage(rec, allChars, opts) {
+ '</div></div>' + '</div></div>'
) : ''; ) : '';
// This used to show sh.sources.length — the number of saved reference
// quotes in the character's profile (capped at 12), not how much the
// character actually speaks. Confirmed live as a genuinely confusing
// number: dozens of characters showed the exact same "12" simply because
// they'd all hit that cap, with no relation to their real line count.
// Prefer the actual live count from the currently-loaded script (exact),
// falling back to the LLM's own line_count estimate from casting time
// when no script is loaded here.
const sidebarChars = (allChars || []).slice().sort(function (a, b) { const sidebarChars = (allChars || []).slice().sort(function (a, b) {
return (b.sheet?.sources?.length || 0) - (a.sheet?.sources?.length || 0); return _charLineCount(b) - _charLineCount(a);
}); });
const sidebarHtml = sidebarChars.map(function (c) { const sidebarHtml = sidebarChars.map(function (c) {
const h = _charHue(c.name); const h = _charHue(c.name);
const count = (c.sheet?.sources || []).length; const count = _charLineCount(c);
return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">' return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">'
+ '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>' + '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>'
+ '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>' + '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>'
@ -2643,6 +2668,20 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
} }
} catch (e) { console.warn('[voice benchmark]', e); } } catch (e) { console.warn('[voice benchmark]', e); }
} }
// Duration/wpm alone can't tell "read the line correctly" from "repeated
// it twice" or "said something unrelated" — both can pass every check
// above. Transcribe the actual attempt back with Whisper and compare to
// what it was supposed to say; a low match rejects this attempt exactly
// like a wpm/clipping failure, so it gets the same retry treatment.
if (!bad && typeof _voiceRoundtripCheck === 'function') {
try {
const rt = await _voiceRoundtripCheck(tryId, sampleText, 'voice_design');
if (rt.score < 0.5) {
bad = true;
console.warn('[voice design] STT roundtrip mismatch (score ' + rt.score.toFixed(2) + '): said "' + rt.transcript + '"');
}
} catch (e) { console.warn('[voice design roundtrip]', e); }
}
if (!bad) { if (!bad) {
const r3 = await _fetchRetryingNetworkErrors('/api/save', { const r3 = await _fetchRetryingNetworkErrors('/api/save', {

View File

@ -1189,7 +1189,7 @@ async function _rehPreviewCastLine(sp, btn) {
const icon = btn.querySelector('.mdi'); const icon = btn.querySelector('.mdi');
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading'; btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
try { try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion), backend); const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion, c.voice), backend);
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); } if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = URL.createObjectURL(blob); _rehAudEl.src = URL.createObjectURL(blob);
await _rehAudEl.play(); await _rehAudEl.play();
@ -1839,13 +1839,55 @@ $('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
// voice_clone backends treat instruct as an IDENTITY description; putting the // voice_clone backends treat instruct as an IDENTITY description; putting the
// emotion first and using directive language ("Speak in a … manner") makes the // emotion first and using directive language ("Speak in a … manner") makes the
// model prioritise it over the static voice description, which otherwise wins. // model prioritise it over the static voice description, which otherwise wins.
function _buildInstruct(voiceProfile, emotion) { // Casting tags emotions in the BOOK's own language (this app runs heavily
// with German books, so `emotion` here is usually a German word like
// "fordernd" or "entschlossen") — but the instruct sentence wrapping it was
// always hardcoded English ("Speak in a fordernd manner."), dropping a
// German adjective into an English carrier sentence. Confirmed as the
// likely reason emotion barely registered for designed voices even though
// isolated English-only test instructs ("speak in an extremely angry
// manner") clearly changed the output — a mixed-language instruct is a much
// weaker signal than a natural sentence in one language. `voiceId`'s own
// language-code prefix (DE_/EN_/...) picks the matching template.
const _BUILD_INSTRUCT_TEMPLATES = {
DE: e => `Sprich in einem ${e} Tonfall.`,
EN: e => `Speak in a ${e} manner.`,
};
// Same wording _buildVoicePrompt uses for the one-time design call — that
// clause was NEVER being resent on ongoing lines: the per-line instruct
// only ever carried the character's saved voice_design_prompt (a plain
// voice-quality description with no accent guidance in it at all), because
// the accent clause was built fresh at design time and never written back
// into that saved profile. Since each synthesis call is stateless — the
// engine has no memory of the original design call — omitting it here meant
// every ONGOING line got zero accent reinforcement, only the one-off
// creation call ever did. Confirmed as a real, separate cause of designed
// voices drifting back toward an American accent during actual narration.
const _BUILD_INSTRUCT_LANG_NAMES = { DE: 'German', FR: 'French', ES: 'Spanish', IT: 'Italian', PT: 'Portuguese', NL: 'Dutch', PL: 'Polish' };
function _buildAccentClause(langCode) {
const langName = _BUILD_INSTRUCT_LANG_NAMES[langCode];
if (langName) return `Speak with an authentic native ${langName} accent — not American-accented, not an English speaker doing ${langName}.`;
if (langCode === 'EN') return 'English with a neutral British or international accent, explicitly not American/US-accented.';
return '';
}
function _buildInstruct(voiceProfile, emotion, voiceId) {
const p = (voiceProfile || '').trim(); const p = (voiceProfile || '').trim();
const e = (emotion || '').trim(); const e = (emotion || '').trim();
if (!e && !p) return ''; const langCode = String(voiceId || '').split('_')[0].toUpperCase();
if (!e) return p; const accent = _buildAccentClause(langCode);
if (!p) return `Speak in a ${e} manner.`; if (!e && !p && !accent) return '';
return `Speak in a ${e} manner. ${p}`; const tmpl = _BUILD_INSTRUCT_TEMPLATES[langCode] || _BUILD_INSTRUCT_TEMPLATES.EN;
// Emotion leads, accent trails — Qwen3-TTS's own prompting guidance warns
// it "does not follow instructions correctly when dealing with
// conflicting attributes... favoring one over the other." Putting the
// accent clause first (as the previous version did) made it the most
// prominent instruction on every single line, which lines up with the
// reported regression right after that fix landed: mood stopped coming
// through. Emotion is the one thing that MUST vary per line; accent is a
// constant reminder the voice's own identity should mostly already carry,
// so it goes last, not first.
const parts = [e ? tmpl(e) : '', p, accent].filter(Boolean);
return parts.join(' ');
} }
// Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the // Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the
@ -3272,11 +3314,11 @@ async function synthOneLine(idx) {
if (!line || line.type !== 'dialog') return; if (!line || line.type !== 'dialog') return;
const c = rehState.cast[line.speaker]; const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') return; if (!c || !c.voice || c.voice === 'me') return;
const instruct = _buildInstruct(c.instruct, line.emotion); const instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
_showReSynthBtn(idx, false); _showReSynthBtn(idx, false);
_markSynthDot(idx, 'synthesizing'); _markSynthDot(idx, 'synthesizing');
try { try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend); const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
rehState.synthCache.set(idx, blob); rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx); rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob); preDecodeBlob(idx, blob);
@ -3670,7 +3712,7 @@ async function playNextLine() {
let blob = await _lineAudioCacheGet(book, cacheKey); let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return; if (!stillCurrent()) return;
if (!blob) { if (!blob) {
blob = await fetchTtsPreviewBlob(rehState.narratorVoice, cleanNarr, 'wav', '', rehState.backend); blob = await fetchTtsPreviewBlob(rehState.narratorVoice, cleanNarr, 'wav', '', _ttsBackendForVoice(rehState.narratorVoice, rehState.backend));
if (!stillCurrent()) return; if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob); _lineAudioCachePut(book, cacheKey, blob);
} }
@ -3705,7 +3747,7 @@ async function playNextLine() {
} }
const profile = (cast.instruct || '').trim(); const profile = (cast.instruct || '').trim();
const instruct = _buildInstruct(profile, line.emotion); const instruct = _buildInstruct(profile, line.emotion, cast.voice);
const cleanTxt = stripMarkdown(line.text); const cleanTxt = stripMarkdown(line.text);
const cached = rehState.synthCache.get(rehState.lineIndex); const cached = rehState.synthCache.get(rehState.lineIndex);
@ -3723,7 +3765,7 @@ async function playNextLine() {
let blob = await _lineAudioCacheGet(book, cacheKey); let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return; if (!stillCurrent()) return;
if (!blob) { if (!blob) {
blob = await fetchTtsPreviewBlob(cast.voice, toneText, 'wav', instruct, rehState.backend); blob = await fetchTtsPreviewBlob(cast.voice, toneText, 'wav', instruct, _ttsBackendForVoice(cast.voice, rehState.backend));
if (!stillCurrent()) return; if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob); _lineAudioCachePut(book, cacheKey, blob);
} }
@ -3855,7 +3897,7 @@ async function _lineAudioSyncDots() {
const c = rehState.cast[line.speaker]; const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue; if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice; voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion); instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion); text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else { } else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue; if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
@ -3926,7 +3968,7 @@ async function synthAll() {
const c = rehState.cast[line.speaker]; const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { _markSynthDot(idx, null); prog(++done, ttsLines.length); continue; } if (!c || !c.voice || c.voice === 'me') { _markSynthDot(idx, null); prog(++done, ttsLines.length); continue; }
voice = c.voice; voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion); instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
} else { } else {
voice = rehState.narratorVoice; instruct = ''; voice = rehState.narratorVoice; instruct = '';
} }
@ -3937,7 +3979,7 @@ async function synthAll() {
const cacheKey = await _lineAudioCacheKey(toneText, voice, instruct); const cacheKey = await _lineAudioCacheKey(toneText, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey); let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) { if (!blob) {
blob = await fetchTtsPreviewBlob(voice, toneText, 'wav', instruct, rehState.backend); blob = await fetchTtsPreviewBlob(voice, toneText, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob); _lineAudioCachePut(book, cacheKey, blob);
} }
rehState.synthCache.set(idx, blob); rehState.synthCache.set(idx, blob);
@ -3984,7 +4026,7 @@ $('reh-tb-resynth-stale')?.addEventListener('click', async () => {
const cacheKey = await _lineAudioCacheKey(toneText, c.voice, instruct); const cacheKey = await _lineAudioCacheKey(toneText, c.voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey); let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) { if (!blob) {
blob = await fetchTtsPreviewBlob(c.voice, toneText, 'wav', instruct, rehState.backend); blob = await fetchTtsPreviewBlob(c.voice, toneText, 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob); _lineAudioCachePut(book, cacheKey, blob);
} }
rehState.synthCache.set(idx, blob); rehState.synthCache.set(idx, blob);
@ -4023,7 +4065,7 @@ $('reh-tb-clean-cache')?.addEventListener('click', async () => {
const c = rehState.cast[line.speaker]; const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue; if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice; voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion); instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion); text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else { } else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue; if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
@ -4589,7 +4631,7 @@ function buildTrainSeq() {
const pc = rehState.cast[prev.speaker]; const pc = rehState.cast[prev.speaker];
if (!pc || pc.voice === 'me') break; if (!pc || pc.voice === 'me') break;
cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '', cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '',
voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion), voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion, pc.voice),
voiceData: pc.voiceData }); voiceData: pc.voiceData });
} }
seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } }); seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } });
@ -4636,7 +4678,7 @@ async function _trainPlayCueLines(cueLines) {
if (trainState.phase !== 'playing_cue') break; if (trainState.phase !== 'playing_cue') break;
let blob = rehState.synthCache.get(cue.index); let blob = rehState.synthCache.get(cue.index);
if (!blob) { if (!blob) {
try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, rehState.backend); } try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, _ttsBackendForVoice(cue.voice, rehState.backend)); }
catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; } catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; }
} }
if (trainState.phase !== 'playing_cue') break; if (trainState.phase !== 'playing_cue') break;

View File

@ -5,20 +5,34 @@
// can listen and pick the most natural-sounding one, then save it to the // can listen and pick the most natural-sounding one, then save it to the
// TTS server's voices.json with one click. // TTS server's voices.json with one click.
// Test sentence: umlauts (ä ö ü ß), dates/numbers, and English words — reveals a // This used to be ONE string, identically assigned to the "DE", "EN" AND
// voice's character per seed. Used for every voice (incl. the batch run). // "mixed torture-test" constants — so every German voice's default seed test
const SEED_FINDER_TEXT_DE = '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.'; // actually read a full English sentence embedded in the middle of it
const SEED_FINDER_TEXT_EN = SEED_FINDER_TEXT_DE; // ("The system administrator successfully configured the customized Docker
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE; // stacks..."). Confirmed live as the exact cause of "the seeds are horrible,
// that's not German, it's a mixture of English and German" — the VOICE
// wasn't broken, the literal text it was asked to read was mixed-language.
// Kept the original as an explicit, opt-in torture test (umlauts, dates,
// numbers, and deliberate code-switching, useful for stress-testing a voice
// meant for mixed-language content) — just never the default for a
// single-language audiobook.
const SEED_FINDER_TEXT_DE = 'Die dreitausendfünfhundertsiebenundsechzig neuen Geräte für das Update benötigten eine außergewöhnlich starke Kühlung und regelmäßige Wartung, um die Leistung bei großer Last zu gewährleisten. Notiere dir den einundzwanzigsten Juni um vierzehn Uhr. Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Träume beginnen mit einem positiven Mindset.';
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.';
function _seedFinderDefaultText(voiceId) { function _seedFinderDefaultText(voiceId) {
if (window._appSettings && window._appSettings.seed_finder_text) { if (window._appSettings && window._appSettings.seed_finder_text) {
return window._appSettings.seed_finder_text; return window._appSettings.seed_finder_text;
} }
// The voice's OWN saved reference transcript (the actual book line it was
// designed from) is a far better seed-selection test than any generic
// sentence — it's exactly the kind of content this voice will really read.
const v = (window._voices || []).find(x => x.id === voiceId);
if (v && v.transcript && v.transcript.trim()) return v.transcript.trim();
const lc = (voiceId || '').toLowerCase(); const lc = (voiceId || '').toLowerCase();
if (lc.startsWith('de_')) return SEED_FINDER_TEXT_MIXED; if (lc.startsWith('de_')) return SEED_FINDER_TEXT_DE;
if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN; if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN;
return SEED_FINDER_TEXT_MIXED; return SEED_FINDER_TEXT_DE;
} }
// ── Sample cache (IndexedDB) — generated WAVs persist so reopening a voice or // ── Sample cache (IndexedDB) — generated WAVs persist so reopening a voice or
@ -93,6 +107,7 @@ function attachSeedFinder(voiceId, body) {
<select class="seed-finder-backend"> <select class="seed-finder-backend">
<option value="voice_clone" selected>Voice Clone</option> <option value="voice_clone" selected>Voice Clone</option>
<option value="streaming">Streaming</option> <option value="streaming">Streaming</option>
<option value="voice_design">Voice Design</option>
</select> </select>
</div> </div>
</div> </div>
@ -165,6 +180,9 @@ function attachSeedFinder(voiceId, body) {
pinInput.value = voiceObj.seed; pinInput.value = voiceObj.seed;
pinStatus.textContent = `✓ Pinned seed ${voiceObj.seed}`; pinStatus.textContent = `✓ Pinned seed ${voiceObj.seed}`;
} }
// A designed voice (no reference WAV) can only run through Voice Design —
// Voice Clone/Streaming fail outright for it every time.
if (voiceObj.origin === 'designed' || !voiceObj.has_ref) backendEl.value = 'voice_design';
let _cancelled = false; let _cancelled = false;
let _currentAudio = null; let _currentAudio = null;
@ -504,7 +522,6 @@ function seedFinderBatchAll() {
// Progress phase of the batch (swaps the dialog body to a live progress view). // Progress phase of the batch (swaps the dialog body to a live progress view).
async function _seedBatchRun(ids, from, to, box, ov) { async function _seedBatchRun(ids, from, to, box, ov) {
const backend = 'voice_clone';
const totalJobs = ids.length * (to - from + 1); const totalJobs = ids.length * (to - from + 1);
box.innerHTML = `<div class="audiobook-title"><span class="mdi mdi-dice-multiple-outline"></span> Batch seed generation</div> box.innerHTML = `<div class="audiobook-title"><span class="mdi mdi-dice-multiple-outline"></span> Batch seed generation</div>
<div class="audiobook-msg" id="sb-msg">Starting</div> <div class="audiobook-msg" id="sb-msg">Starting</div>
@ -519,6 +536,10 @@ async function _seedBatchRun(ids, from, to, box, ov) {
const voiceId = ids[vi]; const voiceId = ids[vi];
const text = _seedFinderDefaultText(voiceId); const text = _seedFinderDefaultText(voiceId);
const th = _sfHash(text); const th = _sfHash(text);
// A designed voice (no reference WAV) can't run through voice_clone at
// all — this used to hardcode voice_clone for every voice in the batch,
// failing outright for every designed one regardless of selection.
const backend = (typeof _ttsBackendForVoice === 'function') ? _ttsBackendForVoice(voiceId, 'voice_clone') : 'voice_clone';
for (let seed = from; seed <= to && !cancel; seed++) { for (let seed = from; seed <= to && !cancel; seed++) {
msg.textContent = `Voice ${vi + 1}/${ids.length} · seed ${seed}${voiceId}`; msg.textContent = `Voice ${vi + 1}/${ids.length} · seed ${seed}${voiceId}`;
const key = `${SF_CACHE_VERSION}|${voiceId}|${backend}|${th}|${seed}`; const key = `${SF_CACHE_VERSION}|${voiceId}|${backend}|${th}|${seed}`;

View File

@ -549,7 +549,8 @@ async function loadSettings() {
sv('s-tts-extra-voice-clone', JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-voice-clone', JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2));
sv('s-tts-extra-streaming', JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-streaming', JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2));
sv('s-tts-extra-customvoice', JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-customvoice', JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2));
sv('s-tts-extra-voice-design', JSON.stringify(byBackend.voice_design || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-voice-design', JSON.stringify(byBackend.voice_design || {}, null, 2));
sv('s-tts-extra-voice-design-playback', JSON.stringify(byBackend.voice_design_playback || defaultTtsParams, null, 2));
sv('s-tts-extra-nvidia-magpie', JSON.stringify(byBackend.nvidia_magpie || {}, null, 2)); sv('s-tts-extra-nvidia-magpie', JSON.stringify(byBackend.nvidia_magpie || {}, null, 2));
sv('s-tts-extra-nvidia-zeroshot',JSON.stringify(byBackend.nvidia_zeroshot|| {}, null, 2)); sv('s-tts-extra-nvidia-zeroshot',JSON.stringify(byBackend.nvidia_zeroshot|| {}, null, 2));
sv('s-tts-extra-nvidia-flow', JSON.stringify(byBackend.nvidia_flow || {}, null, 2)); sv('s-tts-extra-nvidia-flow', JSON.stringify(byBackend.nvidia_flow || {}, null, 2));
@ -775,6 +776,7 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
['streaming', 's-tts-extra-streaming', 'Streaming'], ['streaming', 's-tts-extra-streaming', 'Streaming'],
['customvoice', 's-tts-extra-customvoice', 'CustomVoice'], ['customvoice', 's-tts-extra-customvoice', 'CustomVoice'],
['voice_design', 's-tts-extra-voice-design', 'Voice Design'], ['voice_design', 's-tts-extra-voice-design', 'Voice Design'],
['voice_design_playback', 's-tts-extra-voice-design-playback', 'Voice Design (reading a line)'],
['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'], ['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'],
['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'], ['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'],
['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'], ['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'],

View File

@ -8,6 +8,23 @@ function shouldFilterBackendVoices(backend) {
return ['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend || ''); return ['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend || '');
} }
// voice_design's raw discovery endpoint (/v1/audio/voices) ONLY ever lists
// its own bundled presets (vd_british_male, vd_german_male, ...) — it never
// includes any of the user's own designed characters, even though the
// SAME engine happily synthesizes those same ids when asked directly
// (that's exactly how every designed voice in a book gets synthesized).
// Filtering the raw fetched list the same way voice_clone/streaming are
// filtered (intersecting it with the user's own library) was the first
// attempt here — and made things WORSE, not better: since the raw list
// never contains custom ids at all, that intersection is always empty, so
// the dropdown went from "shows only irrelevant presets" to "shows
// nothing." The fix isn't filtering the fetched list, it's not using the
// fetched list at all — just show the user's own active library directly,
// since that's what actually works with this backend's synthesis endpoint.
function shouldReplaceWithLibraryVoices(backend) {
return backend === 'voice_design';
}
async function activeLibraryVoiceIds() { async function activeLibraryVoiceIds() {
if (!_voices.length) await loadVoiceLibrary(); if (!_voices.length) await loadVoiceLibrary();
return new Set((_voices || []).filter(v => v.enabled !== false).map(v => v.id)); return new Set((_voices || []).filter(v => v.enabled !== false).map(v => v.id));
@ -100,6 +117,24 @@ function updatePreviewVoiceMatchPanel() {
} else { } else {
personaBtn?.remove(); personaBtn?.remove();
} }
// "Apply character persona" is a manually-typed field on the Voice
// Inspector page, never auto-filled — confirmed live that checking it for
// a voice with none set (the common case for freshly Voice-Designed
// characters) silently did nothing, indistinguishable from a bug. Reflect
// whether the selected voice actually has one right on the checkbox.
const personaToggle = $('preview-persona-toggle');
if (personaToggle) {
const label = personaToggle.closest('label');
if (v.persona) {
personaToggle.disabled = false;
if (label) label.title = "Rewrite text through this voice's character persona before generating";
} else {
personaToggle.checked = false;
personaToggle.disabled = true;
if (label) label.title = 'This voice has no character persona saved — set one on the Voice Inspector page first.';
}
}
} }
async function synthesizeSelectedReferenceText() { async function synthesizeSelectedReferenceText() {
@ -138,14 +173,20 @@ $('fetch-tts-voices-btn').addEventListener('click', async () => {
try { try {
const backend = $('tts-backend-select')?.value; const backend = $('tts-backend-select')?.value;
if (!backend) throw new Error('No available TTS backend'); if (!backend) throw new Error('No available TTS backend');
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); let ids;
let voices = Array.isArray(rawVoices) ? rawVoices : []; if (shouldReplaceWithLibraryVoices(backend)) {
if (shouldFilterBackendVoices(backend)) { if (!_voices.length) await loadVoiceLibrary();
const activeIds = await activeLibraryVoiceIds(); ids = (_voices || []).filter(v => v.enabled !== false).map(v => v.id);
voices = voices.filter(v => activeIds.has(backendVoiceId(v))); } else {
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
let voices = Array.isArray(rawVoices) ? rawVoices : [];
if (shouldFilterBackendVoices(backend)) {
const activeIds = await activeLibraryVoiceIds();
voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
}
ids = voices.map(backendVoiceId);
} }
const sel = $('tts-voice-select'), prev = sel.value; const sel = $('tts-voice-select'), prev = sel.value;
const ids = voices.map(backendVoiceId);
if (window.VoicePicker) { if (window.VoicePicker) {
VoicePicker.upgrade('tts-voice-select'); VoicePicker.upgrade('tts-voice-select');
VoicePicker.populate('tts-voice-select', ids); VoicePicker.populate('tts-voice-select', ids);
@ -156,8 +197,8 @@ $('fetch-tts-voices-btn').addEventListener('click', async () => {
if (prev && ids.includes(prev)) sel.value = prev; if (prev && ids.includes(prev)) sel.value = prev;
} }
updatePreviewVoiceMatchPanel(); updatePreviewVoiceMatchPanel();
const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices'; const suffix = (shouldFilterBackendVoices(backend) || shouldReplaceWithLibraryVoices(backend)) ? ' active voices' : ' voices';
toast('Fetched '+voices.length+suffix,'success'); toast('Fetched '+ids.length+suffix,'success');
} catch(e) { toast('Fetch failed: '+e.message,'error'); } } catch(e) { toast('Fetch failed: '+e.message,'error'); }
finally { $('fetch-tts-voices-btn').disabled = false; } finally { $('fetch-tts-voices-btn').disabled = false; }
}); });
@ -211,14 +252,109 @@ async function createTtsStreamUrl(voice, text, instruct = '') {
const data = await r.json(); const data = await r.json();
return data.url; return data.url;
} }
// A GPU-contended TTS backend container can be mid-restart for a handful of
// seconds at a time (see _fetchRetryingNetworkErrors in library-characters.js
// for the same fix on the voice-design path) — a bulk job hitting this
// function hundreds of times in a row (Synth all / Audiobook export) will
// reliably catch that window at least once. Confirmed live: a ~2000-line
// export lost 346 lines outright to "Failed to fetch" with zero retry.
// A non-ok HTTP response (a real error with a status/detail) is NOT
// retried — only a connection-level failure that never got a response at all.
async function _ttsPreviewFetchWithRetry(body, tries) {
tries = tries || 3;
for (let i = 1; i <= tries; i++) {
try {
return await fetch('/api/tts-preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch (e) {
if (i === tries) throw e;
await new Promise(r => setTimeout(r, 2500 * i));
}
}
}
// A designed voice (origin==='designed', or simply no reference WAV) can
// only ever be synthesized through the voice_design engine — it has no
// audio to clone a speaker from. The Rehearser/Audiobook pipeline picks one
// backend for the whole script (rehState.backend) and used to pass it
// straight through for every line regardless of which kind of voice that
// specific line's speaker actually has — confirmed live as a real cause of
// bulk synthesis failures: any designed voice mixed into a cast synthesized
// under a voice_clone-family backend fails outright, every single time, no
// retry possible, since the engine genuinely can't do it. Only overrides
// for that one case; every other voice still respects whatever backend the
// script/global selector actually has picked.
function _ttsBackendForVoice(voiceId, fallbackBackend) {
if (!voiceId || voiceId === 'me') return fallbackBackend;
const v = (window._voices || []).find(x => x.id === voiceId);
if (v && (v.origin === 'designed' || !v.has_ref)) return 'voice_design';
return fallbackBackend;
}
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false, extra = null) { async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false, extra = null) {
const body = {text, voice, response_format: responseFormat, instruct, backend}; const body = {text, voice, response_format: responseFormat, instruct, backend};
if (applyPersona) body.apply_persona = true; if (applyPersona) body.apply_persona = true;
if (extra && typeof extra === 'object') Object.assign(body, extra); // e.g. {seed, temperature} if (extra && typeof extra === 'object') Object.assign(body, extra); // e.g. {seed, temperature}
const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); const r = await _ttsPreviewFetchWithRetry(body);
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
return await r.blob(); const blob = await r.blob();
// Different backends ship very different default output levels (confirmed
// live: a cloned Narrator voice noticeably louder than designed-voice
// characters in a finished audiobook export) — but normalizing each clip
// INDEPENDENTLY to one target level was the first attempt here and broke
// something real: it erases a voice's own loudness dynamics along with
// fixing the cross-voice gap. Confirmed live: a whispered line came out
// LOUDER than its own neutral reading once both got pushed to the same
// target — exactly backwards. Instead, apply the voice's OWN already-
// computed reference gain (from Calc dB) as a FIXED offset — this shifts
// the whole voice's baseline to match others without touching how loud
// one particular line is relative to another from the SAME voice.
// Best-effort: any failure just ships the original clip.
if (responseFormat === 'wav') {
const v = (window._voices || []).find(x => x.id === voice);
const gainDb = v && v.loudness && typeof v.loudness.gain_db === 'number' ? v.loudness.gain_db : 0;
if (gainDb) {
try {
const nr = await fetch('/api/audio/apply-gain?gain_db=' + encodeURIComponent(gainDb), { method: 'POST', body: blob });
if (nr.ok) return await nr.blob();
} catch (_) { /* ship the un-adjusted clip */ }
}
}
return blob;
} }
// ── TTS→STT round-trip verification ─────────────────────────────────────────
// A duration/wpm-only benchmark can't tell "read the line correctly" from
// "repeated it twice" or "said something else entirely" — both can produce
// a perfectly normal-looking duration and pass every other check. Actually
// transcribing the synthesized audio back with Whisper and comparing it to
// the original text catches both directly, at the cost of one extra STT
// call per check.
function _textWords(s) {
return String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
}
// Bag-of-words Dice coefficient: tolerant of STT word-order/minor
// transcription slips, but drops sharply for genuinely different content
// (nonsense) or a doubled-up transcript (repeated speech), since the extra
// duplicate words inflate the length without a matching increase in overlap.
function _textSimilarity(a, b) {
const wa = _textWords(a), wb = _textWords(b);
if (!wa.length && !wb.length) return 1;
if (!wa.length || !wb.length) return 0;
const counts = new Map();
wa.forEach(w => counts.set(w, (counts.get(w) || 0) + 1));
let overlap = 0;
wb.forEach(w => { const c = counts.get(w); if (c) { overlap++; counts.set(w, c - 1); } });
return (2 * overlap) / (wa.length + wb.length);
}
async function _voiceRoundtripCheck(voiceId, text, backend, instruct = '') {
const blob = await fetchTtsPreviewBlob(voiceId, text, 'wav', instruct, backend);
const fd = new FormData();
fd.append('file', blob, 'roundtrip.wav');
fd.append('backend', 'configured');
const r = await fetch('/api/transcribe-bytes', { method: 'POST', body: fd });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
return { transcript: d.text || '', score: _textSimilarity(text, d.text || ''), blob };
}
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false, extra = null) { async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false, extra = null) {
const mode = effectiveTtsPlaybackMode(modeOverride); const mode = effectiveTtsPlaybackMode(modeOverride);
if (backend !== 'streaming' || mode === 'buffered') { if (backend !== 'streaming' || mode === 'buffered') {
@ -277,7 +413,7 @@ $('preview-btn').addEventListener('click', async () => {
const audio = $('preview-audio'); const audio = $('preview-audio');
const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function'; const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
const source = useChunked const source = useChunked
? await generateChunkedTts(voice, text, backend, instruct, _extra) ? await generateChunkedTts(voice, text, backend, instruct, _extra, applyPersona)
: await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra); : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra);
previewBlob = source.blob; previewBlob = source.blob;
window._previewVoice = voice; window._previewBackend = backend; window._previewText = text; window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;

View File

@ -1061,6 +1061,84 @@ $('copy-active-voices-btn').addEventListener('click', async () => {
status('Copied ' + label + ' voices to clipboard'); status('Copied ' + label + ' voices to clipboard');
}); });
// Round-trip verify: synthesize each voice's own reference line (or a
// language-appropriate default) and transcribe it back with Whisper — a
// duration-only benchmark can't tell "read the line" from "repeated it
// twice" or "said something unrelated"; this actually checks the words.
// Returns structured results so other code (e.g. an audiobook pre-flight
// check) can act on failures, not just display them.
async function _verifyVoicesRoundtrip(ids, opts) {
opts = opts || {};
const results = [];
const queue = ids.slice();
const worker = async () => {
while (queue.length) {
const id = queue.shift();
const v = (window._voices || []).find(x => x.id === id);
const text = (v && v.transcript && v.transcript.trim()) || getLibAddSampleText((v && v.lang) || 'EN');
const backend = (typeof _ttsBackendForVoice === 'function') ? _ttsBackendForVoice(id, libraryTtsBackend()) : libraryTtsBackend();
try {
const rt = await _voiceRoundtripCheck(id, text, backend);
results.push({ id, text, transcript: rt.transcript, score: rt.score, ok: rt.score >= 0.5 });
} catch (e) {
results.push({ id, text, error: e.message || String(e), ok: false });
}
if (opts.onProgress) opts.onProgress(results.length, ids.length, id);
}
};
await Promise.all(Array.from({ length: Math.min(2, ids.length) }, worker));
return results;
}
$('verify-voices-stt-btn')?.addEventListener('click', async () => {
const _useSelected = _bulkSelected && _bulkSelected.size > 0;
const ids = _useSelected ? [..._bulkSelected] : activeVoiceIds();
if (!ids.length) { toast('No voices to verify', 'error'); return; }
if (!confirm(`Verify ${ids.length} voice(s) by transcribing a synthesized line back with Whisper and comparing it to the original text? This makes one extra synth + STT call per voice.`)) return;
const btn = $('verify-voices-stt-btn'); if (btn) btn.disabled = true;
const ov = document.createElement('div');
ov.className = 'audiobook-overlay'; ov.id = 'verify-voices-overlay';
ov.innerHTML = `<div class="audiobook-box"><div class="audiobook-title"><span class="mdi mdi-check-decagram-outline"></span> Verifying voices</div>
<div class="audiobook-msg" id="vv-msg">0 / ${ids.length}</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="vv-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="vv-cancel">Cancel</button></div></div>`;
document.body.appendChild(ov);
let cancelled = false;
ov.querySelector('#vv-cancel').addEventListener('click', () => { cancelled = true; });
const fill = ov.querySelector('#vv-fill'), msg = ov.querySelector('#vv-msg');
const results = await _verifyVoicesRoundtrip(ids, {
onProgress: (done, total, id) => {
if (fill) fill.style.width = (done / total * 100) + '%';
if (msg) msg.textContent = `${done} / ${total} · ${id}`;
if (cancelled) throw new Error('cancelled');
},
}).catch(() => []);
ov.remove(); if (btn) btn.disabled = false;
const failed = results.filter(r => !r.ok);
const resOv = document.createElement('div');
resOv.className = 'audiobook-overlay';
resOv.innerHTML = `<div class="audiobook-box" style="max-width:640px;max-height:80vh;overflow:auto;">
<div class="audiobook-title"><span class="mdi mdi-check-decagram-outline"></span> Voice verification results</div>
<p class="card-subtitle" style="margin:8px 0 12px;">${results.length - failed.length} / ${results.length} passed.${failed.length ? ' Failed voices likely need a redesign.' : ''}</p>
<div style="display:flex;flex-direction:column;gap:6px;">
${failed.map(r => `<div style="border:1px solid var(--red);border-radius:6px;padding:8px;font-size:13px;">
<b>${escHtml(r.id)}</b> ${r.error ? escHtml(r.error) : 'score ' + r.score.toFixed(2)}
${r.transcript !== undefined ? `<br><span style="color:var(--text-2)">Expected: ${escHtml(r.text.slice(0, 120))}</span><br><span style="color:var(--red)">Heard: ${escHtml((r.transcript || '(nothing)').slice(0, 120))}</span>` : ''}
</div>`).join('') || '<div style="color:var(--green)">All voices passed.</div>'}
</div>
<div style="display:flex; justify-content:flex-end; margin-top:14px;">
<button type="button" class="btn-primary btn-sm" id="vv-close">Close</button>
</div>
</div>`;
document.body.appendChild(resOv);
resOv.querySelector('#vv-close').addEventListener('click', () => resOv.remove());
resOv.addEventListener('click', e => { if (e.target === resOv) resOv.remove(); });
toast(`Verified ${results.length} voice(s) — ${failed.length} failed`, failed.length ? 'error' : 'success');
});
// Precompute speaker embeddings: fire a tiny synth per active voice so the TTS // Precompute speaker embeddings: fire a tiny synth per active voice so the TTS
// engine computes + caches each voice's .pt (speaker fingerprint) ahead of time, // engine computes + caches each voice's .pt (speaker fingerprint) ahead of time,
// making first real playback instant. The engine prefers the cached .pt and only // making first real playback instant. The engine prefers the cached .pt and only
@ -1090,7 +1168,15 @@ $('precompute-embeddings-btn')?.addEventListener('click', async () => {
while (queue.length && !cancel) { while (queue.length && !cancel) {
const id = queue.shift(); const id = queue.shift();
if (msg) msg.textContent = `${done} / ${ids.length} · ${id}`; if (msg) msg.textContent = `${done} / ${ids.length} · ${id}`;
try { await fetchTtsPreviewBlob(id, 'Hallo.', 'wav', '', backend); ok++; } // A designed voice (no reference WAV) can only ever run through the
// voice_design engine — the selected library backend here is a
// deliberate override for CLONED voices (benchmark/precompute against
// one engine on purpose), but applying it unconditionally meant every
// designed voice in a batch failed outright regardless of that choice.
// Confirmed live: "Precomputed 0 embedding(s), 72 skipped/failed" for
// a selection that was entirely designed voices.
const voiceBackend = (typeof _ttsBackendForVoice === 'function') ? _ttsBackendForVoice(id, backend) : backend;
try { await fetchTtsPreviewBlob(id, 'Hallo.', 'wav', '', voiceBackend); ok++; }
catch (_) { failed++; } catch (_) { failed++; }
done++; if (fill) fill.style.width = (done / ids.length * 100) + '%'; done++; if (fill) fill.style.width = (done / ids.length * 100) + '%';
} }

View File

@ -40,7 +40,16 @@
if (!SECTIONS.includes(sectionId)) return; if (!SECTIONS.includes(sectionId)) return;
var next = '#' + encodeURIComponent(sectionId); var next = '#' + encodeURIComponent(sectionId);
if (location.hash === next) return; if (location.hash === next) return;
try { history.replaceState(null, '', location.pathname + location.search + next); } // pushState, not replaceState: every section change used to overwrite
// the SAME single history entry instead of adding a new one, so the
// browser's Back/Forward buttons 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 (confirmed live: landed
// on a Google search that predated the app entirely). The existing
// hashchange listener below already handles Back/Forward correctly
// (calls showSection for the reverted hash), it just never had real
// history entries to be triggered by.
try { history.pushState(null, '', location.pathname + location.search + next); }
catch (_) { location.hash = next; } catch (_) { location.hash = next; }
} }

View File

@ -72,9 +72,14 @@
<span class="s-hint">Extra fields for the CustomVoice backend.</span> <span class="s-hint">Extra fields for the CustomVoice backend.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>Voice Design params</label> <label>Voice Design params (creating a new voice)</label>
<textarea id="s-tts-extra-voice-design" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea> <textarea id="s-tts-extra-voice-design" spellcheck="false" placeholder="{}"></textarea>
<span class="s-hint">Extra fields for Voice Design and virtual <code>vd_...</code> voices.</span> <span class="s-hint">Only the one-time call that designs a brand-new voice. Left unconstrained by default so different characters actually sound different — pinning temperature/seed here flattens that variety.</span>
</div>
<div class="s-field">
<label>Voice Design params (reading a line)</label>
<textarea id="s-tts-extra-voice-design-playback" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea>
<span class="s-hint">Every ONGOING line spoken by an already-designed voice — same stability as Voice Clone, since this is a fixed identity being read from, not a fresh design.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>NVIDIA Magpie params</label> <label>NVIDIA Magpie params</label>

View File

@ -147,6 +147,13 @@
<label>Semitones <input type="range" id="fx-pitch-semi" min="-12" max="12" step="0.5" value="0"><span id="fx-pitch-semi-val">0 st</span></label> <label>Semitones <input type="range" id="fx-pitch-semi" min="-12" max="12" step="0.5" value="0"><span id="fx-pitch-semi-val">0 st</span></label>
</div> </div>
</div> </div>
<div class="fx-row">
<label class="fx-toggle"><input type="checkbox" id="fx-bandpass-on"> Bandpass (telephone/radio)</label>
<div class="fx-sliders" id="fx-bandpass-params">
<label>Low cut <input type="range" id="fx-bandpass-low" min="20" max="1000" step="10" value="300"><span id="fx-bandpass-low-val">300 Hz</span></label>
<label>High cut <input type="range" id="fx-bandpass-high" min="1000" max="12000" step="100" value="3400"><span id="fx-bandpass-high-val">3400 Hz</span></label>
</div>
</div>
</div> </div>
<div class="btn-row" style="margin-top:12px"> <div class="btn-row" style="margin-top:12px">
<button class="btn-primary" id="effects-apply-btn" disabled>Apply effects</button> <button class="btn-primary" id="effects-apply-btn" disabled>Apply effects</button>

View File

@ -141,6 +141,7 @@
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy selected voices (or all active if none checked)">Copy selected</button> <button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy selected voices (or all active if none checked)">Copy selected</button>
<button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes &amp; caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button> <button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes &amp; caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button>
<button class="btn-secondary vl-tb-btn" id="seed-batch-all-btn" title="Generate &amp; cache Seed Finder samples for every active voice (skips already-cached; resumable)"><span class="mdi mdi-dice-multiple-outline"></span> Batch seeds</button> <button class="btn-secondary vl-tb-btn" id="seed-batch-all-btn" title="Generate &amp; cache Seed Finder samples for every active voice (skips already-cached; resumable)"><span class="mdi mdi-dice-multiple-outline"></span> Batch seeds</button>
<button class="btn-secondary vl-tb-btn" id="verify-voices-stt-btn" title="Synthesize a test sentence with each voice, transcribe it back with Whisper, and compare to the original text — catches repeated/garbled/nonsense output that a duration-only benchmark misses"><span class="mdi mdi-check-decagram-outline"></span> Verify (STT)</button>
</div> </div>
<div class="vl-footer"> <div class="vl-footer">