diff --git a/CHANGELOG.md b/CHANGELOG.md
index f198131..83a097f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,119 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.20.5] — 2026-08-11
+
+### Added
+- **Emotion quick-pick added to Read Aloud, Try a Voice, and Conversation.** All three previously had either no style control at all (Conversation) or a free-text field that Fish-Speech silently ignores (Try a Voice, Read Aloud — it only reacts to an inline `[tag]` in the text). The new picker is backend-aware: on Fish-Speech it's applied as an inline tag on the text itself; on style-aware backends (VoiceDesign, CustomVoice) it fills the existing instruct field as before.
+- **"Run until < N unknown…"** under Read Aloud's Identify Characters menu — repeats the recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a chosen target, or two passes in a row make no further progress (e.g. GPU/LLM contention). Previously only doable by calling `audiobookRecastUnknown()` directly from the browser console.
+
+### Fixed
+- **Fish-Speech's per-line emotion tags were being read out loud as literal text instead of applied as silent tone control** (confirmed live: `(excited)` was spoken as "Hexited"). Fish-Speech's S2-Pro checkpoint requires **square brackets** (`[excited]`) for a tag to be treated as a control instruction — the app was sending round parentheses. Now that the user has Fish-Speech running (`fish-speech-api`, port 38080), the backend is confirmed working end-to-end: clones the reference WAV, keeps identity stable, and now actually applies the requested tone silently. Already auto-discovered under Engines → Text to Speech → Local, no configuration needed.
+- **Character portrait prompts had no idea what kind of book they belonged to**, producing things like a WWI/WWII-era naval admiral, East-Asian-styled warriors, and an Asian-presenting priest in a Western medieval fantasy book (confirmed live with screenshots from a German DSA fantasy novel). The book-level context feature (`/api/book-profile` — genre/setting/era/language) already existed and was wired into voice-design prompts, but was never passed to `csBuildImagePrompt`. Also now includes the character's `race_species` field (human/elf/ork/etc., already collected by the casting LLM but previously unused downstream) in the prompt. Applies automatically to future portrait generations once a book profile is set on Read Aloud → Book Profile.
+- **Fish-Speech emotion tags produced no audible effect on non-English books** (confirmed live on a German audiobook: audio quality was fine, but every line was flat/emotionless). Root cause was two-fold: (1) per-line auto emotions are LLM-generated in the book's own spoken language (e.g. German "bedrohlich"), but Fish-Speech's docs require **English** tags "regardless of the spoken language" — the untranslated German word was silently ignored; (2) a **double-tagging bug**: the client already embeds `[tag]` directly into the text sent to Fish-Speech, but the server was *also* independently deriving and prepending its own tag from the separately-sent Qwen3-TTS-style instruct sentence, producing things like `[Sprich in einem bedrohlich Tonfall.] [bedrohlich] …` — neither bracket was valid English, so both were ignored. Fixed by (a) adding a German→English emotion-word translation table used when building the client-side Fish tag, and (b) making the server skip its own tag derivation whenever the text already carries one, while still falling back to the original short-text-verbatim behavior for freely-typed style instructions (Read Aloud / Try a Voice) that don't match either template. Qwen3-TTS backends are unaffected — they still receive the native-language instruct sentence unchanged, which is what they're designed to understand.
+
+## [1.20.4] — 2026-08-10
+
+### Fixed
+- **"Casting audiobook" and "Casting unknown" could finish all their LLM work and then get permanently stuck showing "Stop Casting"**, with no error and no way to proceed, even though the underlying work had genuinely completed (confirmed live: GPU load back to idle, but the panel never left its "running" state). Root cause: in both `audiobookCast()` and `audiobookRecastUnknown()`, the post-processing that runs after the main loop finishes (deduping, rollback checks, saving the draft) was completely unguarded — any exception there meant execution never reached `view.complete()`, the only thing that actually resets the button and shows a result. Both now wrap that tail in their own try/catch, so a failure there still reaches a terminal state with a visible error instead of hanging forever. Note: this fix only applies to casting runs started after upgrading — a session already stuck in this state is running old code in memory and won't self-recover; reload and reopen the book, and the periodic autosave during casting means progress up to the point it finished should still be there to resume from.
+
+## [1.20.3] — 2026-08-10
+
+### Fixed
+- **Heading OCR now actually recognizes decorative chapter-heading images that combine an icon/border graphic with the text** (e.g. a bold octagonal badge around "3.Kapitel") — previously it failed silently on every single one of these in a real test book (0 of 16 chapters recovered), either finding no text at all or confidently misreading the border as a stray character, because Tesseract's default full-page layout analysis gets confused by the graphic surrounding the actual text. Root-caused by testing the real OCR engine directly against the actual failing page: cropping tightly to exclude the graphic (roughly the bottom half of the heading region, where centered chapter-title text typically sits below any icon) and telling Tesseract to expect a single line of text (page segmentation mode 7) fixed it completely — verified against all 16 chapter headings in the same book, all recovered correctly and cleanly (1.Kapitel through 16.Kapitel, no garbage, no duplicates). Falls back to the original untrimmed full-heading-region OCR for headings that are already plain text with no surrounding graphic, so this is additive, not a narrowing of what already worked.
+
+## [1.20.1] — 2026-08-10
+
+### Fixed
+- **A PDF page that produces zero extractable text (no real text layer AND heading OCR either found nothing or failed the confidence threshold — common for a page that's entirely a decorative divider graphic, e.g. one book had a page that was just a small icon with no chapter number at all) used to be completely invisible to page tracking**, silently shifting every subsequent page number out of sync with the actual PDF for the rest of the book. Confirmed live: investigated a real book's chapter-heading pages directly — one divider page rendered as literally just a small icon graphic (no OCR-recoverable text by design, not an OCR failure), and every such page was dropping out of `readerState.sentences` entirely, taking its page number with it. Every PDF page now keeps at least a placeholder marker (empty text — never spoken, never shown as a line) so page-break reconstruction after LLM speaker-attribution never skips a page number.
+
+## [1.20.0] — 2026-08-10
+
+### Added
+- **App Routing rows now have a playback-speed multiplier (0.5x-2x) and a preview button.** The speed is applied server-side to real routed requests via a pitch-preserving ffmpeg tempo change (not a naive frame-rate shift, which would make a sped-up voice sound like a chipmunk) — see `core/audio.py:_change_tempo`. The preview button synthesizes the row's exact output voice directly (no need to save the route first) and plays it back at the chosen speed for an instant "does this sound right" check, using a short phrase localized to the row's own language setting.
+
+## [1.19.4] — 2026-08-10
+
+### Changed
+- **Audiobook export now synthesizes lines one at a time instead of 2 concurrent workers.** Every TTS backend this app talks to (Voice Clone, Voice Design, Fish-Speech, and the other local engines) is a single self-hosted GPU model instance, not a horizontally-scaled service — confirmed live, twice now, with two different backends: 2 concurrent requests reliably push at least one past the reverse proxy's 60-second timeout under real load, causing seemingly-random per-line failures that can doom a whole multi-hour export. Serial is slower per line but doesn't waste time on doomed, retried requests — net faster in practice, and actually finishes.
+
+## [1.19.3] — 2026-08-10
+
+### Fixed
+- **The looser chapter-heading detection from 1.19.0 caused ordinary narration sentences to be misdetected as chapter breaks, replacing real paragraph text with a thin marker line — confirmed live as "lots of empty pages" in the A4 pagination view.** Two separate bugs: (1) requiring the keyword only at the start of the line, with nothing checked afterward, meant any short sentence starting with a common word like German "Teil" ("part") — e.g. "Teil des Grundes war unklar." ("Part of the reason was unclear.") — matched as a chapter; now whatever follows the keyword must actually look like part of a heading (empty, a bare number, or a colon/dash-separated subtitle), not a normal grammatical continuation. (2) The leading-numeral stripping regex treated a bare "C" as valid Roman numeral 100 with no requirement that anything sensible follow it — so it silently ate the "C" off the front of "Chapter", turning "Chapter 1: The Beginning" into "hapter 1: The Beginning" before the keyword check ever ran. Verified against the actual book that surfaced this (1998 lines): zero false positives, one correct real match ("14. Kapitel").
+
+## [1.19.2] — 2026-08-10
+
+### Fixed
+- **The tone/identity comparison table always suggested Voice Design as the alternative to Voice Clone, never Fish-Speech, even when Fish-Speech was running** — the suggestion logic just took the first backend matching one criterion (`style_aware`, or `uses_wav`) rather than preferring one matching BOTH, so it never surfaced the strictly-better option (tone-aware AND keeps voice identity) over a partial fix. Now prefers a backend satisfying both properties before falling back to a partial match.
+
+## [1.19.1] — 2026-08-10
+
+### Added
+- **Chapter headings now render as a visible horizontal-rule marker with the chapter's own title/number in the Script Rehearser/Studio Stage view**, not just as an audible pause in the finished export. Reuses the exact same detection `audiobookExport()` uses for chapter/file boundaries, so the fix to that detection (numbered OCR headings like "1.Kapitel") shows up here too — scanning through a long script now makes chapter breaks visually obvious instead of looking like one continuous, undifferentiated wall of narration.
+
+## [1.19.0] — 2026-08-10
+
+### Added
+- **Audiobook exports now have real pauses between paragraphs and chapters, plus an optional custom chapter-transition sound** — `mergeWavBlobs()` used to concatenate every line's clip with literally zero gap, which read as characters teleporting mid-scene with no beat between paragraphs, let alone chapters. New "Pacing" section in Studio → Perform & Export lets you set the paragraph pause (default 2s) and chapter pause (default 4s) independently, and upload a short sound (chime, page-turn, etc., max 2 MB) to play before each chapter's pause — decoded and resampled client-side to exactly match the narration's own sample rate so it splices in cleanly rather than corrupting the merge. Settings persist per-browser via localStorage.
+
+### Fixed
+- **Chapter headings recovered via OCR (from PDFs where the heading is baked into the page as an image) usually weren't recognised as chapters at all**, because `audiobookIsChapter()` required the keyword ("Kapitel"/"Chapter"/etc.) to be the very first word — but OCR'd numbered headings commonly read "1.Kapitel" or "I. Kapitel" with the number first. This silently defeated the documented "one file per chapter" audiobook export for any book using numbered image headings (confirmed live: a full novel exported as a single 7.5-hour file instead of per-chapter files). The chapter regex now tolerates an optional leading number or roman numeral before the keyword.
+
+## [1.18.23] — 2026-08-09
+
+### Changed
+- Moved the backend tone/identity comparison table into the "Generate full audiobook" toggle row's spare width, instead of rendering as its own full-width banner below the toolbar.
+
+## [1.18.22] — 2026-08-09
+
+### Changed
+- Redesigned the backend tone/identity warning as a proper comparison table (current backend vs. the suggested alternative, "Tone control" and "Voice stays identical" as columns with ✓/✗) instead of a run-on sentence with a "Switch" button awkwardly wedged into the middle of it.
+
+## [1.18.21] — 2026-08-09
+
+### Changed
+- "Skip narrator" now defaults to unchecked (narration reads by default) and moved to the front of the Stage toolbar.
+- The backend tone-support warning ("Switch to X for reliable tone…") now has an actual "Switch to X" button instead of just naming the better backend in a sentence and leaving you to go find it yourself in a settings dropdown.
+
+## [1.18.20] — 2026-08-09
+
+### Fixed
+- **A single transient synthesis failure could silently doom an entire multi-hour audiobook export.** `fetchTtsPreviewBlob`'s own retry logic only covers connection-level failures — `fetch()` doesn't throw on a non-2xx HTTP response, so a backend hiccup (confirmed live: transient 500s clustered in the first ~50 lines, most likely GPU/engine warm-up contention from the two parallel export workers both starting cold) skipped that retry layer entirely and permanently failed the line. Different, completely ordinary lines failed across repeated attempts — never the same one twice — confirming this was never about any specific line's content. The export now retries a failed line up to 3 times with backoff before giving up on it for real.
+
+## [1.18.19] — 2026-08-09
+
+### Fixed
+- **Reverted the sticky "Generate full audiobook" toggle from 1.18.18 — it broke scrolling entirely.** Stacking two sticky headers (the toggle plus the already-sticky, now-wrapping multi-row transport bar below it) could exceed the viewport height on a real window, leaving nothing scrollable visible at all. The toggle now scrolls away normally again, same as before 1.18.18; only the transport bar stays sticky. Will revisit with the wrapped toolbar's actual height accounted for, tested in isolation before shipping again.
+
+## [1.18.18] — 2026-07-30
+
+### Fixed
+- **The font-size (-A/+A) buttons were ~18px shorter than every other toolbar button** (`.reh-fontsize-btn` overrode padding to 2px 7px against the base button's 9px 18px). Removing the override alone left a smaller residual gap (plain "A" text computes a shorter natural line-height than icon+text buttons) — matched the sibling buttons' actual rendered height directly instead of guessing at line-height multipliers. Verified live: exact match.
+- **Studio's "Generate full audiobook" toggle wasn't sticky, so it scrolled out of reach while reading through a long script** — only the transport bar below it was. Both now stick together, stacked in the correct order, so the mode toggle and play controls stay visible throughout.
+
+## [1.18.15] — 2026-07-30
+
+### Changed
+- Renamed the Stage toolbar's "Skip desc." checkbox to "Skip narrator" — clearer about what it actually controls (whether the Narrator voice reads scene/action text during playback) now that the toggle genuinely works.
+
+## [1.18.14] — 2026-07-30
+
+### Fixed
+- **The Stage toolbar's font-size, page-mode, Train, and Exit buttons were unreachable on any normal (non-ultrawide) window.** `.reh-console-actions` had `flex-shrink: 0`, and flex items default to `min-width: auto` regardless of any width set — together these meant the block could never shrink below its full unwrapped width (~1560px), so the browser's wrapping algorithm never considered it "too wide to fit" and it just overflowed silently off the right edge with no scrollbar to reach it. Confirmed live at a realistic 800px window width. Now shrinks and wraps onto additional rows instead.
+
+## [1.18.13] — 2026-07-30
+
+### Fixed
+- **Narration playback ignored the "Skip descriptions" toggle (and Studio's "Rehearse ⇄ Audiobook" toggle, which just flips the same flag) once any narrator voice was assigned.** The batch pre-skip optimization correctly checked both "skip mode is on" and "no narrator voice set," but the actual per-line narration-speak branch below it never re-checked the skip flag at all — so as soon as a book had a narrator voice configured (the normal case for any book actually being produced), narration played regardless of the toggle's position. This is why the toggle looked like dead weight: it couldn't turn narration OFF, only ever left it stuck ON. Both branches now consistently respect the toggle.
+- Studio's audiobook-mode toggle now warns clearly if you turn it on with no narrator voice assigned yet, instead of silently doing nothing.
+- Root-caused "emotions still not recognisable" on Book 02's audiobook: its entire line-audio cache (1761 cached clips) was synthesized *before* the per-line emotion-instruct engine fix was actually deployed to the running container — confirmed by comparing file timestamps against the fix's deploy time. Since the cache key is a hash of (text + voice + instruct) and none of those changed, every future playback kept serving the identical pre-fix, flat-delivery audio forever, with the engine fix having no way to ever take effect. Cleared the stale cache; the next playback or export for Book 02 will synthesize fresh against the corrected engine. Book 01's cache was unaffected — it was built entirely after the fix.
+
+
+
+### Fixed
+- **A voice that started as a Voice Design creation was routed to the Voice Design engine forever, even after it had a proper reference clip saved.** Voice Design has no seed parameter at all, so every playback of a designed voice was an unpinned, unreproducible roll regardless of any seed pinned for it. Routing now checks only whether a reference clip exists (`has_ref`) — the actual reason Voice Design is needed at all — not voice origin. A designed voice with a saved reference now clones like any other voice, which is what makes a pinned seed actually take effect for it. Applies to line playback throughout the app (Rehearsal, audiobook export, Try It Out) and to the Seed Finder's own backend default.
+
## [1.18.11] — 2026-07-29
### Fixed
diff --git a/VERSION b/VERSION
index 6961fed..7bf9455 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.18.11
+1.20.5
diff --git a/core/audio.py b/core/audio.py
index d7ce619..786b8b7 100644
--- a/core/audio.py
+++ b/core/audio.py
@@ -30,6 +30,32 @@ def _to_wav_16k(src: Path) -> Path:
return out
+def _change_tempo(wav_bytes: bytes, speed: float) -> bytes:
+ """Time-stretch WAV audio without shifting pitch, via ffmpeg's atempo
+ filter — used by App Routing's per-route playback-speed setting. A naive
+ frame-rate change (or pydub's speedup(), which only accelerates and uses
+ a much cruder splice technique) shifts pitch along with speed, which
+ reads as a chipmunk/slow-motion effect rather than someone just talking
+ faster or slower. atempo only accepts 0.5-2.0 per instance; the caller
+ (core/routing.py's _normalize_route) already clamps to that range.
+ """
+ speed = max(0.5, min(2.0, speed))
+ in_path = TEMP_DIR / f"{uuid.uuid4().hex}_tempo_in.wav"
+ out_path = TEMP_DIR / f"{uuid.uuid4().hex}_tempo_out.wav"
+ in_path.write_bytes(wav_bytes)
+ try:
+ result = subprocess.run(
+ ["ffmpeg", "-y", "-i", str(in_path), "-filter:a", f"atempo={speed}", str(out_path)],
+ capture_output=True, timeout=30,
+ )
+ if result.returncode != 0 or not out_path.exists():
+ raise RuntimeError(result.stderr.decode(errors="replace").strip() or "ffmpeg atempo failed")
+ return out_path.read_bytes()
+ finally:
+ in_path.unlink(missing_ok=True)
+ out_path.unlink(missing_ok=True)
+
+
def _trim(src: Path, start_s: float, end_s: float) -> Path:
seg = AudioSegment.from_file(str(src))
trimmed = seg[int(start_s * 1000):int(end_s * 1000)]
diff --git a/core/routing.py b/core/routing.py
index d57749b..15f0af5 100644
--- a/core/routing.py
+++ b/core/routing.py
@@ -67,6 +67,13 @@ def _normalize_route(rule: dict, idx: int = 0) -> dict:
if lang not in _ROUTE_LANGS:
lang = "*"
output_voice = _clean_route_token(rule.get("output_voice", ""), "")
+ try:
+ speed = float(rule.get("speed", 1.0) or 1.0)
+ except (TypeError, ValueError):
+ speed = 1.0
+ # Clamped to ffmpeg's atempo range for a single filter pass (0.5-2.0) —
+ # see _apply_route_sounds, which is what actually applies this.
+ speed = round(max(0.5, min(2.0, speed)), 2)
return {
"id": _clean_route_token(rule.get("id", f"route_{idx+1}"), f"route_{idx+1}"),
"enabled": bool(rule.get("enabled", True)),
@@ -77,6 +84,7 @@ def _normalize_route(rule: dict, idx: int = 0) -> dict:
"output_voice": output_voice,
"before_sound": _clean_route_sound(rule.get("before_sound", "")),
"after_sound": _clean_route_sound(rule.get("after_sound", "")),
+ "speed": speed,
}
diff --git a/core/tts_helpers.py b/core/tts_helpers.py
index 8870a8b..44e619f 100644
--- a/core/tts_helpers.py
+++ b/core/tts_helpers.py
@@ -379,12 +379,40 @@ def _nvidia_clone_request_audio(
# ── Fish-Speech request (clone from saved WAV + inline emotion markers) ──────
+# Compact German→English fallback for the emotion word Qwen3-TTS's native-language
+# instruct sentence carries (e.g. "Sprich in einem bedrohlich Tonfall.") — Fish-Speech's
+# docs require English tags "regardless of the spoken language". This is only a
+# server-side safety net for callers that never went through the client's own
+# _rehInlineTone/_rehEmotionEnglishTag translation (static/js/rehearser.js) and only
+# send `instruct`; keep it in sync with that JS table if it grows.
+_FISHSPEECH_EMOTION_DE_EN = {
+ "wütend": "angry", "zornig": "angry", "traurig": "sad", "ängstlich": "scared",
+ "furchtsam": "fearful", "fröhlich": "happy", "glücklich": "happy",
+ "flüsternd": "whispering", "aufgeregt": "excited", "überrascht": "surprised",
+ "verzweifelt": "desperate", "resigniert": "resigned", "entschlossen": "determined",
+ "selbstbewusst": "confident", "schüchtern": "shy", "ironisch": "sarcastic",
+ "sarkastisch": "sarcastic", "verächtlich": "contemptuous", "ernst": "serious",
+ "streng": "stern", "befehlend": "commanding", "sanft": "gentle", "zärtlich": "tender",
+ "kalt": "cold", "gelangweilt": "bored", "geheimnisvoll": "mysterious",
+ "bedrohlich": "threatening", "dramatisch": "dramatic", "ruhig": "calm",
+ "schockiert": "shocked", "verwirrt": "confused", "weinend": "tearful",
+ "trauernd": "grieving", "schroff": "curt", "freundlich": "friendly",
+ "spielerisch": "playful", "romantisch": "romantic", "erleichtert": "relieved",
+ "neugierig": "curious", "müde": "weary", "bemerkend": "remarking",
+ "flehend": "pleading", "warnend": "warning", "trotzig": "defiant",
+ "erschrocken": "startled",
+}
+
+
def _fishspeech_emotion_prefix(instruct: str) -> str:
"""Turn the per-line style instruction into a Fish-Speech inline emotion marker.
- The Rehearser sends ``"Speak in a {emotion} manner. {persona}"`` — the persona is
- already carried by the cloned reference WAV, so we only forward the emotion as a
- ``(emotion)`` tag, which Fish-Speech honours for per-line tone control.
+ Fish-Speech (S2-Pro) requires SQUARE brackets for a tag to be treated as a silent
+ control instruction — round parentheses get read aloud as literal text instead
+ (confirmed live: "(excited)" was spoken as "Hexited") — and the tag word itself
+ must be English regardless of the instruct sentence's own language (per Fish
+ Audio's docs). Matches both the EN template ("Speak in a X manner.") and the DE
+ template ("Sprich in einem X Tonfall.") from _BUILD_INSTRUCT_TEMPLATES.
"""
import re
s = (instruct or "").strip()
@@ -392,8 +420,24 @@ def _fishspeech_emotion_prefix(instruct: str) -> str:
return ""
m = re.search(r"speak(?:ing)?\s+in\s+(?:a|an)\s+([a-z\- ]+?)\s+manner", s, re.I)
if m:
- return f"({m.group(1).strip().lower()}) "
- return f"({s}) " if len(s) <= 40 else ""
+ # Matched the EN template — the word is already English, use as-is.
+ return f"[{m.group(1).strip().lower()}] "
+ m = re.search(r"sprich\s+in\s+einem\s+([a-zäöüß\- ]+?)\s+tonfall", s, re.I)
+ if m:
+ # Matched the DE template — the word is German and MUST be translated;
+ # it will almost always be pure a-z letters too, so there is no reliable
+ # way to tell "already English" apart from "German" by charset alone here.
+ word = m.group(1).strip().lower()
+ tag = _FISHSPEECH_EMOTION_DE_EN.get(word, "")
+ return f"[{tag}] " if tag else ""
+ # Free-typed style instruction (Read Aloud / Try a Voice let you type anything,
+ # not just the two fixed templates above) — no reliable way to tell English apart
+ # from another language here, so fall back to the original behavior: use it
+ # verbatim if short enough to plausibly be a tag. Confirmed as the pre-existing,
+ # working behavior for manually-typed English instructions on those two pages;
+ # only the automated Rehearser/Studio pipeline's two known templates are handled
+ # more precisely above.
+ return f"[{s}] " if len(s) <= 40 else ""
def _fishspeech_request_audio(
@@ -419,8 +463,13 @@ def _fishspeech_request_audio(
# Stable per-voice seed → reduces run-to-run drift on top of the reference clone.
seed = int(hashlib.md5(voice.encode("utf-8")).hexdigest()[:8], 16)
+ # The Rehearser/Studio pipeline already embeds an English [tag] straight into
+ # `text` client-side (_rehInlineTone in rehearser.js) — only fall back to deriving
+ # one from `instruct` here for callers that don't (e.g. a direct API call that
+ # skips the client helper), to avoid double-tagging the same line.
+ already_tagged = text.lstrip().startswith("[")
payload = {
- "text": _fishspeech_emotion_prefix(instruct) + text,
+ "text": text if already_tagged else _fishspeech_emotion_prefix(instruct) + text,
"format": "wav",
"references": [{"audio": audio_b64, "text": ref_text or ""}],
"seed": seed,
@@ -587,14 +636,27 @@ def _apply_route_sounds(audio: bytes, media_type: str, route: dict | None, setti
return audio, media_type, []
before = _route_sound_path(settings, str(route.get("before_sound", "")))
after = _route_sound_path(settings, str(route.get("after_sound", "")))
- if not before and not after:
+ try:
+ speed = float(route.get("speed", 1.0) or 1.0)
+ except (TypeError, ValueError):
+ speed = 1.0
+ has_speed = abs(speed - 1.0) > 0.01
+ if not before and not after and not has_speed:
return audio, media_type, []
source_format = "wav" if audio[:4] == b"RIFF" or "wav" in media_type.lower() else None
speech = AudioSegment.from_file(io.BytesIO(audio), format=source_format)
speech = speech.set_channels(1).set_sample_width(2).set_frame_rate(24000)
- combined = AudioSegment.empty()
applied = []
+ if has_speed:
+ # Only the spoken voice is stretched — a before/after chime or
+ # page-turn sound stays at its own natural speed, added after.
+ from core.audio import _change_tempo
+ buf = io.BytesIO()
+ speech.export(buf, format="wav")
+ speech = AudioSegment.from_file(io.BytesIO(_change_tempo(buf.getvalue(), speed)), format="wav")
+ applied.append(f"speed:{speed}x")
+ combined = AudioSegment.empty()
if before:
combined += _sound_segment(before)
applied.append(f"before:{before.name}")
diff --git a/routes/conversation.py b/routes/conversation.py
index 0eec581..f0e3a19 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -2735,21 +2735,42 @@ async def conversation_llm_models(url: str = "", api_key: str = ""):
return {"models": [], "url": base, "error": str(e)}
+# Conversation had no style/emotion control at all before this — every reply
+# synthesized flat regardless of backend. Mirrors the same backend-aware split
+# used for the Rehearser/Studio pipeline: Fish-Speech only reacts to an inline
+# [tag] in the text itself (the instruct field is ignored), other backends
+# take the descriptive phrase directly as instruct. `emotion` here is always
+# already-English (the REH_EMOTIONS quick-pick list), so no translation table
+# is needed the way the German-templated audiobook instruct sentences needed one.
+def _conv_tts_text_and_instruct(text: str, emotion: str, backend: str) -> tuple[str, str]:
+ emotion = (emotion or "").strip()
+ if not emotion:
+ return text, ""
+ if re.search(r"fish", backend or "", re.I):
+ tag = emotion.split(",")[0].strip().lower()
+ if re.fullmatch(r"[a-z\- ]+", tag):
+ return f"[{tag}] {text}", ""
+ return text, ""
+ return text, emotion
+
+
def _make_tts_task(
text: str,
voice: str,
settings: dict,
backend: str,
sem: "asyncio.Semaphore | None",
+ emotion: str = "",
) -> "asyncio.Task":
+ tts_text, instruct = _conv_tts_text_and_instruct(text, emotion, backend)
if sem is None:
return asyncio.create_task(
- asyncio.to_thread(_preview_request_audio, text, voice, settings, "", backend)
+ asyncio.to_thread(_preview_request_audio, tts_text, voice, settings, instruct, backend)
)
async def _guarded() -> tuple[bytes, str]:
async with sem:
- return await asyncio.to_thread(_preview_request_audio, text, voice, settings, "", backend)
+ return await asyncio.to_thread(_preview_request_audio, tts_text, voice, settings, instruct, backend)
return asyncio.create_task(_guarded())
@@ -2763,6 +2784,7 @@ async def conversation_turn(
llm_model: str = Form(""),
tts_backend: str = Form("voice_clone"),
tts_voice: str = Form(""),
+ tts_emotion: str = Form(""),
system_prompt: str = Form("You are a helpful voice assistant. Keep replies short and conversational."),
history: str = Form("[]"),
):
@@ -3004,7 +3026,7 @@ async def conversation_turn(
if tts_first_start is None:
tts_first_start = time.monotonic()
tts_texts.append(chunk_text)
- tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem))
+ tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem, tts_emotion))
except Exception as exc:
yield sse({"type": "error", "stage": "llm", "message": str(exc)})
return
@@ -3023,7 +3045,7 @@ async def conversation_turn(
if tts_first_start is None:
tts_first_start = time.monotonic()
tts_texts.append(sent_buf.strip())
- tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem))
+ tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem, tts_emotion))
llm_total_ms = int((time.monotonic() - t_llm) * 1000)
yield sse({"type": "llm_done", "text": llm_text,
diff --git a/routes/tts.py b/routes/tts.py
index 699b26b..b1b0f38 100644
--- a/routes/tts.py
+++ b/routes/tts.py
@@ -892,9 +892,15 @@ async def openai_speech_proxy(request: Request):
backend = _route_backend(route, voice)
style_instruction = str(data.get("instruct") or data.get("style_instruction") or "")
virtual = _resolve_virtual_voice(voice)
+ def _route_speed(r):
+ try:
+ return float((r or {}).get("speed", 1.0) or 1.0)
+ except (TypeError, ValueError):
+ return 1.0
route_has_sounds = bool((route or {}).get("before_sound") or (route or {}).get("after_sound"))
+ route_has_speed = abs(_route_speed(route) - 1.0) > 0.01
- if backend == "streaming" and not virtual and response_format == "wav" and not route_has_sounds:
+ if backend == "streaming" and not virtual and response_format == "wav" and not route_has_sounds and not route_has_speed:
try:
resp = await asyncio.to_thread(_open_tts_stream_response, text, voice, settings, style_instruction)
except Exception as e:
diff --git a/static/dist/main.min.js b/static/dist/main.min.js
index d2405e2..7baecb5 100644
--- a/static/dist/main.min.js
+++ b/static/dist/main.min.js
@@ -1,4 +1,4 @@
-var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_z,_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z,__,_$,_aa,_ba,_ca,_da,_ea,_fa,_ga,_ha,_ia,_ja,_ka,_la,_ma,_na,_oa,_pa,_qa,_ra,_sa,_ta,_ua,_va,_wa,_xa,_ya,_za,_Aa,_Ba,_Ca,_Da,_Ea,_Fa,_Ga,_Ha,_Ia,_Ja,_Ka,_La,_Ma,_Na,_Oa,_Pa,_Qa,_Ra,_Sa,_Ta,_Ua,_Va,_Wa,_Xa,_Ya,_Za,__a,_$a,_ab,_bb,_cb,_db,_eb,_fb,_gb,_hb,_ib,_jb,_kb,_lb,_mb,_nb,_ob,_pb,_qb,_rb,_sb,_tb,_ub,_vb,_wb,_xb,_yb,_zb,_Ab,_Bb,_Cb,_Db,_Eb,_Fb,_Gb,_Hb,_Ib,_Jb,_Kb,_Lb,_Mb,_Nb,_Ob,_Pb,_Qb,_Rb,_Sb,_Tb,_Ub,_Vb,_Wb,_Xb,_Yb,_Zb,__b,_$b,_ac,_bc,_cc,_dc,_ec,_fc,_gc,_hc,_ic,_jc,_kc,_lc,_mc,_nc,_oc,_pc,_qc,_rc,_sc,_tc,_uc;(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
`}).join("")}async function openRouteSoundBrowser(row,target){_routeSoundPickerTarget={row,target},await loadRouteSounds(),renderRouteSoundBrowser();const panel=$("routing-sound-browser");panel&&(panel.hidden=!1,panel.scrollIntoView({block:"nearest",behavior:"smooth"}));const label=target==="before"?"before sound":"after sound",note=$("routing-sound-browser-note");note&&(note.textContent=`Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`)}function closeRouteSoundBrowser(){const panel=$("routing-sound-browser");panel&&(panel.hidden=!0);const audio=$("routing-sound-preview");audio&&(audio.pause(),audio.hidden=!0,audio.removeAttribute("src")),_routeSoundPlayingButton&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=null,_routeSoundPickerTarget=null}function playRouteSound(path,btn){const audio=$("routing-sound-preview");!audio||!path||(_routeSoundPlayingButton&&_routeSoundPlayingButton!==btn&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=btn,btn.textContent="\u275A\u275A",audio.hidden=!1,audio.src=routeSoundUrl(path),audio.onended=()=>{btn.textContent="\u25B6"},audio.onpause=()=>{_routeSoundPlayingButton===btn&&(btn.textContent="\u25B6")},audio.onplay=()=>{btn.textContent="\u275A\u275A"},audio.play().catch(e=>{btn.textContent="\u25B6",toast("Sound preview failed: "+e.message,"error")}))}function useRouteSound(path,target){const selected=_routeSoundPickerTarget||{},row=selected.row||document.querySelector(".routing-row"),useTarget=target||selected.target||"before";setRouteSoundField(row,useTarget,path),toast(`${useTarget==="before"?"Before":"After"} sound selected`,"success")}function routeBackendOptions(value){const current=value||"voice_clone";return ROUTE_BACKENDS.map(([code,label])=>``).join("")}function refreshRoutingVoiceOptions(){const dl=$("routing-voice-options");if(dl){const ids=[...activeVoiceIds(),...virtualDesignVoiceIds()];dl.innerHTML=[...new Set(ids)].map(id=>``).join("")}const soundsDl=$("routing-sound-options");soundsDl&&(soundsDl.innerHTML=_routeSounds.map(sound=>``).join(""))}function newRoute(app="Open WebUI",inputVoice="default",language="*",outputVoice=""){return{id:"route_"+Date.now().toString(36)+"_"+Math.random().toString(36).slice(2,6),enabled:!0,app,input_voice:inputVoice,language,backend:"voice_clone",output_voice:outputVoice,before_sound:"",after_sound:""}}function renderRoutingList(){if($("routing-list")){if(refreshRoutingVoiceOptions(),$("routing-proxy-url").textContent=getCreatorV1Url(),updateCreatorUrlHints(),$("routing-status").textContent=_ttsRoutes.length?`${_ttsRoutes.length} route${_ttsRoutes.length===1?"":"s"}`:"No routes yet.",!_ttsRoutes.length){$("routing-list").innerHTML='
No routing rules yet. Add a route or add the Open WebUI default examples.
';return}$("routing-list").innerHTML=_ttsRoutes.map((r,i)=>`
+ `}).join("")}async function openRouteSoundBrowser(row,target){_routeSoundPickerTarget={row,target},await loadRouteSounds(),renderRouteSoundBrowser();const panel=$("routing-sound-browser");panel&&(panel.hidden=!1,panel.scrollIntoView({block:"nearest",behavior:"smooth"}));const label=target==="before"?"before sound":"after sound",note=$("routing-sound-browser-note");note&&(note.textContent=`Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`)}function closeRouteSoundBrowser(){const panel=$("routing-sound-browser");panel&&(panel.hidden=!0);const audio=$("routing-sound-preview");audio&&(audio.pause(),audio.hidden=!0,audio.removeAttribute("src")),_routeSoundPlayingButton&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=null,_routeSoundPickerTarget=null}function playRouteSound(path,btn){const audio=$("routing-sound-preview");!audio||!path||(_routeSoundPlayingButton&&_routeSoundPlayingButton!==btn&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSoundPlayingButton=btn,btn.textContent="\u275A\u275A",audio.hidden=!1,audio.playbackRate=1,audio.src=routeSoundUrl(path),audio.onended=()=>{btn.textContent="\u25B6"},audio.onpause=()=>{_routeSoundPlayingButton===btn&&(btn.textContent="\u25B6")},audio.onplay=()=>{btn.textContent="\u275A\u275A"},audio.play().catch(e=>{btn.textContent="\u25B6",toast("Sound preview failed: "+e.message,"error")}))}const ROUTE_SPEED_PREVIEW_TEXT={EN:"This is a quick playback speed test.",DE:"Das ist ein kurzer Test der Wiedergabegeschwindigkeit.",FR:"Ceci est un test rapide de la vitesse de lecture.",ES:"Esta es una prueba r\xE1pida de la velocidad de reproducci\xF3n.",IT:"Questo \xE8 un rapido test della velocit\xE0 di riproduzione.",PT:"Este \xE9 um teste r\xE1pido da velocidade de reprodu\xE7\xE3o.",NL:"Dit is een snelle test van de afspeelsnelheid.",PL:"To jest kr\xF3tki test szybko\u015Bci odtwarzania."};async function previewRouteSpeed(row,btn){var _a2,_b2,_c2,_d2,_e2;const outputVoice=(_a2=row.querySelector(".route-output"))==null?void 0:_a2.value.trim();if(!outputVoice){toast("Set an output voice first","error");return}const backend=((_b2=row.querySelector(".route-backend"))==null?void 0:_b2.value)||"voice_clone",speed=Math.max(.5,Math.min(2,parseFloat((_c2=row.querySelector(".route-speed"))==null?void 0:_c2.value)||1)),lang=(((_d2=row.querySelector(".route-lang"))==null?void 0:_d2.value)||"EN").toUpperCase(),text=ROUTE_SPEED_PREVIEW_TEXT[lang]||ROUTE_SPEED_PREVIEW_TEXT.EN,audio=$("routing-sound-preview");if(!audio)return;_routeSoundPlayingButton&&_routeSoundPlayingButton!==btn&&(_routeSoundPlayingButton.textContent="\u25B6"),_routeSpeedPreviewButton&&_routeSpeedPreviewButton!==btn&&((_e2=_routeSpeedPreviewButton.querySelector(".mdi"))==null||_e2.classList.replace("mdi-pause","mdi-play")),_routeSpeedPreviewButton=btn;const icon=btn.querySelector(".mdi");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(outputVoice,text,"wav","",backend);audio.hidden=!1,audio.src=URL.createObjectURL(blob),audio.playbackRate=speed,icon==null||icon.classList.replace("mdi-play","mdi-pause"),audio.onended=()=>icon==null?void 0:icon.classList.replace("mdi-pause","mdi-play"),audio.onpause=()=>{_routeSpeedPreviewButton===btn&&(icon==null||icon.classList.replace("mdi-pause","mdi-play"))},await audio.play()}catch(e){icon==null||icon.classList.replace("mdi-pause","mdi-play"),toast("Preview failed: "+e.message,"error")}finally{btn.disabled=!1}}let _routeSpeedPreviewButton=null;function useRouteSound(path,target){const selected=_routeSoundPickerTarget||{},row=selected.row||document.querySelector(".routing-row"),useTarget=target||selected.target||"before";setRouteSoundField(row,useTarget,path),toast(`${useTarget==="before"?"Before":"After"} sound selected`,"success")}function routeBackendOptions(value){const current=value||"voice_clone";return ROUTE_BACKENDS.map(([code,label])=>``).join("")}function refreshRoutingVoiceOptions(){const dl=$("routing-voice-options");if(dl){const ids=[...activeVoiceIds(),...virtualDesignVoiceIds()];dl.innerHTML=[...new Set(ids)].map(id=>``).join("")}const soundsDl=$("routing-sound-options");soundsDl&&(soundsDl.innerHTML=_routeSounds.map(sound=>``).join(""))}function newRoute(app="Open WebUI",inputVoice="default",language="*",outputVoice=""){return{id:"route_"+Date.now().toString(36)+"_"+Math.random().toString(36).slice(2,6),enabled:!0,app,input_voice:inputVoice,language,backend:"voice_clone",output_voice:outputVoice,before_sound:"",after_sound:""}}function renderRoutingList(){if($("routing-list")){if(refreshRoutingVoiceOptions(),$("routing-proxy-url").textContent=getCreatorV1Url(),updateCreatorUrlHints(),$("routing-status").textContent=_ttsRoutes.length?`${_ttsRoutes.length} route${_ttsRoutes.length===1?"":"s"}`:"No routes yet.",!_ttsRoutes.length){$("routing-list").innerHTML='
No routing rules yet. Add a route or add the Open WebUI default examples.
- `).join(""),typeof VoicePicker!="undefined"&&document.querySelectorAll(".route-output").forEach(inp=>VoicePicker.attachTextPicker(inp))}}function readRoutingForm(){_ttsRoutes=[...document.querySelectorAll(".routing-row")].map((row,i)=>({id:(_ttsRoutes[Number(row.dataset.index)]||{}).id||`route_${i+1}`,enabled:row.querySelector(".route-enabled").checked,app:row.querySelector(".route-app").value.trim()||"*",input_voice:row.querySelector(".route-input").value.trim()||"default",language:row.querySelector(".route-lang").value||"*",backend:row.querySelector(".route-backend").value||"voice_clone",output_voice:row.querySelector(".route-output").value.trim(),before_sound:row.querySelector(".route-before-sound").value.trim(),after_sound:row.querySelector(".route-after-sound").value.trim()}))}async function loadRoutingTab(){if($("routing-list")){$("routing-proxy-url").textContent=getCreatorV1Url(),updateCreatorUrlHints(),$("routing-status").textContent="Loading routing\u2026",$("routing-list").innerHTML=loadingMarkup("Loading routing","Loading active voices and routing rules for the proxy.",5),setBusyButton("routing-refresh-btn",!0),await Promise.allSettled([_voices.length?Promise.resolve():loadVoiceLibrary().catch(()=>{}),loadRouteSounds()]);try{const r=await fetch("/api/tts-routes");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_ttsRoutes=Array.isArray(d.routes)?d.routes:[],renderRoutingList(),status("Routing loaded"),loadRoutingLog()}catch(e){$("routing-status").textContent="Load failed",$("routing-list").innerHTML=`
@@ -339,7 +343,7 @@ curl -X POST ${proxyBase}/speak \\
${escHtml(voice)}
${escHtml(routingLogMeta(item)||"-")}
${escHtml(text||"-")}
-
`}).join("")}}async function loadRoutingLog(){const el=$("routing-log-list");if(el)try{const r=await fetch("/api/tts-routing-log?limit=80");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_routingLogItems=Array.isArray(d.items)?d.items:[],renderCurrentRoutingLog()}catch(e){el.innerHTML=`
Routing log unavailable: ${escHtml(e.message)}
`}}async function clearRoutingLog(){const btn=$("routing-log-clear-btn");btn&&(btn.disabled=!0);try{const r=await fetch("/api/tts-routing-log",{method:"DELETE"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_routingLogItems=[],renderRoutingLog([]),toast("Routing log cleared","success")}catch(e){toast("Clear log failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function testRouting(){readRoutingForm();const btn=$("routing-test-btn"),el=$("routing-test-result");btn.disabled=!0,el.className="routing-test-result",el.textContent="Testing route...";try{const r=await fetch("/api/tts-route-test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({app:$("routing-test-app").value.trim()||"Open WebUI",voice:$("routing-test-voice").value.trim()||"default",input:$("routing-test-text").value.trim()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}renderRouteTestResult(await r.json()),loadRoutingLog()}catch(e){el.className="routing-test-result warn",el.textContent="Route test failed: "+e.message}finally{btn.disabled=!1}}async function uploadRouteSoundForRow(row,target){const input=document.createElement("input");input.type="file",input.accept="audio/*",input.multiple=!1,input.onchange=async()=>{if(!input.files||!input.files.length)return;const btn=row.querySelector(`.route-sound-upload[data-target="${target}"]`),field=row.querySelector(target==="before"?".route-before-sound":".route-after-sound");btn&&(btn.disabled=!0);try{const fd=new FormData;fd.append("file",input.files[0]);const r=await fetch("/api/route-sounds/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();field.value=d.path||"",await loadRouteSounds(),readRoutingForm(),renderRoutingList(),toast(`${target==="before"?"Before":"After"} sound uploaded`,"success"),status(`Uploaded route sound: ${d.path}`)}catch(e){toast("Sound upload failed: "+e.message,"error"),status("Sound upload failed")}finally{btn&&(btn.disabled=!1)}},input.click()}(_h=$("routing-refresh-btn"))==null||_h.addEventListener("click",loadRoutingTab),(_i=$("routing-add-btn"))==null||_i.addEventListener("click",()=>{readRoutingForm(),_ttsRoutes.push(newRoute()),renderRoutingList()}),(_j=$("routing-add-openwebui-btn"))==null||_j.addEventListener("click",()=>{readRoutingForm();const voices=activeVoiceIds(),firstByLang=lang=>voices.find(v=>v.toUpperCase().startsWith(lang+"_"))||"";_ttsRoutes.push(newRoute("Open WebUI","default","EN",firstByLang("EN"))),_ttsRoutes.push(newRoute("Open WebUI","default","DE",firstByLang("DE"))),renderRoutingList()}),(_k=$("routing-save-btn"))==null||_k.addEventListener("click",saveRoutingTab),(_l=$("routing-test-btn"))==null||_l.addEventListener("click",testRouting),(_m=$("routing-log-refresh-btn"))==null||_m.addEventListener("click",loadRoutingLog),(_n=$("routing-log-clear-btn"))==null||_n.addEventListener("click",clearRoutingLog),(_o=$("routing-log-filter"))==null||_o.addEventListener("change",e=>{_routingLogFilter=e.target.value||"all",renderCurrentRoutingLog()}),(_p=$("routing-list"))==null||_p.addEventListener("change",e=>{const picker=e.target.closest(".route-sound-picker");if(!picker)return;const field=picker.closest(".routing-row").querySelector(picker.dataset.target==="before"?".route-before-sound":".route-after-sound");field&&(field.value=picker.value||""),readRoutingForm()}),(_q=$("routing-list"))==null||_q.addEventListener("click",e=>{const pickBtn=e.target.closest(".route-sound-pick");if(pickBtn){const row2=pickBtn.closest(".routing-row");openRouteSoundBrowser(row2,pickBtn.dataset.target);return}const uploadBtn=e.target.closest(".route-sound-upload");if(uploadBtn){const row2=uploadBtn.closest(".routing-row");uploadRouteSoundForRow(row2,uploadBtn.dataset.target);return}const btn=e.target.closest(".routing-delete");if(!btn)return;readRoutingForm();const row=btn.closest(".routing-row");_ttsRoutes.splice(Number(row.dataset.index),1),renderRoutingList()}),(_r=$("routing-sound-search"))==null||_r.addEventListener("input",debounce(renderRouteSoundBrowser,120)),(_s=$("routing-sound-refresh-btn"))==null||_s.addEventListener("click",async()=>{await loadRouteSounds(),renderRouteSoundBrowser()}),(_t=$("routing-sound-close-btn"))==null||_t.addEventListener("click",closeRouteSoundBrowser),(_u=$("routing-sound-list"))==null||_u.addEventListener("click",e=>{const item=e.target.closest(".routing-sound-item");if(!item)return;const path=item.dataset.path||"",playBtn=e.target.closest(".sound-play");if(playBtn){playRouteSound(path,playBtn);return}e.target.closest(".sound-use-current")&&useRouteSound(path,(_routeSoundPickerTarget==null?void 0:_routeSoundPickerTarget.target)||"before")});const CLONE_SAMPLE_TEXTS={EN:"Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain \u2014 these are the things I love. Can you hear how clearly I speak?",DE:"Hallo! Ich hei\xDFe Alex und das ist meine Stimme. Ich kann leise fl\xFCstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster \u2014 das liebe ich. H\xF6rst du, wie deutlich ich spreche?",IT:"Ciao! Mi chiamo Marco e questa \xE8 la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia \u2014 queste sono le cose che amo. Senti come parlo chiaramente?",ES:"\xA1Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire fr\xEDo del invierno, la c\xE1lida luz del fuego y el suave sonido de la lluvia \u2014 estas son las cosas que amo. \xBFPuedes o\xEDr lo claramente que hablo?",FR:"Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumi\xE8re du feu et le son de la pluie \u2014 voil\xE0 ce que j'aime. Entends-tu comme je parle clairement ?",PT:"Ol\xE1! Meu nome \xE9 Ana e esta \xE9 a minha voz. Posso falar suavemente ou com grande for\xE7a. O ar fresco do inverno, a luz quente do fogo e o som da chuva \u2014 estas s\xE3o as coisas que amo. Consegues ouvir como falo claramente?",NL:"Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen \u2014 dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?",PL:"Cze\u015B\u0107! Mam na imi\u0119 Anna i to jest m\xF3j g\u0142os. Mog\u0119 m\xF3wi\u0107 cicho lub z ca\u0142\u0105 moc\u0105. Mro\u017Ane zimowe powietrze, ciep\u0142e \u015Bwiat\u0142o ognia i d\u017Awi\u0119k deszczu za oknem \u2014 to s\u0105 rzeczy, kt\xF3re kocham. Czy s\u0142yszysz, jak wyra\u017Anie m\xF3wi\u0119?"},CLONE_SAMPLE_NAMES={EN:"Sam",DE:"Alex",IT:"Marco",ES:"Carlos",FR:"Sophie",PT:"Ana",NL:"Laura",PL:"Anna"};function cloneSampleForLang(lang){var _a2;const txt=$("clone-sample-text"),def=CLONE_SAMPLE_NAMES[lang]||"";let t=CLONE_SAMPLE_TEXTS[lang]||CLONE_SAMPLE_TEXTS.EN;const name=(((_a2=$("clone-your-name"))==null?void 0:_a2.value)||"").trim();name&&def&&(t=t.replace(new RegExp("\\b"+def.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b"),name)),txt&&(txt.value=t,txt.dataset.sampleName=name||def)}window.initCloneSampleText=function(){const sel=$("clone-sample-lang"),txt=$("clone-sample-text");!sel||!txt||(cloneSampleForLang(sel.value),sel._cloneSampleBound||(sel.addEventListener("change",()=>cloneSampleForLang(sel.value)),sel._cloneSampleBound=!0))},initCloneSampleText();let ws=null,wsRegions=null,currentFileId=null,trimmedFileId=null,designedFileId=null,editingVoiceId=null,editingVoicePath=null;function initWaveSurfer(){ws&&(ws.destroy(),ws=null,wsRegions=null),wsRegions=WaveSurfer.Regions.create(),ws=WaveSurfer.create({container:"#waveform",waveColor:"#45475a",progressColor:"#89b4fa",cursorColor:"#cba6f7",height:90,normalize:!0,plugins:[wsRegions]}),ws.on("ready",()=>{const dur=ws.getDuration();$("trim-end").value=dur.toFixed(2),$("trim-end").max=dur.toFixed(2),$("trim-start").max=dur.toFixed(2),updateRegion()}),wsRegions.on("region-updated",r=>{$("trim-start").value=r.start.toFixed(2),$("trim-end").value=r.end.toFixed(2),updateDurationLabel()})}function updateRegion(){wsRegions.clearRegions();const s=parseFloat($("trim-start").value)||0,e=parseFloat($("trim-end").value)||(ws?ws.getDuration():0);wsRegions.addRegion({start:s,end:e,color:"rgba(137,180,250,0.25)",drag:!0,resize:!0}),updateDurationLabel()}function updateDurationLabel(){const d=Math.max(0,(parseFloat($("trim-end").value)||0)-(parseFloat($("trim-start").value)||0)),el=$("trim-duration");el.textContent=d.toFixed(1)+" s",el.className=d>=5&&d<=20?"dur-ok":d>20?"dur-warn":"dur-bad"}["trim-start","trim-end"].forEach(id=>$(id).addEventListener("input",()=>{ws&&updateRegion()})),$("play-btn").addEventListener("click",()=>{ws&&ws.playPause()}),$("play-selection-btn").addEventListener("click",()=>{ws&&ws.play(parseFloat($("trim-start").value)||0,parseFloat($("trim-end").value)||ws.getDuration())}),$("auto-trim-btn").addEventListener("click",async()=>{if(!currentFileId){toast("No audio loaded","error");return}$("auto-trim-btn").disabled=!0,status("Finding best TTS reference segment\u2026");try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)status("Backend auto trim unavailable; analysing audio in browser\u2026"),d=await clientAutoTrimBounds(currentFileId);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText||"Auto trim failed")}$("trim-start").value=Number(d.start).toFixed(2),$("trim-end").value=Number(d.end).toFixed(2),ws&&updateRegion(),toast("Auto trim set: "+Number(d.duration).toFixed(1)+" s","success"),status(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),status("Auto trim failed")}finally{$("auto-trim-btn").disabled=!1}});function loadAudioId(id,dur,opts={}){currentFileId=id,trimmedFileId=null,designedFileId=null,editingVoiceId=opts.editingVoiceId||null,editingVoicePath=opts.editingVoicePath||null,$("trim-start").value="0",$("trim-end").value=dur.toFixed(2),$("waveform-card").style.display="",initWaveSurfer(),ws.load("/api/audio/"+id),$("save-result").style.display="none",$("trim-audio").style.display="none",$("no-audio-hint").style.display="",editingVoiceId&&($("voice-id-input").value=editingVoiceId,$("voice-id-input").dispatchEvent(new Event("input")),$("transcript-area").value=opts.transcript||"",status("Editing existing voice: "+editingVoiceId))}const dropZone=$("drop-zone"),fileInput=$("file-input");dropZone.addEventListener("click",()=>fileInput.click()),dropZone.addEventListener("dragover",e=>{e.preventDefault(),dropZone.classList.add("drag-over")}),dropZone.addEventListener("dragleave",()=>dropZone.classList.remove("drag-over")),dropZone.addEventListener("drop",e=>{e.preventDefault(),dropZone.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadFile(e.dataTransfer.files[0])}),fileInput.addEventListener("change",()=>{fileInput.files.length&&uploadFile(fileInput.files[0])});async function uploadFile(file){status("Uploading "+file.name+"\u2026");const fd=new FormData;fd.append("file",file);try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();loadAudioId(d.id,d.duration),status("Loaded: "+file.name+" ("+d.duration.toFixed(1)+" s)"),toast("File loaded","success")}catch(e){toast("Upload failed: "+e.message,"error"),status("Upload failed")}}async function loadLibraryVoiceAudio(v){const audioResp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!audioResp.ok){const e=await audioResp.json().catch(()=>({}));throw new Error(e.detail||audioResp.statusText)}const blob=await audioResp.blob(),ext=(v.file_type||"wav").toLowerCase(),fd=new FormData;fd.append("file",new File([blob],`${v.id}.${ext}`,{type:blob.type||"audio/wav"}));const upload=await fetch("/api/upload",{method:"POST",body:fd});if(!upload.ok){const e=await upload.json().catch(()=>({}));throw new Error(e.detail||upload.statusText)}const d=await upload.json();return{id:d.id,voice_id:v.id,duration:d.duration,transcript:v.transcript||"",file_type:ext,path:v.path}}$("yt-btn").addEventListener("click",()=>{const url=$("yt-url").value.trim();if(!url)return;$("yt-btn").disabled=!0,$("yt-progress").textContent="Starting download\u2026";const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),$("yt-progress").textContent=d.error,$("yt-btn").disabled=!1,es.close()):d.done?(es.close(),$("yt-btn").disabled=!1,$("yt-progress").textContent="Done!",loadAudioId(d.id,d.duration),toast("YouTube audio loaded","success")):($("yt-progress").textContent=d.msg||"",d.pct&&status("Downloading\u2026 "+d.pct+"%"))},es.onerror=()=>{es.close(),$("yt-btn").disabled=!1}});const RAW_MIC_CONSTRAINTS={echoCancellation:!1,noiseSuppression:!1,autoGainControl:!1};async function visibleMicrophoneCount(){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.enumerateDevices))return null;try{return(await navigator.mediaDevices.enumerateDevices()).filter(device=>device.kind==="audioinput").length}catch{return null}}async function microphoneErrorMessage(error){const name=(error==null?void 0:error.name)||"",message=(error==null?void 0:error.message)||"",lowerMessage=message.toLowerCase(),micCount=await visibleMicrophoneCount();return name==="NotFoundError"||lowerMessage.includes("requested device not found")?micCount===0?"No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.":"The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.":name==="NotAllowedError"||name==="PermissionDeniedError"?"Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.":name==="NotReadableError"?"The microphone is busy or unavailable. Close other apps using it, then try again.":name==="SecurityError"?"Microphone access requires localhost or HTTPS.":message||"Microphone failed."}async function requestMicrophoneStream(options={}){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.getUserMedia))throw new Error("Microphone requires HTTPS. Open the app via https://... or access it on localhost.");if(!options.raw)return navigator.mediaDevices.getUserMedia({audio:!0});try{return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS})}catch(e){if((e==null?void 0:e.name)==="OverconstrainedError"||(e==null?void 0:e.name)==="NotFoundError")return navigator.mediaDevices.getUserMedia({audio:!0});throw e}}let _cloneMonState={stream:null,recordStream:null,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,waveRing:null,monitoring:!1};function _cloneRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("clone-mic-meter");if(!meter)return;if(!meter.children.length)for(let i=0;i<18;i++){const b=document.createElement("div");b.className="bar",meter.appendChild(b)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))});const el=$("clone-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function _cloneStartMeter(){if(!_cloneMonState.analyser)return;_cloneMonState.meterRaf&&cancelAnimationFrame(_cloneMonState.meterRaf);const data=new Float32Array(_cloneMonState.analyser.fftSize),canvas=$("clone-live-wave"),RING=300,ADD=10;_cloneMonState.waveRing=new Float32Array(RING);const tick=()=>{_cloneMonState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const s of data)sum+=s*s,peak=Math.max(peak,Math.abs(s));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;if(_cloneRenderMeter(level,db,peak>.98),canvas&&_cloneMonState.waveRing){const ring=_cloneMonState.waveRing;ring.copyWithin(0,ADD);for(let i=0;i.98?"#f38ba8":db>-12?"#f9e2af":"#a6e3a1",ctx.lineWidth=1.5;const mid=h/2;for(let i=0;i{try{n&&n.disconnect()}catch{}}),_cloneMonState.stream&&_cloneMonState.stream.getTracks().forEach(t=>t.stop()),_cloneMonState.recordStream&&_cloneMonState.recordStream.getTracks().forEach(t=>t.stop()),_cloneMonState.audioCtx&&_cloneMonState.audioCtx.close().catch(()=>{}),Object.assign(_cloneMonState,{stream:null,recordStream:null,sourceNode:null,gainNode:null,analyser:null,audioCtx:null,monitoring:!1,waveRing:null}),_cloneRenderMeter(0,-1/0,!1);const wc=$("clone-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height),$("clone-monitor-btn")&&($("clone-monitor-btn").disabled=!1),$("clone-monitor-stop")&&($("clone-monitor-stop").disabled=!0)}(_v=$("clone-monitor-btn"))==null||_v.addEventListener("click",async()=>{try{await _cloneStartMonitor(),status("Mic level monitor active")}catch(e){const m=await microphoneErrorMessage(e);toast(m,"error")}}),(_w=$("clone-monitor-stop"))==null||_w.addEventListener("click",()=>{_cloneStopMonitor(),status("Mic level monitor stopped")}),(_x=$("clone-mic-gain"))==null||_x.addEventListener("input",()=>{const v=parseFloat($("clone-mic-gain").value)||0;$("clone-mic-gain-value")&&($("clone-mic-gain-value").textContent=v.toFixed(2)+"x"),_cloneMonState.gainNode&&(_cloneMonState.gainNode.gain.value=v)}),_cloneRenderMeter();let mediaRec=null,recChunks=[],recTimer=null,recSecs=0;$("rec-start-btn").addEventListener("click",async()=>{try{await _cloneStartMonitor(),recChunks=[],recSecs=0,$("rec-time").textContent="0:00",$("rec-indicator").classList.add("active"),$("rec-start-btn").disabled=!0,$("rec-stop-btn").disabled=!1,recTimer=setInterval(()=>{recSecs++,$("rec-time").textContent=Math.floor(recSecs/60)+":"+String(recSecs%60).padStart(2,"0")},1e3),mediaRec=new MediaRecorder(_cloneMonState.recordStream||_cloneMonState.stream,{audioBitsPerSecond:256e3}),mediaRec.ondataavailable=e=>{e.data.size&&recChunks.push(e.data)},mediaRec.onstop=async()=>{clearInterval(recTimer),$("rec-indicator").classList.remove("active");const blob=new Blob(recChunks,{type:mediaRec.mimeType||"audio/webm"}),ext=(mediaRec.mimeType||"").includes("ogg")?".ogg":".webm";_cloneStopMonitor(),await uploadFile(new File([blob],"recording"+ext,{type:blob.type}))},mediaRec.start(100),status("Recording\u2026")}catch(e){_cloneStopMonitor(),toast(await microphoneErrorMessage(e),"error")}}),$("rec-stop-btn").addEventListener("click",()=>{mediaRec&&mediaRec.state!=="inactive"&&mediaRec.stop(),$("rec-start-btn").disabled=!1,$("rec-stop-btn").disabled=!0}),$("trim-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;if(!currentFileId){toast("No audio loaded","error");return}try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId,start:parseFloat($("trim-start").value)||0,end:parseFloat($("trim-end").value)||0})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();trimmedFileId=d.id,designedFileId=null,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",switchTab("save"),toast("Trim done","success"),(_b2=(_a2=$("transcript-area"))==null?void 0:_a2.closest(".card"))==null||_b2.scrollIntoView({behavior:"smooth",block:"center"}),(_c2=window._cloneAutoTranscribe)==null||_c2.call(window)}catch(e){toast("Trim failed: "+e.message,"error")}});const DESIGN_LANG_CODE={Auto:"EN",English:"EN",Chinese:"ZH",Japanese:"JA",Korean:"KO",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Portuguese:"PT",Russian:"RU"},DESIGN_GENDER_WORD={F:"female",M:"male",N:"neutral"},DESIGN_PRESET_KEY="vcf-design-presets",DESIGN_PRESET_SEEDED_KEY="vcf-design-presets-seeded-v2",DEFAULT_DESIGN_PRESETS={EN_M_Young_Energetic:{description:"Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.",sample_text:"Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.",language:"English",gender:"M"},EN_F_Warm_Narrator:{description:"Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.",sample_text:"The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.",language:"English",gender:"F"},DE_M_Elderly_Documentary:{description:"Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.",sample_text:"Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.",language:"German",gender:"M"},DE_F_Young_Friendly:{description:"Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.",sample_text:"Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.",language:"German",gender:"F"},EN_N_Old_Wise_Assistant:{description:"Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.",sample_text:"Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.",language:"English",gender:"N"}},QWEN_DESIGN_SAMPLES={"qwen-timbre-reuse":{title:"Qwen Timbre Reuse",summary:"Reference clip for designing a reusable teen character timbre.",description:"Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous",text:"H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?",language:"English",gender:"M"},"acoustic-sausage-announcer":{title:"Acoustic Attribute Control - British announcer",summary:"Fast, loud, articulate British male delivery with excitement and performative authority.",description:`gender: Male.
+ `}).join("")}}async function loadRoutingLog(){const el=$("routing-log-list");if(el)try{const r=await fetch("/api/tts-routing-log?limit=80");if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();_routingLogItems=Array.isArray(d.items)?d.items:[],renderCurrentRoutingLog()}catch(e){el.innerHTML=`
Routing log unavailable: ${escHtml(e.message)}
`}}async function clearRoutingLog(){const btn=$("routing-log-clear-btn");btn&&(btn.disabled=!0);try{const r=await fetch("/api/tts-routing-log",{method:"DELETE"});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}_routingLogItems=[],renderRoutingLog([]),toast("Routing log cleared","success")}catch(e){toast("Clear log failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function testRouting(){readRoutingForm();const btn=$("routing-test-btn"),el=$("routing-test-result");btn.disabled=!0,el.className="routing-test-result",el.textContent="Testing route...";try{const r=await fetch("/api/tts-route-test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({app:$("routing-test-app").value.trim()||"Open WebUI",voice:$("routing-test-voice").value.trim()||"default",input:$("routing-test-text").value.trim()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}renderRouteTestResult(await r.json()),loadRoutingLog()}catch(e){el.className="routing-test-result warn",el.textContent="Route test failed: "+e.message}finally{btn.disabled=!1}}async function uploadRouteSoundForRow(row,target){const input=document.createElement("input");input.type="file",input.accept="audio/*",input.multiple=!1,input.onchange=async()=>{if(!input.files||!input.files.length)return;const btn=row.querySelector(`.route-sound-upload[data-target="${target}"]`),field=row.querySelector(target==="before"?".route-before-sound":".route-after-sound");btn&&(btn.disabled=!0);try{const fd=new FormData;fd.append("file",input.files[0]);const r=await fetch("/api/route-sounds/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();field.value=d.path||"",await loadRouteSounds(),readRoutingForm(),renderRoutingList(),toast(`${target==="before"?"Before":"After"} sound uploaded`,"success"),status(`Uploaded route sound: ${d.path}`)}catch(e){toast("Sound upload failed: "+e.message,"error"),status("Sound upload failed")}finally{btn&&(btn.disabled=!1)}},input.click()}(_h=$("routing-refresh-btn"))==null||_h.addEventListener("click",loadRoutingTab),(_i=$("routing-add-btn"))==null||_i.addEventListener("click",()=>{readRoutingForm(),_ttsRoutes.push(newRoute()),renderRoutingList()}),(_j=$("routing-add-openwebui-btn"))==null||_j.addEventListener("click",()=>{readRoutingForm();const voices=activeVoiceIds(),firstByLang=lang=>voices.find(v=>v.toUpperCase().startsWith(lang+"_"))||"";_ttsRoutes.push(newRoute("Open WebUI","default","EN",firstByLang("EN"))),_ttsRoutes.push(newRoute("Open WebUI","default","DE",firstByLang("DE"))),renderRoutingList()}),(_k=$("routing-save-btn"))==null||_k.addEventListener("click",saveRoutingTab),(_l=$("routing-test-btn"))==null||_l.addEventListener("click",testRouting),(_m=$("routing-log-refresh-btn"))==null||_m.addEventListener("click",loadRoutingLog),(_n=$("routing-log-clear-btn"))==null||_n.addEventListener("click",clearRoutingLog),(_o=$("routing-log-filter"))==null||_o.addEventListener("change",e=>{_routingLogFilter=e.target.value||"all",renderCurrentRoutingLog()}),(_p=$("routing-list"))==null||_p.addEventListener("change",e=>{const picker=e.target.closest(".route-sound-picker");if(!picker)return;const field=picker.closest(".routing-row").querySelector(picker.dataset.target==="before"?".route-before-sound":".route-after-sound");field&&(field.value=picker.value||""),readRoutingForm()}),(_q=$("routing-list"))==null||_q.addEventListener("click",e=>{const speedPreviewBtn=e.target.closest(".route-speed-preview");if(speedPreviewBtn){previewRouteSpeed(speedPreviewBtn.closest(".routing-row"),speedPreviewBtn);return}const pickBtn=e.target.closest(".route-sound-pick");if(pickBtn){const row2=pickBtn.closest(".routing-row");openRouteSoundBrowser(row2,pickBtn.dataset.target);return}const uploadBtn=e.target.closest(".route-sound-upload");if(uploadBtn){const row2=uploadBtn.closest(".routing-row");uploadRouteSoundForRow(row2,uploadBtn.dataset.target);return}const btn=e.target.closest(".routing-delete");if(!btn)return;readRoutingForm();const row=btn.closest(".routing-row");_ttsRoutes.splice(Number(row.dataset.index),1),renderRoutingList()}),(_r=$("routing-sound-search"))==null||_r.addEventListener("input",debounce(renderRouteSoundBrowser,120)),(_s=$("routing-sound-refresh-btn"))==null||_s.addEventListener("click",async()=>{await loadRouteSounds(),renderRouteSoundBrowser()}),(_t=$("routing-sound-close-btn"))==null||_t.addEventListener("click",closeRouteSoundBrowser),(_u=$("routing-sound-list"))==null||_u.addEventListener("click",e=>{const item=e.target.closest(".routing-sound-item");if(!item)return;const path=item.dataset.path||"",playBtn=e.target.closest(".sound-play");if(playBtn){playRouteSound(path,playBtn);return}e.target.closest(".sound-use-current")&&useRouteSound(path,(_routeSoundPickerTarget==null?void 0:_routeSoundPickerTarget.target)||"before")});const CLONE_SAMPLE_TEXTS={EN:"Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain \u2014 these are the things I love. Can you hear how clearly I speak?",DE:"Hallo! Ich hei\xDFe Alex und das ist meine Stimme. Ich kann leise fl\xFCstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster \u2014 das liebe ich. H\xF6rst du, wie deutlich ich spreche?",IT:"Ciao! Mi chiamo Marco e questa \xE8 la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia \u2014 queste sono le cose che amo. Senti come parlo chiaramente?",ES:"\xA1Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire fr\xEDo del invierno, la c\xE1lida luz del fuego y el suave sonido de la lluvia \u2014 estas son las cosas que amo. \xBFPuedes o\xEDr lo claramente que hablo?",FR:"Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumi\xE8re du feu et le son de la pluie \u2014 voil\xE0 ce que j'aime. Entends-tu comme je parle clairement ?",PT:"Ol\xE1! Meu nome \xE9 Ana e esta \xE9 a minha voz. Posso falar suavemente ou com grande for\xE7a. O ar fresco do inverno, a luz quente do fogo e o som da chuva \u2014 estas s\xE3o as coisas que amo. Consegues ouvir como falo claramente?",NL:"Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen \u2014 dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?",PL:"Cze\u015B\u0107! Mam na imi\u0119 Anna i to jest m\xF3j g\u0142os. Mog\u0119 m\xF3wi\u0107 cicho lub z ca\u0142\u0105 moc\u0105. Mro\u017Ane zimowe powietrze, ciep\u0142e \u015Bwiat\u0142o ognia i d\u017Awi\u0119k deszczu za oknem \u2014 to s\u0105 rzeczy, kt\xF3re kocham. Czy s\u0142yszysz, jak wyra\u017Anie m\xF3wi\u0119?"},CLONE_SAMPLE_NAMES={EN:"Sam",DE:"Alex",IT:"Marco",ES:"Carlos",FR:"Sophie",PT:"Ana",NL:"Laura",PL:"Anna"};function cloneSampleForLang(lang){var _a2;const txt=$("clone-sample-text"),def=CLONE_SAMPLE_NAMES[lang]||"";let t=CLONE_SAMPLE_TEXTS[lang]||CLONE_SAMPLE_TEXTS.EN;const name=(((_a2=$("clone-your-name"))==null?void 0:_a2.value)||"").trim();name&&def&&(t=t.replace(new RegExp("\\b"+def.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\b"),name)),txt&&(txt.value=t,txt.dataset.sampleName=name||def)}window.initCloneSampleText=function(){const sel=$("clone-sample-lang"),txt=$("clone-sample-text");!sel||!txt||(cloneSampleForLang(sel.value),sel._cloneSampleBound||(sel.addEventListener("change",()=>cloneSampleForLang(sel.value)),sel._cloneSampleBound=!0))},initCloneSampleText();let ws=null,wsRegions=null,currentFileId=null,trimmedFileId=null,designedFileId=null,editingVoiceId=null,editingVoicePath=null;function initWaveSurfer(){ws&&(ws.destroy(),ws=null,wsRegions=null),wsRegions=WaveSurfer.Regions.create(),ws=WaveSurfer.create({container:"#waveform",waveColor:"#45475a",progressColor:"#89b4fa",cursorColor:"#cba6f7",height:90,normalize:!0,plugins:[wsRegions]}),ws.on("ready",()=>{const dur=ws.getDuration();$("trim-end").value=dur.toFixed(2),$("trim-end").max=dur.toFixed(2),$("trim-start").max=dur.toFixed(2),updateRegion()}),wsRegions.on("region-updated",r=>{$("trim-start").value=r.start.toFixed(2),$("trim-end").value=r.end.toFixed(2),updateDurationLabel()})}function updateRegion(){wsRegions.clearRegions();const s=parseFloat($("trim-start").value)||0,e=parseFloat($("trim-end").value)||(ws?ws.getDuration():0);wsRegions.addRegion({start:s,end:e,color:"rgba(137,180,250,0.25)",drag:!0,resize:!0}),updateDurationLabel()}function updateDurationLabel(){const d=Math.max(0,(parseFloat($("trim-end").value)||0)-(parseFloat($("trim-start").value)||0)),el=$("trim-duration");el.textContent=d.toFixed(1)+" s",el.className=d>=5&&d<=20?"dur-ok":d>20?"dur-warn":"dur-bad"}["trim-start","trim-end"].forEach(id=>$(id).addEventListener("input",()=>{ws&&updateRegion()})),$("play-btn").addEventListener("click",()=>{ws&&ws.playPause()}),$("play-selection-btn").addEventListener("click",()=>{ws&&ws.play(parseFloat($("trim-start").value)||0,parseFloat($("trim-end").value)||ws.getDuration())}),$("auto-trim-btn").addEventListener("click",async()=>{if(!currentFileId){toast("No audio loaded","error");return}$("auto-trim-btn").disabled=!0,status("Finding best TTS reference segment\u2026");try{const r=await fetch("/api/auto-trim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId})});let d;if(r.ok)d=await r.json();else if(r.status===404||r.status===405)status("Backend auto trim unavailable; analysing audio in browser\u2026"),d=await clientAutoTrimBounds(currentFileId);else{const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText||"Auto trim failed")}$("trim-start").value=Number(d.start).toFixed(2),$("trim-end").value=Number(d.end).toFixed(2),ws&&updateRegion(),toast("Auto trim set: "+Number(d.duration).toFixed(1)+" s","success"),status(d.reason||"Auto trim ready")}catch(e){toast("Auto trim failed: "+e.message,"error"),status("Auto trim failed")}finally{$("auto-trim-btn").disabled=!1}});function loadAudioId(id,dur,opts={}){currentFileId=id,trimmedFileId=null,designedFileId=null,editingVoiceId=opts.editingVoiceId||null,editingVoicePath=opts.editingVoicePath||null,$("trim-start").value="0",$("trim-end").value=dur.toFixed(2),$("waveform-card").style.display="",initWaveSurfer(),ws.load("/api/audio/"+id),$("save-result").style.display="none",$("trim-audio").style.display="none",$("no-audio-hint").style.display="",editingVoiceId&&($("voice-id-input").value=editingVoiceId,$("voice-id-input").dispatchEvent(new Event("input")),$("transcript-area").value=opts.transcript||"",status("Editing existing voice: "+editingVoiceId))}const dropZone=$("drop-zone"),fileInput=$("file-input");dropZone.addEventListener("click",()=>fileInput.click()),dropZone.addEventListener("dragover",e=>{e.preventDefault(),dropZone.classList.add("drag-over")}),dropZone.addEventListener("dragleave",()=>dropZone.classList.remove("drag-over")),dropZone.addEventListener("drop",e=>{e.preventDefault(),dropZone.classList.remove("drag-over"),e.dataTransfer.files.length&&uploadFile(e.dataTransfer.files[0])}),fileInput.addEventListener("change",()=>{fileInput.files.length&&uploadFile(fileInput.files[0])});async function uploadFile(file){status("Uploading "+file.name+"\u2026");const fd=new FormData;fd.append("file",file);try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}const d=await r.json();loadAudioId(d.id,d.duration),status("Loaded: "+file.name+" ("+d.duration.toFixed(1)+" s)"),toast("File loaded","success")}catch(e){toast("Upload failed: "+e.message,"error"),status("Upload failed")}}async function loadLibraryVoiceAudio(v){const audioResp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!audioResp.ok){const e=await audioResp.json().catch(()=>({}));throw new Error(e.detail||audioResp.statusText)}const blob=await audioResp.blob(),ext=(v.file_type||"wav").toLowerCase(),fd=new FormData;fd.append("file",new File([blob],`${v.id}.${ext}`,{type:blob.type||"audio/wav"}));const upload=await fetch("/api/upload",{method:"POST",body:fd});if(!upload.ok){const e=await upload.json().catch(()=>({}));throw new Error(e.detail||upload.statusText)}const d=await upload.json();return{id:d.id,voice_id:v.id,duration:d.duration,transcript:v.transcript||"",file_type:ext,path:v.path}}$("yt-btn").addEventListener("click",()=>{const url=$("yt-url").value.trim();if(!url)return;$("yt-btn").disabled=!0,$("yt-progress").textContent="Starting download\u2026";const es=new EventSource("/api/download-yt?url="+encodeURIComponent(url));es.onmessage=e=>{const d=JSON.parse(e.data);d.error?(toast("Download failed: "+d.error,"error"),$("yt-progress").textContent=d.error,$("yt-btn").disabled=!1,es.close()):d.done?(es.close(),$("yt-btn").disabled=!1,$("yt-progress").textContent="Done!",loadAudioId(d.id,d.duration),toast("YouTube audio loaded","success")):($("yt-progress").textContent=d.msg||"",d.pct&&status("Downloading\u2026 "+d.pct+"%"))},es.onerror=()=>{es.close(),$("yt-btn").disabled=!1}});const RAW_MIC_CONSTRAINTS={echoCancellation:!1,noiseSuppression:!1,autoGainControl:!1};async function visibleMicrophoneCount(){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.enumerateDevices))return null;try{return(await navigator.mediaDevices.enumerateDevices()).filter(device=>device.kind==="audioinput").length}catch{return null}}async function microphoneErrorMessage(error){const name=(error==null?void 0:error.name)||"",message=(error==null?void 0:error.message)||"",lowerMessage=message.toLowerCase(),micCount=await visibleMicrophoneCount();return name==="NotFoundError"||lowerMessage.includes("requested device not found")?micCount===0?"No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.":"The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.":name==="NotAllowedError"||name==="PermissionDeniedError"?"Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.":name==="NotReadableError"?"The microphone is busy or unavailable. Close other apps using it, then try again.":name==="SecurityError"?"Microphone access requires localhost or HTTPS.":message||"Microphone failed."}async function requestMicrophoneStream(options={}){var _a2;if(!((_a2=navigator.mediaDevices)!=null&&_a2.getUserMedia))throw new Error("Microphone requires HTTPS. Open the app via https://... or access it on localhost.");if(!options.raw)return navigator.mediaDevices.getUserMedia({audio:!0});try{return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS})}catch(e){if((e==null?void 0:e.name)==="OverconstrainedError"||(e==null?void 0:e.name)==="NotFoundError")return navigator.mediaDevices.getUserMedia({audio:!0});throw e}}let _cloneMonState={stream:null,recordStream:null,audioCtx:null,sourceNode:null,gainNode:null,analyser:null,meterRaf:null,waveRing:null,monitoring:!1};function _cloneRenderMeter(level=0,db=-1/0,clipped=!1){const meter=$("clone-mic-meter");if(!meter)return;if(!meter.children.length)for(let i=0;i<18;i++){const b=document.createElement("div");b.className="bar",meter.appendChild(b)}const active=Math.round(Math.max(0,Math.min(1,level))*meter.children.length);[...meter.children].forEach((bar,i)=>{bar.className="bar",bar.style.height=7+Math.min(i,active)*1.55+"px",i-12&&i>11&&bar.classList.add("hot"),clipped&&i>14&&bar.classList.add("clip"))});const el=$("clone-db-readout");el&&(el.textContent=Number.isFinite(db)?db.toFixed(1)+" dB":"-\u221E dB")}function _cloneStartMeter(){if(!_cloneMonState.analyser)return;_cloneMonState.meterRaf&&cancelAnimationFrame(_cloneMonState.meterRaf);const data=new Float32Array(_cloneMonState.analyser.fftSize),canvas=$("clone-live-wave"),RING=300,ADD=10;_cloneMonState.waveRing=new Float32Array(RING);const tick=()=>{_cloneMonState.analyser.getFloatTimeDomainData(data);let sum=0,peak=0;for(const s of data)sum+=s*s,peak=Math.max(peak,Math.abs(s));const rms=Math.sqrt(sum/data.length),db=rms>0?20*Math.log10(rms):-1/0,level=Number.isFinite(db)?(db+60)/60:0;if(_cloneRenderMeter(level,db,peak>.98),canvas&&_cloneMonState.waveRing){const ring=_cloneMonState.waveRing;ring.copyWithin(0,ADD);for(let i=0;i.98?"#f38ba8":db>-12?"#f9e2af":"#a6e3a1",ctx.lineWidth=1.5;const mid=h/2;for(let i=0;i{try{n&&n.disconnect()}catch{}}),_cloneMonState.stream&&_cloneMonState.stream.getTracks().forEach(t=>t.stop()),_cloneMonState.recordStream&&_cloneMonState.recordStream.getTracks().forEach(t=>t.stop()),_cloneMonState.audioCtx&&_cloneMonState.audioCtx.close().catch(()=>{}),Object.assign(_cloneMonState,{stream:null,recordStream:null,sourceNode:null,gainNode:null,analyser:null,audioCtx:null,monitoring:!1,waveRing:null}),_cloneRenderMeter(0,-1/0,!1);const wc=$("clone-live-wave");wc&&wc.getContext("2d").clearRect(0,0,wc.width,wc.height),$("clone-monitor-btn")&&($("clone-monitor-btn").disabled=!1),$("clone-monitor-stop")&&($("clone-monitor-stop").disabled=!0)}(_v=$("clone-monitor-btn"))==null||_v.addEventListener("click",async()=>{try{await _cloneStartMonitor(),status("Mic level monitor active")}catch(e){const m=await microphoneErrorMessage(e);toast(m,"error")}}),(_w=$("clone-monitor-stop"))==null||_w.addEventListener("click",()=>{_cloneStopMonitor(),status("Mic level monitor stopped")}),(_x=$("clone-mic-gain"))==null||_x.addEventListener("input",()=>{const v=parseFloat($("clone-mic-gain").value)||0;$("clone-mic-gain-value")&&($("clone-mic-gain-value").textContent=v.toFixed(2)+"x"),_cloneMonState.gainNode&&(_cloneMonState.gainNode.gain.value=v)}),_cloneRenderMeter();let mediaRec=null,recChunks=[],recTimer=null,recSecs=0;$("rec-start-btn").addEventListener("click",async()=>{try{await _cloneStartMonitor(),recChunks=[],recSecs=0,$("rec-time").textContent="0:00",$("rec-indicator").classList.add("active"),$("rec-start-btn").disabled=!0,$("rec-stop-btn").disabled=!1,recTimer=setInterval(()=>{recSecs++,$("rec-time").textContent=Math.floor(recSecs/60)+":"+String(recSecs%60).padStart(2,"0")},1e3),mediaRec=new MediaRecorder(_cloneMonState.recordStream||_cloneMonState.stream,{audioBitsPerSecond:256e3}),mediaRec.ondataavailable=e=>{e.data.size&&recChunks.push(e.data)},mediaRec.onstop=async()=>{clearInterval(recTimer),$("rec-indicator").classList.remove("active");const blob=new Blob(recChunks,{type:mediaRec.mimeType||"audio/webm"}),ext=(mediaRec.mimeType||"").includes("ogg")?".ogg":".webm";_cloneStopMonitor(),await uploadFile(new File([blob],"recording"+ext,{type:blob.type}))},mediaRec.start(100),status("Recording\u2026")}catch(e){_cloneStopMonitor(),toast(await microphoneErrorMessage(e),"error")}}),$("rec-stop-btn").addEventListener("click",()=>{mediaRec&&mediaRec.state!=="inactive"&&mediaRec.stop(),$("rec-start-btn").disabled=!1,$("rec-stop-btn").disabled=!0}),$("trim-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;if(!currentFileId){toast("No audio loaded","error");return}try{const r=await fetch("/api/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:currentFileId,start:parseFloat($("trim-start").value)||0,end:parseFloat($("trim-end").value)||0})});if(!r.ok){const e=await r.json();throw new Error(e.detail)}const d=await r.json();trimmedFileId=d.id,designedFileId=null,$("trim-audio").src="/api/audio/"+d.id,$("trim-audio").style.display="",$("no-audio-hint").style.display="none",switchTab("save"),toast("Trim done","success"),(_b2=(_a2=$("transcript-area"))==null?void 0:_a2.closest(".card"))==null||_b2.scrollIntoView({behavior:"smooth",block:"center"}),(_c2=window._cloneAutoTranscribe)==null||_c2.call(window)}catch(e){toast("Trim failed: "+e.message,"error")}});const DESIGN_LANG_CODE={Auto:"EN",English:"EN",Chinese:"ZH",Japanese:"JA",Korean:"KO",German:"DE",French:"FR",Spanish:"ES",Italian:"IT",Portuguese:"PT",Russian:"RU"},DESIGN_GENDER_WORD={F:"female",M:"male",N:"neutral"},DESIGN_PRESET_KEY="vcf-design-presets",DESIGN_PRESET_SEEDED_KEY="vcf-design-presets-seeded-v2",DEFAULT_DESIGN_PRESETS={EN_M_Young_Energetic:{description:"Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.",sample_text:"Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.",language:"English",gender:"M"},EN_F_Warm_Narrator:{description:"Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.",sample_text:"The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.",language:"English",gender:"F"},DE_M_Elderly_Documentary:{description:"Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.",sample_text:"Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.",language:"German",gender:"M"},DE_F_Young_Friendly:{description:"Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.",sample_text:"Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.",language:"German",gender:"F"},EN_N_Old_Wise_Assistant:{description:"Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.",sample_text:"Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.",language:"English",gender:"N"}},QWEN_DESIGN_SAMPLES={"qwen-timbre-reuse":{title:"Qwen Timbre Reuse",summary:"Reference clip for designing a reusable teen character timbre.",description:"Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous",text:"H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?",language:"English",gender:"M"},"acoustic-sausage-announcer":{title:"Acoustic Attribute Control - British announcer",summary:"Fast, loud, articulate British male delivery with excitement and performative authority.",description:`gender: Male.
pitch: Low male pitch with significant upward inflections for emphasis and excitement.
speed: Fast-paced delivery with deliberate pauses for dramatic effect.
volume: Loud and projecting, increasing notably during moments of praise and announcements.
@@ -734,7 +738,7 @@ This warms each voice so the engine caches its .pt and first playback is instant
- `;const close=()=>{ov.remove(),document.removeEventListener("keydown",onKey)};function onKey(e){e.key==="Escape"&&close()}ov.addEventListener("click",e=>{e.target===ov&&close()}),ov.querySelector(".vl-bdc-cancel").addEventListener("click",close),document.addEventListener("keydown",onKey),ov.querySelector(".vl-bdc-go").addEventListener("click",async()=>{const goBtn=ov.querySelector(".vl-bdc-go"),cancelBtn=ov.querySelector(".vl-bdc-cancel");goBtn.disabled=cancelBtn.disabled=!0;let done=0,errors=0;await runPool(ids,async id=>{try{(await fetch(`/api/voice/${encodeURIComponent(id)}`,{method:"DELETE"})).ok?done++:errors++}catch{errors++}},5,n=>{goBtn.innerHTML=` Deleting ${n}/${ids.length}\u2026`}),close(),toast(`Deleted ${done} voice${done!==1?"s":""}${errors?` (${errors} errors)`:""}`,errors?"error":"success"),_bulkSelected.clear(),await loadVoiceLibrary()}),document.body.appendChild(ov),ov.querySelector(".vl-bdc-cancel").focus()}async function _bulkSetEnabled(ids,enabled){let done=0;for(const id of ids)await saveMeta(id,{enabled}).catch(()=>{}),done++;return done}function backendVoiceId(value){return typeof value=="string"?value:(value==null?void 0:value.id)||(value==null?void 0:value.voice)||(value==null?void 0:value.name)||JSON.stringify(value)}function shouldFilterBackendVoices(backend){return["voice_clone","streaming","nvidia_zeroshot","nvidia_flow"].includes(backend||"")}function shouldReplaceWithLibraryVoices(backend){return backend==="voice_design"}async function activeLibraryVoiceIds(){return _voices.length||await loadVoiceLibrary(),new Set((_voices||[]).filter(v=>v.enabled!==!1).map(v=>v.id))}function cleanReferenceText(text){return String(text||"").trim()}function selectedPreviewLibraryVoice(){var _a2;const id=((_a2=$("tts-voice-select"))==null?void 0:_a2.value)||"";return id?(_voices||[]).find(v=>v.id===id):null}function previewVoiceWarnings(v){var _a2;const warnings=[],backend=backendById(((_a2=$("tts-backend-select"))==null?void 0:_a2.value)||"");backend&&backend.id&&!["voice_clone","streaming","nvidia_zeroshot","nvidia_flow"].includes(backend.id)&&warnings.push(backend.id==="nvidia_magpie"?"NVIDIA Magpie uses fixed speaker voices, not saved WAV clone identity.":"This backend may follow style/model voice more than the saved WAV identity."),backend&&backend.id==="nvidia_zeroshot"&&v.duration&&(Number(v.duration)<3||Number(v.duration)>10)&&warnings.push("NVIDIA Zeroshot works best with a clear 3-10 second prompt."),backend&&backend.id==="nvidia_flow"&&!v.transcript&&warnings.push("NVIDIA Flow requires the exact saved reference transcript for this voice."),v.transcript||warnings.push("No reference transcript is saved; cloned identity is harder to judge."),v.duration&&(Number(v.duration)<3||Number(v.duration)>20)&&warnings.push("Reference clip length is outside the 3-20 second sweet spot."),v.needs_tts_restart&&warnings.push("This voice changed since the last backend refresh; restart or clear restart flags before judging it.");const healthWarnings=v.health&&Array.isArray(v.health.warnings)?v.health.warnings:[];return warnings.push(...healthWarnings.slice(0,3)),warnings}function updatePreviewVoiceMatchPanel(){const panel=$("preview-match-panel");if(!panel)return;const v=selectedPreviewLibraryVoice();if(!v){panel.hidden=!0;return}panel.hidden=!1;const lang=v.language||v.lang||(v.id||"").split("_")[0]||"-",gender=v.gender||(v.id||"").split("_")[1]||"-",db=fmtDbfs(v),dur=v.duration?fmtDuration(v.duration):"-";$("preview-match-title").textContent=v.id,$("preview-match-detail").textContent=`${lang} \xB7 ${gender} \xB7 ${dur} \xB7 ${db} dBFS`;const warnings=previewVoiceWarnings(v);$("preview-match-warning").textContent=warnings.length?warnings.join(" "):"For a fair voice match check, play the WAV and synthesize the exact saved reference text.";const transcript=cleanReferenceText(v.transcript||"");$("preview-match-transcript").textContent=transcript||"No reference text saved for this voice.",$("preview-ref-use-text").disabled=!transcript,$("preview-ref-synth").disabled=!transcript;const audio=$("preview-ref-audio"),expected=voiceFileUrl(v);audio.dataset.src!==expected&&(audio.pause(),audio.src=expected,audio.dataset.src=expected);const actionsEl=panel.querySelector(".preview-match-actions");let personaBtn=panel.querySelector(".preview-persona-btn");v.persona?personaBtn||(personaBtn=document.createElement("button"),personaBtn.className="btn-secondary preview-persona-btn",personaBtn.type="button",personaBtn.textContent="Rewrite with persona",actionsEl==null||actionsEl.appendChild(personaBtn),personaBtn.addEventListener("click",async()=>{const text=$("preview-text-area").value.trim();if(!text){toast("Enter text to rewrite","error");return}const lv=selectedPreviewLibraryVoice();if(!(lv!=null&&lv.persona)){toast("This voice has no persona","error");return}personaBtn.disabled=!0,personaBtn.textContent="Rewriting\u2026";try{const llmUrl=localStorage.getItem("refine-llm-url")||(_appSettings==null?void 0:_appSettings.llm_url)||"http://localhost:11434/v1",r=await fetch("/api/rewrite-with-persona",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text,persona:lv.persona,llm_url:llmUrl,model:(_appSettings==null?void 0:_appSettings.llm_model)||"",mode:"rewrite"})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();$("preview-text-area").value=d.text,toast("Text rewritten in persona style","success")}catch(e){toast("Persona rewrite failed: "+e.message,"error")}finally{personaBtn.disabled=!1,personaBtn.textContent="Rewrite with persona"}})):personaBtn==null||personaBtn.remove();const personaToggle=$("preview-persona-toggle");if(personaToggle){const label=personaToggle.closest("label");v.persona?(personaToggle.disabled=!1,label&&(label.title="Rewrite text through this voice's character persona before generating")):(personaToggle.checked=!1,personaToggle.disabled=!0,label&&(label.title="This voice has no character persona saved \u2014 set one on the Voice Inspector page first."))}}async function synthesizeSelectedReferenceText(){const v=selectedPreviewLibraryVoice();if(!v){toast("Select a library voice first","error");return}let text=cleanReferenceText(v.transcript||"");if(!text){toast("This voice has no reference text","error");return}if(v.needs_tts_restart){if(!confirm("This voice is marked as needing a TTS restart. If you already restarted the backend, clear the flag and synthesize anyway?"))return;await clearTtsRestartFlags(),v.needs_tts_restart=!1,updatePreviewVoiceMatchPanel()}const backend=$("tts-backend-select").value;if(!backend){toast("No available TTS backend","error");return}const btn=$("preview-ref-synth");btn.disabled=!0;try{$("preview-text-area").value=text;const source=await createTtsAudioSource(v.id,text,backend,$("preview-playback-mode").value,$("preview-style-instruction").value.trim());previewBlob=source.blob;const audio=$("preview-audio");audio.src=source.url,audio.style.display="",await audio.play(),$("save-preview-mp3-btn").disabled=!1,$("save-preview-btn").disabled=source.streaming,toast(source.streaming?"Reference text streaming":"Reference text synthesized","success")}catch(e){toast("Reference synthesis failed: "+e.message,"error")}finally{btn.disabled=!1}}$("fetch-tts-voices-btn").addEventListener("click",async()=>{var _a2;$("fetch-tts-voices-btn").disabled=!0;try{const backend=(_a2=$("tts-backend-select"))==null?void 0:_a2.value;if(!backend)throw new Error("No available TTS backend");let ids;if(shouldReplaceWithLibraryVoices(backend))_voices.length||await loadVoiceLibrary(),ids=(_voices||[]).filter(v=>v.enabled!==!1).map(v=>v.id);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;window.VoicePicker?(VoicePicker.upgrade("tts-voice-select"),VoicePicker.populate("tts-voice-select",ids),prev&&ids.includes(prev)&&VoicePicker.setValue("tts-voice-select",prev)):(sel.innerHTML='',ids.forEach(id=>{const o=document.createElement("option");o.value=o.textContent=id,sel.appendChild(o)}),prev&&ids.includes(prev)&&(sel.value=prev)),updatePreviewVoiceMatchPanel();const suffix=shouldFilterBackendVoices(backend)||shouldReplaceWithLibraryVoices(backend)?" active voices":" voices";toast("Fetched "+ids.length+suffix,"success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{$("fetch-tts-voices-btn").disabled=!1}}),$("tts-backend-select").addEventListener("change",()=>{const sel=$("tts-voice-select");sel.innerHTML='',updateBackendHelp(),updatePreviewVoiceMatchPanel(),previewBlob=null,$("save-preview-mp3-btn").disabled=!0,$("save-preview-btn").disabled=!0}),$("tts-voice-select").addEventListener("change",updatePreviewVoiceMatchPanel),$("preview-ref-play").addEventListener("click",async()=>{updatePreviewVoiceMatchPanel();const audio=$("preview-ref-audio");try{await audio.play()}catch(e){toast("Reference playback failed: "+e.message,"error")}}),$("preview-ref-use-text").addEventListener("click",()=>{const v=selectedPreviewLibraryVoice(),text=cleanReferenceText((v==null?void 0:v.transcript)||"");if(!text){toast("This voice has no reference text","error");return}$("preview-text-area").value=text,toast("Reference text copied to target text","success")}),$("preview-ref-synth").addEventListener("click",synthesizeSelectedReferenceText);let _ttsStreamHealth=null;function effectiveTtsPlaybackMode(override="settings"){return override&&override!=="settings"?override:_appSettings.tts_stream_mode||"auto"}async function isTtsStreamAvailable(force=!1){if(_ttsStreamHealth&&!force)return _ttsStreamHealth.ok;try{return _ttsStreamHealth=await fetch("/api/tts-stream-health").then(r=>r.json()),!!_ttsStreamHealth.ok}catch{return _ttsStreamHealth={ok:!1},!1}}async function createTtsStreamUrl(voice,text,instruct=""){if(!await isTtsStreamAvailable())throw new Error("streaming backend unavailable");const r=await fetch("/api/tts-stream-session",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text,voice,instruct})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}return(await r.json()).url}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))}}function _ttsBackendForVoice(voiceId,fallbackBackend){if(!voiceId||voiceId==="me")return fallbackBackend;const v=(window._voices||[]).find(x=>x.id===voiceId);return v&&(v.origin==="designed"||!v.has_ref)?"voice_design":fallbackBackend}async function fetchTtsPreviewBlob(voice,text,responseFormat="wav",instruct="",backend="voice_clone",applyPersona=!1,extra=null){const body={text,voice,response_format:responseFormat,instruct,backend};applyPersona&&(body.apply_persona=!0),extra&&typeof extra=="object"&&Object.assign(body,extra);const r=await _ttsPreviewFetchWithRetry(body);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const blob=await r.blob();if(responseFormat==="wav"){const v=(window._voices||[]).find(x=>x.id===voice),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{}}return blob}function _textWords(s){return String(s||"").toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g,"").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean)}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;return wb.forEach(w=>{const c=counts.get(w);c&&(overlap++,counts.set(w,c-1))}),2*overlap/(wa.length+wb.length)}async function _voiceRoundtripCheck(voiceId,text,backend,instruct=""){const blob=await fetchTtsPreviewBlob(voiceId,text,"wav",instruct,backend),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=!1,extra=null){const mode=effectiveTtsPlaybackMode(modeOverride);if(backend!=="streaming"||mode==="buffered"){const blob=await fetchTtsPreviewBlob(voice,text,"wav",instruct,backend,applyPersona,extra);return{url:URL.createObjectURL(blob),blob,streaming:!1,label:"buffered"}}try{return{url:await createTtsStreamUrl(voice,text,instruct),blob:null,streaming:!0,label:"streaming"}}catch(e){if(mode==="streaming")throw e;const blob=await fetchTtsPreviewBlob(voice,text,"wav",instruct,backend,applyPersona,extra);return{url:URL.createObjectURL(blob),blob,streaming:!1,label:"buffered"}}}let previewBlob=null;const PREVIEW_SAMPLE_TEXT="Hello! This is a voice preview from TTS Voice Creator - Clone and Design.";$("preview-text-area").addEventListener("focus",()=>{$("preview-text-area").value===PREVIEW_SAMPLE_TEXT&&($("preview-text-area").value="")},{once:!0});function _onPreviewGenerated(source,voice,text,backend,instruct){typeof effectsSourceBlob!="undefined"&&(window._effectsSourceBlob=null),window._effectsSynthArgs={voice,text,instruct:instruct||"",backend};const ea=$("effects-apply-btn");ea&&(ea.disabled=!1);const ap=$("add-to-playlist-btn");ap&&source.blob&&(ap.disabled=!1),typeof historyPush=="function"&&source.blob&&historyPush(voice,text,backend,source.blob,source.url)}const _TRYOUT_SPEED_KEY="ttsvc_tryout_native_speed";(function(){const saved=localStorage.getItem(_TRYOUT_SPEED_KEY);if(saved){const el=$("preview-native-speed");el&&(el.value=saved)}})(),(_R=$("preview-native-speed"))==null||_R.addEventListener("change",function(){localStorage.setItem(_TRYOUT_SPEED_KEY,this.value)}),$("preview-btn").addEventListener("click",async()=>{var _a2,_b2,_c2;const voice=$("tts-voice-select").value,backend=$("tts-backend-select").value,text=$("preview-text-area").value.trim(),instruct=$("preview-style-instruction").value.trim(),applyPersona=((_a2=$("preview-persona-toggle"))==null?void 0:_a2.checked)||!1;if(!backend){toast("No available TTS backend","error");return}if(!voice){toast("Select a TTS voice","error");return}if(!text){toast("Enter preview text","error");return}$("preview-btn").disabled=!0,$("save-preview-mp3-btn").disabled=!0,$("save-preview-btn").disabled=!0,$("add-to-playlist-btn")&&($("add-to-playlist-btn").disabled=!0),$("effects-apply-btn")&&($("effects-apply-btn").disabled=!0);const _nspd=parseFloat((_b2=$("preview-native-speed"))==null?void 0:_b2.value),_extra=!isNaN(_nspd)&&_nspd!==1?{speed:_nspd}:null;try{const audio=$("preview-audio"),source=((_c2=$("preview-chunked-toggle"))==null?void 0:_c2.checked)&&text.length>200&&typeof generateChunkedTts=="function"?await generateChunkedTts(voice,text,backend,instruct,_extra,applyPersona):await createTtsAudioSource(voice,text,backend,$("preview-playback-mode").value,instruct,applyPersona,_extra);previewBlob=source.blob,window._previewVoice=voice,window._previewBackend=backend,window._previewText=text,audio.src=source.url,audio.style.display="",await audio.play(),$("save-preview-mp3-btn").disabled=!1,$("save-preview-btn").disabled=source.streaming,_onPreviewGenerated(source,voice,text,backend,instruct),toast(source.streaming?"Streaming preview playing":source.label==="chunked"?`Chunked (${text.length} chars) playing`:"Preview playing","success")}catch(e){toast("TTS failed: "+e.message,"error")}finally{$("preview-btn").disabled=!1}}),$("save-preview-mp3-btn").addEventListener("click",async()=>{const voice=$("tts-voice-select").value,backend=$("tts-backend-select").value,text=$("preview-text-area").value.trim(),instruct=$("preview-style-instruction").value.trim();if(!backend){toast("No available TTS backend","error");return}if(!voice||!text)return;const btn=$("save-preview-mp3-btn");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(voice,text,"mp3",instruct,backend),a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=(voice||"preview")+"_preview.mp3",a.click(),toast("MP3 saved","success")}catch(e){toast("MP3 save failed: "+e.message,"error")}finally{btn.disabled=!1}}),$("save-preview-btn").addEventListener("click",()=>{if(!previewBlob)return;const a=document.createElement("a");a.href=URL.createObjectURL(previewBlob),a.download=($("tts-voice-select").value||"preview")+"_preview.wav",a.click()});const PERF_HISTORY_KEY="vcf-perf-history",PERF_HISTORY_MAX=50,PERF_HISTORY_SORT={key:"ts",dir:"desc"};function perfHistoryLoad(){try{return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY)||"[]")}catch{return[]}}function perfHistorySave(entries){try{localStorage.setItem(PERF_HISTORY_KEY,JSON.stringify(entries.slice(-PERF_HISTORY_MAX)))}catch{}}function perfHistoryAdd(entry){const h=perfHistoryLoad();h.push(entry),perfHistorySave(h)}function perfSparklineSvg(rtfValues){if(!rtfValues.length)return"";const W=120,H=32,PAD=2,barW=Math.max(4,Math.floor((W-PAD*2)/rtfValues.length)-1),maxV=Math.max(...rtfValues,1),bars=rtfValues.map((v,i)=>{const bh=Math.max(3,Math.round(v/maxV*(H-PAD*2))),x=PAD+i*(barW+1),y=H-PAD-bh,col=v<1?"var(--green)":"var(--yellow)";return``}).join("");return``}function perfHistoryVoiceLookup(voiceId){return(Array.isArray(window._voices)&&window._voices.length?window._voices:typeof _voices!="undefined"&&Array.isArray(_voices)?_voices:[]).find(v=>v&&(v.id===voiceId||v.name===voiceId||v.voice_id===voiceId))||null}function perfHistoryVoiceMeta(entry){var _a2,_b2,_c2;const voice=(entry==null?void 0:entry.voice)||"",saved=(entry==null?void 0:entry.voiceMeta)||{},lib=perfHistoryVoiceLookup(voice)||{},language=saved.lang||saved.language||(entry==null?void 0:entry.lang)||(entry==null?void 0:entry.language)||lib.lang||lib.language||"",gender=String(saved.gender||(entry==null?void 0:entry.gender)||lib.gender||"").trim().toUpperCase().charAt(0);return{id:voice,label:saved.label||saved.display_name||(entry==null?void 0:entry.voiceLabel)||lib.display_name||lib.name||voice,lang:language,gender:["F","M","N"].includes(gender)?gender:"",flag:saved.flag||(entry==null?void 0:entry.flag)||lib.flag||"",avatar:saved.avatar||(entry==null?void 0:entry.avatar)||lib.avatar||"",hasPicture:!!((_c2=(_b2=(_a2=saved.has_picture)!=null?_a2:saved.hasPicture)!=null?_b2:entry==null?void 0:entry.hasPicture)!=null?_c2:lib.has_picture)}}function perfHistoryExtraFields(voice,backend){const lib=perfHistoryVoiceLookup(voice)||{};return{voiceMeta:{label:lib.display_name||lib.name||voice,lang:lib.lang||lib.language||"",gender:lib.gender||"",flag:lib.flag||"",avatar:lib.avatar||"",has_picture:!!lib.has_picture},device:typeof backendComputeDevice=="function"?backendComputeDevice(backend):""}}function perfHistoryGenderLabel(gender){return{F:"Female",M:"Male",N:"Diverse"}[gender]||""}function perfHistoryDeviceLabel(entry){return(entry==null?void 0:entry.device)||(typeof backendComputeDevice=="function"?backendComputeDevice((entry==null?void 0:entry.backend)||""):"")||"Unknown"}function perfHistoryDeviceHtml(entry){const label=perfHistoryDeviceLabel(entry),cls=typeof backendComputeDeviceClass=="function"?backendComputeDeviceClass((entry==null?void 0:entry.backend)||""):label.toLowerCase().includes("gpu")?"gpu":label.toLowerCase().includes("cpu")?"cpu":"";return`${escHtml(label)}`}function perfHistoryAvatarHtml(entry){var _a2;const meta=perfHistoryVoiceMeta(entry),title=meta.label||meta.id||"Voice";if(meta.hasPicture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(meta.avatar,24):null;if(icon)return`${icon.replace(/vp-avatar/g,"perf-history-avatar-icon")}`;const color=typeof avatarColor=="function"?avatarColor(meta.lang||meta.id||title):"#6b7280",init=((_a2=(title||"?").trim()[0])==null?void 0:_a2.toUpperCase())||"?";return`${escHtml(init)}`}function perfHistorySortValue(entry,key){switch(key){case"backend":return String(entry.backend||"").toLowerCase();case"language":return String(perfHistoryVoiceMeta(entry).lang||"").toLowerCase();case"gender":return String(perfHistoryGenderLabel(perfHistoryVoiceMeta(entry).gender)||"").toLowerCase();case"voice":return String(entry.voice||"").toLowerCase();case"device":return String(perfHistoryDeviceLabel(entry)||"").toLowerCase();case"avgLatencyMs":return Number(entry.avgLatencyMs);case"minLatencyMs":return Number(entry.minLatencyMs);case"avgRtf":return Number(entry.avgRtf);case"ts":default:return Number(entry.ts)}}function perfHistoryCompare(a,b){const av=perfHistorySortValue(a,PERF_HISTORY_SORT.key),bv=perfHistorySortValue(b,PERF_HISTORY_SORT.key);let result=0;if(typeof av=="string"||typeof bv=="string")result=String(av).localeCompare(String(bv),void 0,{numeric:!0,sensitivity:"base"});else{const an=Number.isFinite(av)?av:-1/0,bn=Number.isFinite(bv)?bv:-1/0;result=an===bn?0:an-bn}return PERF_HISTORY_SORT.dir==="asc"?result:-result}function perfHistoryHeadButton(key,label){const active=PERF_HISTORY_SORT.key===key,icon=active?PERF_HISTORY_SORT.dir==="asc"?"mdi-arrow-up":"mdi-arrow-down":"mdi-swap-vertical";return`