Fix Fish-Speech emotion tags, wire book context into portraits, add emotion controls app-wide (v1.20.5)
Fish-Speech emotion tags were silently ignored on non-English books: per-line emotions are LLM-generated in the book's own language, but Fish-Speech only recognizes English [tag] markers, and a double-tagging bug was stacking a broken server-derived tag on top of the client's own. Added a DE->EN translation table and removed the double-tagging. Also wires the existing book-profile context and race_species field into character portrait prompts (previously only used for voice design), adds a recast-until-threshold loop for casting, and adds backend-aware emotion quick-picks to Read Aloud, Try a Voice, and Conversation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ac115b25b9
commit
5fecbf06d4
113
CHANGELOG.md
113
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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
63
static/dist/main.min.js
vendored
63
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.18.11">
|
||||
<meta name="app-version" content="1.20.5">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.18.11">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.20.5">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.18.11"></script>
|
||||
<script src="/static/loader.js?v=1.20.5"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4112,6 +4112,13 @@ ABSOLUTE REGELN:
|
||||
{ icon: 'mdi-account-question-outline', label: 'Identify unknown characters', title: 'Re-scan only the unknown segments with the current prompt', onClick: runIdentifyUnknown },
|
||||
{ icon: 'mdi-shield-check-outline', label: 'Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass },
|
||||
{ icon: 'mdi-account-search-outline', label: 'Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass },
|
||||
{ icon: 'mdi-repeat', label: 'Run until < N unknown…', title: 'Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls', onClick: () => {
|
||||
const input = window.prompt('Stop once fewer than this many Unknown speakers remain:', '10');
|
||||
if (input == null) return;
|
||||
const threshold = parseInt(input, 10);
|
||||
if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; }
|
||||
audiobookRecastUntilThreshold(threshold);
|
||||
} },
|
||||
]));
|
||||
castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
|
||||
{ icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll },
|
||||
@ -4465,48 +4472,89 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
|
||||
_audiobook.running = false;
|
||||
_audiobook.abort = null;
|
||||
}
|
||||
if (pendingReplacements.size) {
|
||||
[...pendingReplacements.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([idx, repl]) => segs.splice(idx, 1, ...repl));
|
||||
}
|
||||
// Splicing multi-segment replacements back in at scattered indices can
|
||||
// reintroduce a passage that a neighbouring recast group's overlapping
|
||||
// context window already restated correctly a few segments earlier —
|
||||
// confirmed live (a paragraph appearing twice with a stray leading quote
|
||||
// mark on the second copy, separated by an unrelated dialogue block).
|
||||
const { segments: deduped, removed: dupRemoved } = _audiobookDedupNearbyDuplicates(segs);
|
||||
if (dupRemoved) {
|
||||
segs.splice(0, segs.length, ...deduped);
|
||||
view.note(`Removed ${dupRemoved} duplicated line${dupRemoved !== 1 ? 's' : ''} introduced by this verification pass.`);
|
||||
}
|
||||
// Everything below only ever ran when nothing above threw — but nothing
|
||||
// here was itself guarded, so any failure in this post-processing (dedup,
|
||||
// rollback check, saving the draft) left the UI permanently stuck showing
|
||||
// "Stop Casting" with no error and no way out, even though the LLM work
|
||||
// itself had already genuinely finished (GPU load back to idle). Wrapping
|
||||
// it means a failure here still reaches a terminal view.done()/view.note()
|
||||
// call instead of silently hanging forever.
|
||||
try {
|
||||
if (pendingReplacements.size) {
|
||||
[...pendingReplacements.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([idx, repl]) => segs.splice(idx, 1, ...repl));
|
||||
}
|
||||
// Splicing multi-segment replacements back in at scattered indices can
|
||||
// reintroduce a passage that a neighbouring recast group's overlapping
|
||||
// context window already restated correctly a few segments earlier —
|
||||
// confirmed live (a paragraph appearing twice with a stray leading quote
|
||||
// mark on the second copy, separated by an unrelated dialogue block).
|
||||
const { segments: deduped, removed: dupRemoved } = _audiobookDedupNearbyDuplicates(segs);
|
||||
if (dupRemoved) {
|
||||
segs.splice(0, segs.length, ...deduped);
|
||||
view.note(`Removed ${dupRemoved} duplicated line${dupRemoved !== 1 ? 's' : ''} introduced by this verification pass.`);
|
||||
}
|
||||
|
||||
const afterUnknownCount = countUnknownDialogue(segs);
|
||||
if (!_audiobook.cancel && afterUnknownCount > beforeUnknownCount) {
|
||||
segs.splice(0, segs.length, ...originalSegments);
|
||||
view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`);
|
||||
toast('Quality run rolled back because it increased Unknown speakers.', 'error');
|
||||
}
|
||||
|
||||
// Recast-unknown only fixes speakers on already-cast segments — it never adds
|
||||
// new passages, so the original cast's real done/total must carry through
|
||||
// unchanged rather than being overwritten with a fake "100% done" value.
|
||||
const _rcDone = _audiobook.completedChunks || segs.length;
|
||||
const _rcTotal = _audiobook.completedTotal || _rcDone;
|
||||
const afterUnknownCount = countUnknownDialogue(segs);
|
||||
if (!_audiobook.cancel && afterUnknownCount > beforeUnknownCount) {
|
||||
segs.splice(0, segs.length, ...originalSegments);
|
||||
view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`);
|
||||
toast('Quality run rolled back because it increased Unknown speakers.', 'error');
|
||||
}
|
||||
|
||||
// Recast-unknown only fixes speakers on already-cast segments — it never adds
|
||||
// new passages, so the original cast's real done/total must carry through
|
||||
// unchanged rather than being overwritten with a fake "100% done" value.
|
||||
const _rcDone = _audiobook.completedChunks || segs.length;
|
||||
const _rcTotal = _audiobook.completedTotal || _rcDone;
|
||||
|
||||
if (_audiobook.cancel) {
|
||||
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
|
||||
view.done({ stopped: true, message: 'Character definition stopped. Existing cast preserved.' });
|
||||
toast('Character definition stopped. Existing cast preserved.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_audiobook.cancel) {
|
||||
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
|
||||
view.done({ stopped: true, message: 'Character definition stopped. Existing cast preserved.' });
|
||||
toast('Character definition stopped. Existing cast preserved.', 'info');
|
||||
return;
|
||||
const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`;
|
||||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||||
} catch (e) {
|
||||
console.error('[audiobookRecastUnknown] post-processing failed', e);
|
||||
view.done({ stopped: true, message: 'Finished checking speakers, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' });
|
||||
toast('Casting finished but cleanup failed: ' + e.message, 'error');
|
||||
}
|
||||
|
||||
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
|
||||
const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`;
|
||||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||||
}
|
||||
|
||||
// Repeatedly runs the recast-unknown + narrator-verify pass (the exact same
|
||||
// { includeNarrator: true } combination used manually — via console — for a
|
||||
// full autonomous end-to-end run this app was already driven through once)
|
||||
// until the number of Unknown-speaker dialogue lines drops below `threshold`
|
||||
// or two consecutive passes make no further progress (LLM/GPU contention or a
|
||||
// genuinely irreducible residue of ambiguous lines — either way, more passes
|
||||
// won't help). User-requested follow-up: this used to only be doable by
|
||||
// calling audiobookRecastUnknown() directly from the browser console.
|
||||
async function audiobookRecastUntilThreshold(threshold = 10, maxPasses = 8) {
|
||||
if (_audiobook.running) { toast('Casting is already running', 'error'); return; }
|
||||
const segs = _audiobook.segments;
|
||||
if (!segs || !segs.length) { toast('No cast to check', 'error'); return; }
|
||||
const countUnknown = () => (segs || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
|
||||
let prev = countUnknown();
|
||||
if (prev <= threshold) { toast(`Already at ${prev} unknown speakers (target: <${threshold})`, 'success'); return; }
|
||||
toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})…`, 'info');
|
||||
for (let pass = 1; pass <= maxPasses; pass++) {
|
||||
await audiobookRecastUnknown(null, null, { includeNarrator: true });
|
||||
if (_audiobook.cancel) { toast('Stopped — cancelled mid-pass.', 'info'); return; }
|
||||
const now = countUnknown();
|
||||
if (now <= threshold) { toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass !== 1 ? 'es' : ''}).`, 'success'); return; }
|
||||
if (now >= prev) { toast(`Stopped after ${pass} pass${pass !== 1 ? 'es' : ''}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`, 'error'); return; }
|
||||
prev = now;
|
||||
}
|
||||
toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`, 'error');
|
||||
}
|
||||
window.audiobookRecastUntilThreshold = audiobookRecastUntilThreshold;
|
||||
|
||||
async function audiobookOpenCastView() {
|
||||
if (_audiobook.running) return;
|
||||
const text = audiobookScopeText();
|
||||
@ -4942,32 +4990,45 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
|
||||
return;
|
||||
}
|
||||
|
||||
// allSegments is declared const further up — replace its contents in
|
||||
// place rather than rebinding, since it's captured by closures above.
|
||||
// Fix stray quote-mark boundaries BEFORE merging same-speaker segments,
|
||||
// so a narration row that's about to be merged away doesn't carry a
|
||||
// misplaced guillemet into its neighbour first.
|
||||
const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments);
|
||||
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_quoteFixedSegs);
|
||||
allSegments.length = 0;
|
||||
allSegments.push(..._mergedSegs);
|
||||
_audiobook.segments = allSegments;
|
||||
_audiobook.lastText = text;
|
||||
_audiobook.roster = roster;
|
||||
_audiobook.narratedPassages = narrationOnly;
|
||||
_audiobook.degraded = degraded;
|
||||
_audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length;
|
||||
_audiobook.completedTotal = chunks.length;
|
||||
_abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length);
|
||||
audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB
|
||||
// Everything below only ever ran when nothing above threw, but wasn't
|
||||
// itself guarded — a failure here (e.g. in the quote-fix/merge passes or
|
||||
// saving the draft) used to leave the panel stuck showing "Stop Casting"
|
||||
// forever, with no error and the LLM work already genuinely finished
|
||||
// (confirmed live: GPU load back to idle, button never resets). Wrapping
|
||||
// it means a failure here still reaches a terminal view.done() instead of
|
||||
// silently hanging.
|
||||
try {
|
||||
// allSegments is declared const further up — replace its contents in
|
||||
// place rather than rebinding, since it's captured by closures above.
|
||||
// Fix stray quote-mark boundaries BEFORE merging same-speaker segments,
|
||||
// so a narration row that's about to be merged away doesn't carry a
|
||||
// misplaced guillemet into its neighbour first.
|
||||
const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments);
|
||||
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_quoteFixedSegs);
|
||||
allSegments.length = 0;
|
||||
allSegments.push(..._mergedSegs);
|
||||
_audiobook.segments = allSegments;
|
||||
_audiobook.lastText = text;
|
||||
_audiobook.roster = roster;
|
||||
_audiobook.narratedPassages = narrationOnly;
|
||||
_audiobook.degraded = degraded;
|
||||
_audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length;
|
||||
_audiobook.completedTotal = chunks.length;
|
||||
_abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length);
|
||||
audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB
|
||||
|
||||
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
|
||||
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
|
||||
|
||||
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
|
||||
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||||
const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length;
|
||||
const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
|
||||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||||
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
|
||||
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||||
const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length;
|
||||
const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
|
||||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||||
} catch (e) {
|
||||
console.error('[audiobookCast] post-processing failed', e);
|
||||
view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' });
|
||||
toast('Casting finished but cleanup failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Editable attribution preview ─────────────────────────────────────────────
|
||||
@ -5141,9 +5202,154 @@ function audiobookIsChapter(line) {
|
||||
if (line.type === 'act' || line.type === 'scene') return true;
|
||||
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
|
||||
if (!t || t.length > 60) return false;
|
||||
return /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t);
|
||||
// OCR'd headings (chapter titles baked into the PDF as images, recovered via
|
||||
// Tesseract) commonly lead with the chapter number rather than the keyword —
|
||||
// "1.Kapitel", "I. Kapitel", "Kapitel 1" — so the keyword can't be required
|
||||
// at position 0. Allow an optional leading number/roman numeral (with an
|
||||
// optional trailing period, and no requirement for a space before the
|
||||
// keyword, since OCR frequently drops the space in "1.Kapitel").
|
||||
// A lone leading letter from the roman-numeral set is also just the first
|
||||
// letter of an ordinary word — "C" (100) is real Roman notation, but it's
|
||||
// also literally how "Chapter" starts. Confirmed live: without the
|
||||
// lookahead requiring whitespace/period/end right after the numeral run,
|
||||
// this silently ate the "C" off "Chapter 1: The Beginning", leaving
|
||||
// "hapter 1..." — which then obviously failed the keyword match entirely.
|
||||
const NUM = /^(?:[ivxlcdm]+(?=[.\s]|$)|\d{1,3})\.?\s*/i;
|
||||
const rest = t.replace(NUM, '');
|
||||
const KEYWORD = /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i;
|
||||
if (!KEYWORD.test(rest)) return false;
|
||||
// "part"/"book"/"teil" ("teil" = German "part") are common ordinary words,
|
||||
// not just chapter markers — "Teil des Grundes war unklar." ("Part of the
|
||||
// reason was unclear.") is a perfectly normal, short narration sentence
|
||||
// that starts with the same word. Requiring the keyword at position 0 was
|
||||
// never enough on its own; this used to only affect export file-splitting
|
||||
// (harmless — one fewer bucket boundary), but wiring the same check into
|
||||
// the Stage view's rendering made it consequential: an ordinary sentence
|
||||
// getting misdetected now silently replaces real paragraph text with a
|
||||
// thin chapter-marker line (confirmed live: "lots of empty pages" in the
|
||||
// A4 pagination view, from real narration vanishing this way). Whatever
|
||||
// follows the keyword must therefore look like part of a heading — empty,
|
||||
// a bare trailing number/numeral, or a colon/dash-separated subtitle —
|
||||
// never a normal grammatical continuation.
|
||||
const afterKeyword = rest.replace(KEYWORD, '').trim();
|
||||
if (afterKeyword === '') return true;
|
||||
if (/^[ivxlcdm\d]{1,6}\.?$/i.test(afterKeyword)) return true;
|
||||
return /^[ivxlcdm\d]{0,6}\.?\s*[-:–—]\s*.{1,40}$/i.test(afterKeyword);
|
||||
}
|
||||
|
||||
// ── Pause settings (paragraph/chapter gaps + optional chapter sound) ────────
|
||||
// Persisted per-browser (not per-book) via localStorage — these are pacing
|
||||
// preferences, not book content, so they should carry over between exports.
|
||||
|
||||
function _abPauseSettings() {
|
||||
const num = (key, def) => {
|
||||
let v;
|
||||
try { v = parseFloat(localStorage.getItem(key)); } catch (_) { v = NaN; }
|
||||
return Number.isFinite(v) && v >= 0 ? v : def;
|
||||
};
|
||||
return {
|
||||
paragraphMs: num('ttsvc_ab_pause_paragraph_s', 2) * 1000,
|
||||
chapterMs: num('ttsvc_ab_pause_chapter_s', 4) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function _abSetPauseSetting(key, seconds) {
|
||||
try { localStorage.setItem(key, String(seconds)); } catch (_) {}
|
||||
}
|
||||
|
||||
function _abChapterSfxDataUrl() {
|
||||
try { return localStorage.getItem('ttsvc_ab_chapter_sfx') || null; } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function _abSetChapterSfx(dataUrl) {
|
||||
try {
|
||||
if (dataUrl) localStorage.setItem('ttsvc_ab_chapter_sfx', dataUrl);
|
||||
else localStorage.removeItem('ttsvc_ab_chapter_sfx');
|
||||
} catch (e) { toast('Could not save chapter sound — file may be too large', 'error'); }
|
||||
}
|
||||
|
||||
// Decodes the user's custom chapter-transition sound (any format the browser
|
||||
// can decode) and re-renders it at the EXACT sample rate/channels the
|
||||
// narration clips use — mergeWavBlobs concatenates raw PCM bytes assuming a
|
||||
// single shared format, so a mismatched sample rate here would play back
|
||||
// pitched/sped-up or corrupt the merge, not just sound wrong.
|
||||
async function _abChapterSfxWavBlob(fmt) {
|
||||
const dataUrl = _abChapterSfxDataUrl();
|
||||
if (!dataUrl) return null;
|
||||
try {
|
||||
const resp = await fetch(dataUrl);
|
||||
const arrayBuf = await resp.arrayBuffer();
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||||
const tmpCtx = new AC();
|
||||
const decoded = await tmpCtx.decodeAudioData(arrayBuf.slice(0));
|
||||
if (typeof tmpCtx.close === 'function') tmpCtx.close();
|
||||
const offline = new OAC(fmt.channels, Math.max(1, Math.ceil(decoded.duration * fmt.sampleRate)), fmt.sampleRate);
|
||||
const src = offline.createBufferSource();
|
||||
src.buffer = decoded;
|
||||
src.connect(offline.destination);
|
||||
src.start();
|
||||
const rendered = await offline.startRendering();
|
||||
const frames = rendered.length;
|
||||
const pcm = new Int16Array(frames * fmt.channels);
|
||||
for (let ch = 0; ch < fmt.channels; ch++) {
|
||||
const data = rendered.getChannelData(Math.min(ch, rendered.numberOfChannels - 1));
|
||||
for (let i = 0; i < frames; i++) {
|
||||
const s = Math.max(-1, Math.min(1, data[i]));
|
||||
pcm[i * fmt.channels + ch] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
}
|
||||
return _buildWavBlob(new Uint8Array(pcm.buffer), fmt);
|
||||
} catch (e) {
|
||||
console.error('[audiobook export] chapter sound decode failed', e);
|
||||
toast('Could not use the custom chapter sound — check the audio file', 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Wires the Perform & Export pause-settings fields to localStorage. Called
|
||||
// every time Studio's phase 4 is shown (studio.js) — guarded so listeners
|
||||
// are only attached once, since the fields themselves are never reparented
|
||||
// away (unlike the borrowed Stage/tone-warning elements).
|
||||
let _abPauseUIInited = false;
|
||||
function _abInitPauseUI() {
|
||||
const pEl = $('stu-pause-paragraph'), cEl = $('stu-pause-chapter');
|
||||
const sfxFile = $('stu-chapter-sfx-file'), sfxClear = $('stu-chapter-sfx-clear'), sfxCurrent = $('stu-chapter-sfx-current');
|
||||
if (!pEl || !cEl) return;
|
||||
const s = _abPauseSettings();
|
||||
pEl.value = s.paragraphMs / 1000;
|
||||
cEl.value = s.chapterMs / 1000;
|
||||
if (sfxCurrent) sfxCurrent.textContent = _abChapterSfxDataUrl() ? 'Custom sound set' : 'No custom sound';
|
||||
if (_abPauseUIInited) return;
|
||||
_abPauseUIInited = true;
|
||||
pEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_paragraph_s', Math.max(0, parseFloat(pEl.value) || 0)));
|
||||
cEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_chapter_s', Math.max(0, parseFloat(cEl.value) || 0)));
|
||||
sfxClear?.addEventListener('click', () => {
|
||||
_abSetChapterSfx(null);
|
||||
if (sfxFile) sfxFile.value = '';
|
||||
if (sfxCurrent) sfxCurrent.textContent = 'No custom sound';
|
||||
});
|
||||
sfxFile?.addEventListener('change', () => {
|
||||
const file = sfxFile.files && sfxFile.files[0];
|
||||
if (!file) return;
|
||||
// Capped well under localStorage's ~5-10MB origin quota (base64 inflates
|
||||
// size by ~33%) — a chapter chime doesn't need to be a whole song.
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast('Chapter sound is too large (max 2 MB) — trim it to a short chime/sting', 'error');
|
||||
sfxFile.value = '';
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
_abSetChapterSfx(reader.result);
|
||||
if (sfxCurrent) sfxCurrent.textContent = file.name;
|
||||
};
|
||||
reader.onerror = () => toast('Could not read that audio file', 'error');
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
window._abInitPauseUI = _abInitPauseUI;
|
||||
|
||||
function audiobookLineVoice(l) {
|
||||
if (l.type === 'dialog') {
|
||||
const c = rehState.cast[l.speaker] || {};
|
||||
@ -5220,7 +5426,29 @@ async function audiobookExport() {
|
||||
blob = await _lineAudioCacheGet(book, cacheKey);
|
||||
}
|
||||
if (!blob) {
|
||||
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
|
||||
// fetchTtsPreviewBlob's own retry (_ttsPreviewFetchWithRetry) only
|
||||
// covers a connection-level failure — fetch() doesn't throw on a
|
||||
// non-2xx response, so a backend hiccup (confirmed live: transient
|
||||
// 500s during the first ~50 lines, most likely GPU/engine warm-up
|
||||
// contention from the two parallel workers both starting cold)
|
||||
// throws straight out of fetchTtsPreviewBlob itself, past that
|
||||
// retry layer entirely. A single blip like that used to doom the
|
||||
// ENTIRE multi-hour export — confirmed live, different ordinary,
|
||||
// unremarkable lines failed on repeated attempts, so this isn't
|
||||
// about any one line's content. Give a transient failure a few
|
||||
// seconds to pass before giving up on this line for real.
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (attempt < 3) await new Promise(r => setTimeout(r, 3000 * attempt));
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
if (cacheKey) _lineAudioCachePut(book, cacheKey, blob);
|
||||
}
|
||||
wavClips.set(i, blob);
|
||||
@ -5230,7 +5458,17 @@ async function audiobookExport() {
|
||||
prog.update(++done, `Synthesising line ${done} / ${allIdx.length}…`);
|
||||
}
|
||||
};
|
||||
try { await Promise.all(Array.from({ length: Math.min(2, allIdx.length) }, worker)); }
|
||||
// Used to run 2 workers concurrently to speed up bulk export. Every TTS
|
||||
// backend this app talks to (voice_clone, voice_design, fishspeech, the
|
||||
// other local engines) is a single self-hosted GPU model instance, not a
|
||||
// horizontally-scaled service — confirmed live, twice, with two different
|
||||
// backends: 2 concurrent requests reliably push at least one past the
|
||||
// reverse proxy's 60s timeout under real load, producing exactly the
|
||||
// "some lines just fail, no clear reason" pattern that made a multi-hour
|
||||
// book export fail outright. Serial is slower per line but doesn't waste
|
||||
// time on doomed, retried, ultimately-failing requests — net faster and
|
||||
// actually finishes.
|
||||
try { await worker(); }
|
||||
finally { prog.done(); _audiobook.running = false; }
|
||||
|
||||
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
|
||||
@ -5249,6 +5487,7 @@ async function audiobookExport() {
|
||||
const savedFiles = []; // {name, url} — for the results panel below
|
||||
const bookForExport = _lineAudioBookName();
|
||||
const encMsg = $('audiobook-msg');
|
||||
const pauses = _abPauseSettings();
|
||||
for (let c = 0; c < buckets.length; c++) {
|
||||
const blobs = buckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);
|
||||
if (!blobs.length) continue;
|
||||
@ -5256,8 +5495,25 @@ async function audiobookExport() {
|
||||
// data under one RIFF header — unlike naively Blob-concatenating
|
||||
// separately-encoded mp3 clips (each with its own frame/ID3 headers),
|
||||
// this produces one genuinely continuous, seekable audio stream.
|
||||
// A silent gap is inserted between every pair of lines (`pauses.paragraphMs`)
|
||||
// — mergeWavBlobs used to butt clips together with literally zero space,
|
||||
// which read as characters teleporting mid-scene with no beat between them.
|
||||
if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${buckets.length}…`;
|
||||
const mergedWav = await mergeWavBlobs(blobs);
|
||||
let mergedWav = await mergeWavBlobs(blobs, pauses.paragraphMs);
|
||||
// Every chapter after the first gets a longer lead-in pause (and, if the
|
||||
// user configured one, a custom transition sound) prepended — the first
|
||||
// chapter needs no lead-in since there's nothing before it to separate
|
||||
// from. This survives even when chapters export as separate files (the
|
||||
// normal case — see `realChapters` below): most audiobook players queue
|
||||
// consecutive tracks back-to-back with no gap of their own.
|
||||
if (c > 0 && buckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {
|
||||
const ref = _parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt;
|
||||
const lead = [];
|
||||
const sfx = await _abChapterSfxWavBlob(ref);
|
||||
if (sfx) lead.push(sfx);
|
||||
if (pauses.chapterMs > 0) lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs, ref), ref));
|
||||
if (lead.length) mergedWav = await mergeWavBlobs([...lead, mergedWav]);
|
||||
}
|
||||
// One real mp3 encode pass over the whole merged chapter, at an
|
||||
// explicit bitrate (routes/tts.py's /api/audio/encode-mp3) — the old
|
||||
// per-line encoding left the bitrate at ffmpeg/lame's unset default,
|
||||
|
||||
@ -1264,9 +1264,10 @@ function csAlignmentBar(score, arcDirection, arcNote) {
|
||||
// Fallback only — the LLM-generated image_prompt (routes/conversation.py
|
||||
// character_generate_prompts) is preferred and asks for the same turnaround-
|
||||
// sheet format; this covers the case where that hasn't been generated yet.
|
||||
function csBuildImagePrompt(s) {
|
||||
function csBuildImagePrompt(s, bookProfile) {
|
||||
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
|
||||
const parts = [];
|
||||
if (s.race_species) parts.push(s.race_species);
|
||||
if (s.archetype) parts.push(s.archetype);
|
||||
if (s.physical) parts.push(s.physical);
|
||||
if (s.clothing) parts.push(s.clothing);
|
||||
@ -1275,7 +1276,20 @@ function csBuildImagePrompt(s) {
|
||||
parts.push(pct >= 70 ? 'benevolent expression' : pct <= 30 ? 'dark and menacing presence' : 'ambiguous expression');
|
||||
if (s.arc_direction === 'bad-to-good') parts.push('redemptive aura');
|
||||
if (s.arc_direction === 'good-to-bad') parts.push('ominous aura, turning to darkness');
|
||||
// Without this, per-character prompts had no idea what kind of book they belonged
|
||||
// to at all — confirmed live as "generals" rendered as 1920s-era generals, and
|
||||
// East-Asian-styled portraits, in a Western medieval-fantasy book. A one-time book
|
||||
// profile (genre/setting/era) grounds every character's prompt in the same world
|
||||
// instead of each one guessing independently — see _getBookProfile.
|
||||
const bp = bookProfile || {};
|
||||
const settingBits = [bp.genre, bp.setting, bp.era].filter(Boolean);
|
||||
const settingClause = settingBits.length
|
||||
? `This character belongs to the following book/world: ${settingBits.join(', ')}. Every visual choice — architecture, `
|
||||
+ `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or `
|
||||
+ `real-world default. `
|
||||
: '';
|
||||
return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
|
||||
+ settingClause
|
||||
+ `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — `
|
||||
+ `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" `
|
||||
+ `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual `
|
||||
|
||||
@ -143,10 +143,28 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
const ttsBkSel = $('conv-tts-backend-select');
|
||||
const ttsFetchBtn = $('conv-tts-fetch-btn');
|
||||
const ttsVoiceSel = $('conv-tts-voice-select');
|
||||
const ttsEmotionSel = $('conv-emotion-select');
|
||||
const systemPrompt = $('conv-system-prompt');
|
||||
const turnHistory = $('conv-turn-history');
|
||||
if (!chatWindow || !micBtn) return;
|
||||
|
||||
// Conversation replies previously had no style/emotion control at all,
|
||||
// regardless of backend. Same REH_EMOTIONS quick-pick used in Try a Voice /
|
||||
// Read Aloud — sent to the server as a plain English phrase; the backend
|
||||
// decides server-side (see _conv_tts_text_and_instruct in
|
||||
// routes/conversation.py) whether it becomes an inline Fish [tag] or an
|
||||
// instruct-field phrase for style-aware backends.
|
||||
if (ttsEmotionSel && typeof REH_EMOTIONS !== 'undefined' && !ttsEmotionSel.dataset.populated) {
|
||||
REH_EMOTIONS.forEach(e => {
|
||||
if (!e.value) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = e.value;
|
||||
o.textContent = `${e.emoji} ${e.label}`;
|
||||
ttsEmotionSel.appendChild(o);
|
||||
});
|
||||
ttsEmotionSel.dataset.populated = '1';
|
||||
}
|
||||
|
||||
// Warn if microphone API is unavailable (HTTP on non-localhost = insecure context)
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
if (micStatus) {
|
||||
@ -963,6 +981,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
form.append('llm_model', llmModelSel?.value || '');
|
||||
form.append('tts_backend', ttsBkSel?.value || 'voice_clone');
|
||||
form.append('tts_voice', ttsVoiceSel?.value || '');
|
||||
form.append('tts_emotion', ttsEmotionSel?.value || '');
|
||||
form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.');
|
||||
form.append('history', JSON.stringify(conversationHistory.slice(-20)));
|
||||
|
||||
@ -1087,6 +1106,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
form.append('llm_model', llmModelSel?.value || '');
|
||||
form.append('tts_backend', ttsBkSel?.value || 'voice_clone');
|
||||
form.append('tts_voice', ttsVoiceSel?.value || '');
|
||||
form.append('tts_emotion', ttsEmotionSel?.value || '');
|
||||
form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.');
|
||||
form.append('history', JSON.stringify(conversationHistory.slice(-20)));
|
||||
|
||||
|
||||
@ -1,55 +1,79 @@
|
||||
// ── WAV merge utility (chunked TTS + playlist export) ──────────────────────
|
||||
|
||||
async function mergeWavBlobs(blobs) {
|
||||
if (!blobs || blobs.length === 0) return null;
|
||||
if (blobs.length === 1) return blobs[0];
|
||||
|
||||
function parseWav(bytes) {
|
||||
const v = new DataView(bytes.buffer);
|
||||
let off = 12, fmt = null, dataOff = 0, dataSize = 0;
|
||||
while (off + 8 <= bytes.length) {
|
||||
const id = v.getUint32(off, false);
|
||||
const sz = v.getUint32(off + 4, true);
|
||||
if (id === 0x666d7420) {
|
||||
fmt = { channels: v.getUint16(off+10,true), sampleRate: v.getUint32(off+12,true), bitDepth: v.getUint16(off+22,true) };
|
||||
} else if (id === 0x64617461) {
|
||||
dataOff = off + 8; dataSize = sz;
|
||||
}
|
||||
off += 8 + sz;
|
||||
function _parseWavBytes(bytes) {
|
||||
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let off = 12, fmt = null, dataOff = 0, dataSize = 0;
|
||||
while (off + 8 <= bytes.length) {
|
||||
const id = v.getUint32(off, false);
|
||||
const sz = v.getUint32(off + 4, true);
|
||||
if (id === 0x666d7420) {
|
||||
fmt = { channels: v.getUint16(off+10,true), sampleRate: v.getUint32(off+12,true), bitDepth: v.getUint16(off+22,true) };
|
||||
} else if (id === 0x64617461) {
|
||||
dataOff = off + 8; dataSize = sz;
|
||||
}
|
||||
return { fmt, dataOff, dataSize };
|
||||
off += 8 + sz;
|
||||
}
|
||||
return { fmt, dataOff, dataSize };
|
||||
}
|
||||
|
||||
const parsed = [];
|
||||
for (const b of blobs) {
|
||||
const bytes = new Uint8Array(await b.arrayBuffer());
|
||||
const p = parseWav(bytes);
|
||||
if (!p.fmt) throw new Error('Invalid WAV in chunk');
|
||||
parsed.push({ bytes, ...p });
|
||||
}
|
||||
const ref = parsed[0].fmt;
|
||||
const totalPcm = parsed.reduce((s, p) => s + p.dataSize, 0);
|
||||
const out = new Uint8Array(44 + totalPcm);
|
||||
// Wraps raw PCM bytes in a standalone WAV file — shared by the silence-gap
|
||||
// helper below and by anything else that needs to hand mergeWavBlobs a
|
||||
// ready-made clip (e.g. a resampled custom chapter-transition sound).
|
||||
function _buildWavBlob(pcmBytes, fmt) {
|
||||
const out = new Uint8Array(44 + pcmBytes.length);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, 0x52494646, false);
|
||||
dv.setUint32(4, 36 + totalPcm, true);
|
||||
dv.setUint32(4, 36 + pcmBytes.length, true);
|
||||
dv.setUint32(8, 0x57415645, false);
|
||||
dv.setUint32(12, 0x666d7420, false);
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true);
|
||||
dv.setUint16(22, ref.channels, true);
|
||||
dv.setUint32(24, ref.sampleRate, true);
|
||||
dv.setUint32(28, ref.sampleRate * ref.channels * (ref.bitDepth >> 3), true);
|
||||
dv.setUint16(32, ref.channels * (ref.bitDepth >> 3), true);
|
||||
dv.setUint16(34, ref.bitDepth, true);
|
||||
dv.setUint16(22, fmt.channels, true);
|
||||
dv.setUint32(24, fmt.sampleRate, true);
|
||||
dv.setUint32(28, fmt.sampleRate * fmt.channels * (fmt.bitDepth >> 3), true);
|
||||
dv.setUint16(32, fmt.channels * (fmt.bitDepth >> 3), true);
|
||||
dv.setUint16(34, fmt.bitDepth, true);
|
||||
dv.setUint32(36, 0x64617461, false);
|
||||
dv.setUint32(40, totalPcm, true);
|
||||
let pos = 44;
|
||||
for (const p of parsed) {
|
||||
dv.setUint32(40, pcmBytes.length, true);
|
||||
out.set(pcmBytes, 44);
|
||||
return new Blob([out], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
// A zero-filled (silent) raw-PCM byte run matching a reference WAV's format —
|
||||
// used to insert an audible gap between clips that mergeWavBlobs otherwise
|
||||
// concatenates with zero space between them (confirmed live: an audiobook
|
||||
// exported with literally no pause between paragraphs or chapters reads as
|
||||
// characters teleporting mid-scene).
|
||||
function _silencePcmBytes(ms, fmt) {
|
||||
const bytesPerSample = fmt.bitDepth >> 3;
|
||||
const frames = Math.round((ms / 1000) * fmt.sampleRate);
|
||||
return new Uint8Array(frames * fmt.channels * bytesPerSample);
|
||||
}
|
||||
|
||||
// `gapMs` inserts silence BETWEEN each pair of clips (not before the first or
|
||||
// after the last) — the natural "beat" between two lines of narration.
|
||||
async function mergeWavBlobs(blobs, gapMs = 0) {
|
||||
if (!blobs || blobs.length === 0) return null;
|
||||
if (blobs.length === 1 && !gapMs) return blobs[0];
|
||||
|
||||
const parsed = [];
|
||||
for (const b of blobs) {
|
||||
const bytes = new Uint8Array(await b.arrayBuffer());
|
||||
const p = _parseWavBytes(bytes);
|
||||
if (!p.fmt) throw new Error('Invalid WAV in chunk');
|
||||
parsed.push({ bytes, ...p });
|
||||
}
|
||||
const ref = parsed[0].fmt;
|
||||
const silence = gapMs > 0 ? _silencePcmBytes(gapMs, ref) : null;
|
||||
const totalPcm = parsed.reduce((s, p) => s + p.dataSize, 0) + (silence ? silence.length * (parsed.length - 1) : 0);
|
||||
const out = new Uint8Array(totalPcm);
|
||||
let pos = 0;
|
||||
parsed.forEach((p, i) => {
|
||||
out.set(p.bytes.slice(p.dataOff, p.dataOff + p.dataSize), pos);
|
||||
pos += p.dataSize;
|
||||
}
|
||||
return new Blob([out], { type: 'audio/wav' });
|
||||
if (silence && i < parsed.length - 1) { out.set(silence, pos); pos += silence.length; }
|
||||
});
|
||||
return _buildWavBlob(out, ref);
|
||||
}
|
||||
|
||||
// ── Chunked TTS ────────────────────────────────────────────────────────────
|
||||
|
||||
@ -1294,7 +1294,8 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
pg.querySelector('.lcd-avatar-gen-btn')?.addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
const btn = this;
|
||||
const prompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh) : '');
|
||||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
|
||||
const prompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
|
||||
if (!prompt) { toast('No image prompt to work from — generate the Character Image Prompt below first', 'error'); return; }
|
||||
const orig = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
@ -1679,7 +1680,7 @@ window._charDetailModal = _charDetailModal;
|
||||
function _openAvatarLightbox(rec, onSaved) {
|
||||
document.getElementById('avatar-lightbox')?.remove();
|
||||
const sh = rec.sheet || {};
|
||||
const currentPrompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh) : '');
|
||||
const currentPrompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, _getBookProfileSync(rec.book)) : '');
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'avatar-lightbox';
|
||||
ov.className = 'audiobook-overlay';
|
||||
@ -2103,6 +2104,12 @@ async function _getBookProfile(book) {
|
||||
_bookProfileCache.set(key, profile);
|
||||
return profile;
|
||||
}
|
||||
// Best-effort sync read for callers that build a display prompt outside an async
|
||||
// handler (e.g. a lightbox opened from a plain click listener) — returns {} on a
|
||||
// cache miss rather than blocking; the async path above is authoritative.
|
||||
function _getBookProfileSync(book) {
|
||||
return _bookProfileCache.get(String(book || '').trim()) || {};
|
||||
}
|
||||
async function _saveBookProfile(book, profile) {
|
||||
const key = String(book || '').trim();
|
||||
const r = await fetch('/api/book-profile', {
|
||||
@ -2302,10 +2309,12 @@ function _confirmVoiceReuse(rec, reuse) {
|
||||
try {
|
||||
const langHint = (typeof _resolveBookLang === 'function') ? await _resolveBookLang(rec).catch(function () { return ''; }) : '';
|
||||
const text = (typeof _charSampleTextFor === 'function' && _charSampleTextFor(rec, langHint)) || ('Hallo, ich bin ' + rec.name + '.');
|
||||
// Same fix as _libPreviewCharVoice — a designed (no-reference-WAV)
|
||||
// voice can't play through the voice_clone engine at all.
|
||||
// A voice with no reference clip can't play through voice_clone at
|
||||
// all; once it has one (even if it started life as a designed
|
||||
// voice) it can and should, for the same reproducibility reasons as
|
||||
// _ttsBackendForVoice.
|
||||
const rv = (window._voices || []).find(x => x.id === reuse.voiceId);
|
||||
const rBackend = (rv && (rv.origin === 'designed' || !rv.has_ref)) ? 'voice_design' : 'voice_clone';
|
||||
const rBackend = (rv && !rv.has_ref) ? 'voice_design' : 'voice_clone';
|
||||
const blob = await fetchTtsPreviewBlob(reuse.voiceId, text, 'wav', '', rBackend);
|
||||
if (!audioEl) { audioEl = new Audio(); audioEl.addEventListener('ended', function () { icon.className = 'mdi mdi-play'; }); }
|
||||
audioEl.src = URL.createObjectURL(blob);
|
||||
@ -2749,7 +2758,8 @@ async function _charAutoGenerateImage(rec, provider) {
|
||||
throw new Error('Not enough character detail to generate a meaningful portrait — skipped instead of using a generic placeholder');
|
||||
}
|
||||
}
|
||||
const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh) : '');
|
||||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
|
||||
const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
|
||||
if (!prompt) throw new Error('No image prompt to work from yet');
|
||||
const body = { prompt: prompt };
|
||||
if (provider) body.provider = provider;
|
||||
|
||||
@ -440,8 +440,30 @@ function _readerTimeout(promise, ms, label) {
|
||||
// Rasterize the top `gapPx` (scale-1 px) of `page` and OCR it, returning
|
||||
// synthetic word entries (same shape as real getTextContent words) spread
|
||||
// across the band so they slot into the normal sentence/highlight pipeline.
|
||||
// Decorative chapter-heading images commonly combine a small icon/border
|
||||
// graphic ABOVE or AROUND the actual number/title text (confirmed live: a
|
||||
// real book's heading was an octagonal badge with a bold border and an icon
|
||||
// sitting on top of the text) — Tesseract's default full-page layout
|
||||
// analysis gets thrown off by the graphic, either finding no text at all
|
||||
// (high confidence, empty result) or confidently misreading the border as a
|
||||
// stray character. Tightening the crop to exclude the graphic and telling
|
||||
// Tesseract to expect a single line of text (PSM 7) fixed this in testing:
|
||||
// the same heading that returned "" or ">" under default settings read back
|
||||
// as the correct "3.Kapitel" at 80% confidence once cropped to just the
|
||||
// bottom ~55% of the gap (where centered chapter-title text typically sits,
|
||||
// below any icon) with PSM 7. Tried first since it's the more surgical,
|
||||
// less error-prone read; the untrimmed full-gap crop remains as a fallback
|
||||
// for headings that are already plain text with no surrounding graphic.
|
||||
async function _readerOcrAttempt(worker, canvas, psm) {
|
||||
await worker.setParameters({ tessedit_pageseg_mode: psm });
|
||||
const { data } = await _readerTimeout(worker.recognize(canvas), 20000, 'Heading OCR');
|
||||
const text = (data.text || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
|
||||
let crop;
|
||||
let crop, tightCrop;
|
||||
try {
|
||||
const worker = await readerGetOcrWorker();
|
||||
const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE });
|
||||
@ -454,11 +476,27 @@ async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
|
||||
crop.height = cropH;
|
||||
await _readerTimeout(page.render({ canvasContext: crop.getContext('2d'), viewport }).promise, 15000, 'Page render');
|
||||
|
||||
const { data } = await _readerTimeout(worker.recognize(crop), 20000, 'Heading OCR');
|
||||
const text = (data.text || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return [];
|
||||
let text = null;
|
||||
if (cropH > 40) {
|
||||
const y0 = Math.round(cropH * 0.45);
|
||||
const inset = Math.round(crop.width * 0.03);
|
||||
tightCrop = document.createElement('canvas');
|
||||
tightCrop.width = Math.max(crop.width - inset * 2, 1);
|
||||
tightCrop.height = cropH - y0;
|
||||
tightCrop.getContext('2d').putImageData(crop.getContext('2d').getImageData(inset, y0, tightCrop.width, tightCrop.height), 0, 0);
|
||||
text = await _readerOcrAttempt(worker, tightCrop, '7');
|
||||
}
|
||||
if (!text) text = await _readerOcrAttempt(worker, crop, '3');
|
||||
if (!text) return [];
|
||||
|
||||
const tokens = text.split(' ').filter(Boolean);
|
||||
// Stray single-symbol tokens (a leftover border-line fragment misread as
|
||||
// "|" or similar) don't affect confidence enough to fail the threshold,
|
||||
// but do end up glued onto the real heading text — confirmed live: a
|
||||
// clean "3.Kapitel" recognition also produced a trailing "|" token,
|
||||
// which broke chapter-detection's stricter "nothing after the heading
|
||||
// but a number/subtitle" check by leaving that stray symbol behind it.
|
||||
const tokens = text.split(' ').filter(t => t && /[a-zäöüßàâçéèêëîïôûùüÿñæœ0-9]/i.test(t));
|
||||
if (!tokens.length) return [];
|
||||
const bandH = Math.max(gapPx - 4, 10);
|
||||
const avgCharW = Math.max((base.width - 16) / text.length, 4);
|
||||
const words = [];
|
||||
@ -474,6 +512,7 @@ async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
|
||||
return [];
|
||||
} finally {
|
||||
if (crop) { crop.width = 0; crop.height = 0; }
|
||||
if (tightCrop) { tightCrop.width = 0; tightCrop.height = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -600,6 +639,24 @@ async function readerExtractPdfText(loaded) {
|
||||
// Build + append this page's sentences and units
|
||||
const pageBase = readerBuildSentences(pageWords);
|
||||
const pageUnits = readerGroupUnits(pageBase, readerState.chunkMode);
|
||||
// A page with zero extracted text — no real text layer AND heading OCR
|
||||
// either found nothing or failed confidence (common for a page that's
|
||||
// ENTIRELY a decorative image, e.g. a chapter-divider graphic with no
|
||||
// recognizable characters at all) — used to contribute nothing to
|
||||
// readerState.sentences whatsoever. Since page numbers downstream
|
||||
// (audiobookScopeText's pageMarks, used to reconstruct page breaks after
|
||||
// LLM speaker-attribution) are derived entirely from real units' own
|
||||
// .words[0].page, a page with no units is invisible to that system —
|
||||
// not just missing its own content, but silently shifting every
|
||||
// SUBSEQUENT page number in the rehearsal/export view out of sync with
|
||||
// the actual PDF (confirmed live: reported page breaks drifting from
|
||||
// the source PDF on a book with several such divider pages). A blank
|
||||
// placeholder unit (empty text — never spoken, never shown as a line;
|
||||
// audiobookScopeText already skips any unit with no text) keeps this
|
||||
// page's mere existence visible to page tracking either way.
|
||||
if (!pageUnits.length) {
|
||||
pageUnits.push({ text: '', words: [{ page: pageIdx, x: 0, top: 0, w: 0, h: 0, text: '' }], status: 'pending', _stat: null, paraStart: false });
|
||||
}
|
||||
const startUnit = readerState.sentences.length;
|
||||
readerState.baseSentences.push(...pageBase);
|
||||
readerState.sentences.push(...pageUnits);
|
||||
@ -1081,6 +1138,36 @@ function readerUpdateScopeLabel() {
|
||||
// merged/exported file with paragraphs missing and no indication anything
|
||||
// had gone wrong. Callers now get the failed-index list back and must
|
||||
// surface it instead of reporting a clean success.
|
||||
// Same fix as Try a Voice / Studio: Fish-Speech ignores the free-text
|
||||
// #reader-instruct field entirely (it only reads an inline [tag] in the text
|
||||
// itself) — the emotion quick-pick applies as a tag on that backend instead.
|
||||
function _readerTextForSynth(text) {
|
||||
const backend = $('reader-backend-select')?.value || '';
|
||||
const emotion = $('reader-emotion-select')?.value || '';
|
||||
if (!/fish/i.test(backend) || !emotion) return text;
|
||||
if (/^\s*\[/.test(text)) return text;
|
||||
const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotion) : '';
|
||||
return tag ? `[${tag}] ${text}` : text;
|
||||
}
|
||||
(function initReaderEmotionPicker() {
|
||||
const sel = $('reader-emotion-select');
|
||||
if (!sel || typeof REH_EMOTIONS === 'undefined') return;
|
||||
REH_EMOTIONS.forEach(e => {
|
||||
if (!e.value) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = e.value;
|
||||
o.textContent = `${e.emoji} ${e.label}`;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.addEventListener('change', () => {
|
||||
const backend = $('reader-backend-select')?.value || '';
|
||||
if (!/fish/i.test(backend)) {
|
||||
const instructInput = $('reader-instruct');
|
||||
if (instructInput && sel.value) instructInput.value = sel.value;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
async function readerSynthIndices(targets) {
|
||||
targets = targets.filter(i => !readerState.blobCache.has(i));
|
||||
if (!targets.length || readerState.synthRunning) return { done: 0, failed: [] };
|
||||
@ -1108,7 +1195,7 @@ async function readerSynthIndices(targets) {
|
||||
if (readerState.blobCache.has(i)) { done++; update(); continue; }
|
||||
if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth');
|
||||
try {
|
||||
const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams());
|
||||
const blob = await fetchTtsPreviewBlob(voice, _readerTextForSynth(readerState.sentences[i].text), READER_FMT, instruct, backend, false, readerGenParams());
|
||||
readerState.blobCache.set(i, blob);
|
||||
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready');
|
||||
} catch (e) {
|
||||
@ -1281,7 +1368,7 @@ async function readerGetBuffer(idx) {
|
||||
if (!backend) throw new Error('No TTS backend selected');
|
||||
const instruct = $('reader-instruct')?.value.trim() || '';
|
||||
if (readerState.sentences[idx].status !== 'reading') readerSetStatus(idx, 'synth');
|
||||
blob = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
|
||||
blob = await fetchTtsPreviewBlob(voice, _readerTextForSynth(readerState.sentences[idx].text), READER_FMT, instruct, backend, false, readerGenParams());
|
||||
readerState.blobCache.set(idx, blob);
|
||||
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
|
||||
}
|
||||
@ -1311,7 +1398,7 @@ async function readerPrefetch(idx) {
|
||||
const instruct = $('reader-instruct')?.value.trim() || '';
|
||||
if (readerState.sentences[idx].status === 'pending') readerSetStatus(idx, 'synth');
|
||||
try {
|
||||
const b = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
|
||||
const b = await fetchTtsPreviewBlob(voice, _readerTextForSynth(readerState.sentences[idx].text), READER_FMT, instruct, backend, false, readerGenParams());
|
||||
readerState.blobCache.set(idx, b);
|
||||
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
|
||||
} catch (_) { if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'pending'); return; }
|
||||
|
||||
@ -75,7 +75,7 @@ const rehState = {
|
||||
staleLines: new Set(), // lines that were cached but had their tone changed
|
||||
synthCancelled: false,
|
||||
synthRunning: false,
|
||||
skipDescriptions: true,
|
||||
skipDescriptions: false,
|
||||
narratorVoice: '',
|
||||
practiceStart: null,
|
||||
practiceEnd: null,
|
||||
@ -1899,11 +1899,82 @@ function _rehBackendIsFish() {
|
||||
catch (_) { id = rehState.backend || ''; }
|
||||
return /fish/i.test(id);
|
||||
}
|
||||
// Fish-Speech's docs are explicit: "Use English emotion tags in square brackets
|
||||
// regardless of the spoken language." Per-line auto emotions are LLM-generated in the
|
||||
// book's own language (the casting prompt literally asks for "1-2 deutsche Wörter"),
|
||||
// which is what _buildInstruct's native-language sentence needs for Qwen3-TTS — but
|
||||
// that same raw word is useless as a Fish [tag] untranslated. Confirmed live: German
|
||||
// tags were silently ignored, producing flat/emotionless Fish-Speech output even
|
||||
// though the audio itself was fine. This table only needs to cover common tone/mood
|
||||
// adjectives, not a full dictionary — unrecognized words fall through to no tag at
|
||||
// all (line 8 below), which is a NOP for Fish, safer than sending a foreign-language
|
||||
// word it will just ignore anyway.
|
||||
const _REH_EMOTION_DE_EN = {
|
||||
'wütend':'angry','zornig':'angry','erzürnt':'angry','verärgert':'annoyed','gereizt':'irritated',
|
||||
'traurig':'sad','melancholisch':'melancholic','betrübt':'sad','niedergeschlagen':'dejected',
|
||||
'ängstlich':'scared','furchtsam':'fearful','verängstigt':'frightened','panisch':'panicked','nervös':'nervous',
|
||||
'fröhlich':'happy','glücklich':'happy','freudig':'joyful','heiter':'cheerful','vergnügt':'delighted',
|
||||
'flüsternd':'whispering','leise':'quiet','gedämpft':'hushed',
|
||||
'aufgeregt':'excited','begeistert':'enthusiastic','euphorisch':'euphoric',
|
||||
'überrascht':'surprised','erstaunt':'astonished','verblüfft':'amazed',
|
||||
'genervt':'annoyed','frustriert':'frustrated',
|
||||
'verzweifelt':'desperate','hoffnungslos':'hopeless','resigniert':'resigned',
|
||||
'entschlossen':'determined','entschieden':'decisive',
|
||||
'selbstbewusst':'confident','stolz':'proud','arrogant':'arrogant',
|
||||
'schüchtern':'shy','verlegen':'embarrassed','unsicher':'uncertain',
|
||||
'ironisch':'sarcastic','sarkastisch':'sarcastic','spöttisch':'mocking','höhnisch':'scornful','verächtlich':'contemptuous',
|
||||
'ernst':'serious','streng':'stern','autoritär':'authoritative','befehlend':'commanding',
|
||||
'sanft':'gentle','zärtlich':'tender','liebevoll':'loving','warm':'warm',
|
||||
'kalt':'cold','distanziert':'distant','gleichgültig':'indifferent','gelangweilt':'bored',
|
||||
'geheimnisvoll':'mysterious','unheimlich':'eerie','düster':'ominous','bedrohlich':'threatening',
|
||||
'dramatisch':'dramatic','theatralisch':'theatrical','pathetisch':'melodramatic',
|
||||
'ruhig':'calm','gelassen':'composed','besonnen':'measured',
|
||||
'schockiert':'shocked','entsetzt':'horrified','fassungslos':'stunned',
|
||||
'zögernd':'hesitant','verwirrt':'confused','ratlos':'bewildered','unschlüssig':'undecided',
|
||||
'weinend':'tearful','schluchzend':'sobbing','trauernd':'grieving','gebrochen':'broken',
|
||||
'schroff':'curt','barsch':'gruff','grob':'rough','abweisend':'dismissive',
|
||||
'freundlich':'friendly','herzlich':'warm','einladend':'welcoming',
|
||||
'spielerisch':'playful','neckend':'teasing','frech':'cheeky','schelmisch':'mischievous',
|
||||
'romantisch':'romantic','sehnsüchtig':'longing','verliebt':'infatuated',
|
||||
'triumphierend':'triumphant','siegessicher':'victorious',
|
||||
'erleichtert':'relieved','beruhigt':'reassured',
|
||||
'schuldbewusst':'guilty','reumütig':'remorseful',
|
||||
'neugierig':'curious','interessiert':'interested',
|
||||
'müde':'weary','erschöpft':'exhausted',
|
||||
'wehmütig':'wistful','nostalgisch':'nostalgic',
|
||||
'bemerkend':'remarking','feststellend':'noting','sachlich':'matter-of-fact','nüchtern':'plain',
|
||||
'flehend':'pleading','bittend':'imploring',
|
||||
'warnend':'warning','mahnend':'admonishing',
|
||||
'trotzig':'defiant','rebellisch':'rebellious',
|
||||
'erschrocken':'startled','verstört':'disturbed',
|
||||
};
|
||||
function _rehEmotionEnglishTag(emotion) {
|
||||
const raw = (emotion || '').trim();
|
||||
if (!raw) return '';
|
||||
// Preset emotions (REH_EMOTIONS / custom) are already English descriptive phrases —
|
||||
// the first, most tag-like word is what Fish actually needs.
|
||||
const firstWord = raw.split(/[,;]\s*/)[0].trim();
|
||||
const lower = firstWord.toLowerCase();
|
||||
// Check the DE→EN table BEFORE assuming "looks ASCII" means "is English" — most
|
||||
// German emotion adjectives (e.g. "bedrohlich") are pure a-z too, so charset alone
|
||||
// can't distinguish them from English; the dict must win first.
|
||||
if (_REH_EMOTION_DE_EN[lower]) return _REH_EMOTION_DE_EN[lower];
|
||||
// Try stripping a common German adjective ending (declined forms LLMs sometimes
|
||||
// produce, e.g. "ängstliche" instead of "ängstlich") and match on the stem.
|
||||
const stem = lower.replace(/(e|er|es|en|em)$/, '');
|
||||
if (stem.length >= 4) {
|
||||
for (const key in _REH_EMOTION_DE_EN) {
|
||||
if (key.startsWith(stem)) return _REH_EMOTION_DE_EN[key];
|
||||
}
|
||||
}
|
||||
if (/^[a-z\- ]+$/.test(lower)) return lower; // not in the table, but looks English (e.g. REH_EMOTIONS presets)
|
||||
return ''; // no reliable English tag — omit rather than send a word Fish will ignore
|
||||
}
|
||||
function _rehInlineTone(text, emotion) {
|
||||
const e = (emotion || '').trim();
|
||||
if (!e || !_rehBackendIsFish()) return text;
|
||||
if (!_rehBackendIsFish()) return text;
|
||||
if (/^\s*\[/.test(text)) return text; // already carries an inline [tag]
|
||||
return `[${e.toLowerCase()}] ${text}`;
|
||||
const tag = _rehEmotionEnglishTag(emotion);
|
||||
return tag ? `[${tag}] ${text}` : text;
|
||||
}
|
||||
|
||||
// ── Auto-design voices with LLM + Design a Voice ────────────────────────────
|
||||
@ -2575,6 +2646,23 @@ function buildScriptPage() {
|
||||
: '';
|
||||
const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');
|
||||
|
||||
// Same detection audiobookExport() uses to decide chapter/file boundaries
|
||||
// (audiobook.js) — surfaced here too so a chapter is visible in the script
|
||||
// itself, not just audible as a pause in the finished export. Runs before
|
||||
// the normal type switch so a chapter heading (however it was tagged
|
||||
// during parsing — 'act', 'scene', or plain 'action' text recovered via
|
||||
// heading OCR) always gets this treatment instead of its usual rendering.
|
||||
if (typeof audiobookIsChapter === 'function' && audiobookIsChapter(line)) {
|
||||
const label = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
|
||||
return `<div class="reh-chapter-marker${lineFlags}" data-index="${i}">
|
||||
${bulkCheck}
|
||||
<span class="reh-chapter-marker-line"></span>
|
||||
<span class="reh-chapter-marker-label">${label ? escHtml(label) : 'Chapter'}</span>
|
||||
<span class="reh-chapter-marker-line"></span>
|
||||
${editBtn} ${synthDot}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
switch (line.type) {
|
||||
case 'act':
|
||||
return `<div class="reh-act${lineFlags}" data-index="${i}" style="position:relative">${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}</div>`;
|
||||
@ -3348,6 +3436,22 @@ function selectEmotion(idx, value, anchorBtn) {
|
||||
if (value) _checkToneStyleSupport();
|
||||
}
|
||||
|
||||
// One row of the tone/identity comparison table.
|
||||
function _rehToneCmpRow(backend, isCurrent) {
|
||||
const check = (ok) => ok
|
||||
? '<span class="reh-tone-cmp-yes"><span class="mdi mdi-check"></span></span>'
|
||||
: '<span class="reh-tone-cmp-no"><span class="mdi mdi-close"></span></span>';
|
||||
const action = isCurrent
|
||||
? '<span class="reh-tone-cmp-current-tag">current</span>'
|
||||
: `<button type="button" class="btn-secondary btn-sm reh-tone-switch-btn" data-backend-id="${escHtml(backend.id)}">Switch</button>`;
|
||||
return `<tr${isCurrent ? ' class="reh-tone-cmp-current"' : ''}>
|
||||
<td>${escHtml(backend.label)}</td>
|
||||
<td>${check(backend.style_aware)}</td>
|
||||
<td>${check(backend.uses_wav)}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function _checkToneStyleSupport() {
|
||||
const warn = $('reh-tone-warn'); if (!warn) return;
|
||||
const txtEl = $('reh-tone-warn-txt');
|
||||
@ -3359,20 +3463,39 @@ function _checkToneStyleSupport() {
|
||||
// Two opposite engine trade-offs, surfaced so the user can choose knowingly:
|
||||
// • clone backends → consistent character identity, but weak tone control
|
||||
// • design backends → strong tone, but a fresh persona each call (voices drift)
|
||||
// Originally a single run-on sentence with a button awkwardly wedged into
|
||||
// the middle of it (confirmed live: read badly, wrapped worse). A table
|
||||
// says the same two facts as two columns instead of two clauses.
|
||||
if (_rehBackendIsFish()) {
|
||||
// Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
|
||||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent <em>and</em> applies tone. Per-line tones are sent as inline <code>[tags]</code> (e.g. <code>[whisper]</code>, <code>[excited]</code>, <code>[laughing]</code>). You can also type a custom tone like <code>[professional broadcast tone]</code> — S2 supports free-form descriptions. <a href="https://huggingface.co/fishaudio/s2-pro" target="_blank" rel="noopener">Fish-Speech S2 ↗</a>`;
|
||||
warn.hidden = false;
|
||||
} else if (!b.style_aware && hasTone) {
|
||||
const styleAware = all.find(x => x.style_aware);
|
||||
const suggest = styleAware ? ` Switch to <strong>${escHtml(styleAware.label)}</strong> for reliable tone — but expect each voice to drift between lines.` : '';
|
||||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.${suggest}`;
|
||||
// Prefer a backend that fixes BOTH weaknesses at once (tone-aware AND
|
||||
// clones from a reference WAV, e.g. Fish-Speech) over one that only
|
||||
// fixes this one (tone-aware but re-rolls the voice each line, e.g.
|
||||
// Voice Design) — confirmed live: with both available, `.find()` was
|
||||
// silently suggesting whichever happened to come first in the backend
|
||||
// list, which was never Fish-Speech despite it being the strictly
|
||||
// better option whenever it's actually running.
|
||||
const styleAware = all.find(x => x.style_aware && x.uses_wav) || all.find(x => x.style_aware);
|
||||
if (txtEl) {
|
||||
txtEl.innerHTML = styleAware
|
||||
? `<table class="reh-tone-cmp"><thead><tr><th></th><th>Tone control</th><th>Voice stays identical</th><th></th></tr></thead><tbody>${_rehToneCmpRow(b, true)}${_rehToneCmpRow(styleAware, false)}</tbody></table>`
|
||||
: `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.`;
|
||||
}
|
||||
warn.hidden = false;
|
||||
} else if (b.style_aware && !b.uses_wav) {
|
||||
const wavBackend = all.find(x => x.uses_wav);
|
||||
const suggest = wavBackend ? ` Switch to <strong>${escHtml(wavBackend.label)}</strong> to keep each character’s voice identical throughout.` : '';
|
||||
const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? ' Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.' : '';
|
||||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}${suggest}`;
|
||||
// Same preference as above, mirrored: a wav-cloning backend that's ALSO
|
||||
// tone-aware (Fish-Speech) beats one that drops tone control entirely
|
||||
// (Voice Clone) as the suggested alternative.
|
||||
const wavBackend = all.find(x => x.uses_wav && x.style_aware) || all.find(x => x.uses_wav);
|
||||
const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? '<p class="reh-tone-cmp-note">Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.</p>' : '';
|
||||
if (txtEl) {
|
||||
txtEl.innerHTML = wavBackend
|
||||
? `<table class="reh-tone-cmp"><thead><tr><th></th><th>Tone control</th><th>Voice stays identical</th><th></th></tr></thead><tbody>${_rehToneCmpRow(b, true)}${_rehToneCmpRow(wavBackend, false)}</tbody></table>${qwenHint}`
|
||||
: `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}`;
|
||||
}
|
||||
warn.hidden = false;
|
||||
} else {
|
||||
warn.hidden = true;
|
||||
@ -3381,6 +3504,21 @@ function _checkToneStyleSupport() {
|
||||
|
||||
$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });
|
||||
|
||||
// The suggestion buttons above get rebuilt (via innerHTML) every time
|
||||
// _checkToneStyleSupport() re-runs, so a delegated listener on the
|
||||
// container — bound once — is the only reliable way to catch clicks on them.
|
||||
$('reh-tone-warn')?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.reh-tone-switch-btn');
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.backendId;
|
||||
const sel = $('reh-backend-select');
|
||||
if (sel && [...sel.options].some(o => o.value === id)) sel.value = id;
|
||||
rehState.backend = id;
|
||||
_checkToneStyleSupport();
|
||||
const b = (typeof backendById === 'function') ? backendById(id) : null;
|
||||
toast('Switched to ' + (b ? b.label : id), 'success');
|
||||
});
|
||||
|
||||
// ── Transport controls ──────────────────────────────────────────────────────
|
||||
|
||||
$('reh-tb-play')?.addEventListener('click', () => {
|
||||
@ -3658,9 +3796,18 @@ async function playNextLine() {
|
||||
return playNextLine();
|
||||
}
|
||||
|
||||
// Skip non-dialog lines only when skip mode is on AND no narrator voice is set.
|
||||
// When a narrator voice exists, every line with text gets spoken — never skip.
|
||||
if (rehState.skipDescriptions && !rehState.narratorVoice) {
|
||||
// Skip non-dialog lines whenever skip mode is on, full stop — regardless of
|
||||
// whether a narrator voice happens to be assigned. This used to also require
|
||||
// !rehState.narratorVoice, on the theory that skipping was only meaningful
|
||||
// when there was nothing to skip TO — but the individual-line branch below
|
||||
// never re-checked skipDescriptions at all, so once ANY narrator voice was
|
||||
// configured (the common case once a book is actually set up), narration
|
||||
// played regardless of this toggle. Confirmed live: Studio's "Rehearse ⇄
|
||||
// Audiobook" toggle (which just flips this same flag) had zero observable
|
||||
// effect once a narrator voice existed — exactly the reported "doesn't
|
||||
// read the narrator" / toggle-does-nothing behavior, just inverted from
|
||||
// what it looked like (narration was stuck ON, not stuck OFF).
|
||||
if (rehState.skipDescriptions) {
|
||||
while (
|
||||
rehState.lineIndex < rehState.lines.length &&
|
||||
rehState.lines[rehState.lineIndex].type !== 'dialog' &&
|
||||
@ -3698,7 +3845,7 @@ async function playNextLine() {
|
||||
if (line.type !== 'dialog') {
|
||||
// Any line with text can be narrated — direction/transition/scene/act/action all included
|
||||
const hasText = !!(line.text || '').trim();
|
||||
if (rehState.narratorVoice && hasText) {
|
||||
if (rehState.narratorVoice && hasText && !rehState.skipDescriptions) {
|
||||
showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : ''));
|
||||
const cached = rehState.synthCache.get(rehState.lineIndex);
|
||||
if (cached) {
|
||||
|
||||
@ -131,6 +131,7 @@ function playRouteSound(path, btn) {
|
||||
_routeSoundPlayingButton = btn;
|
||||
btn.textContent = '❚❚';
|
||||
audio.hidden = false;
|
||||
audio.playbackRate = 1; // reset — the route-speed preview below reuses this same element
|
||||
audio.src = routeSoundUrl(path);
|
||||
audio.onended = () => { btn.textContent = '▶'; };
|
||||
audio.onpause = () => { if (_routeSoundPlayingButton === btn) btn.textContent = '▶'; };
|
||||
@ -141,6 +142,61 @@ function playRouteSound(path, btn) {
|
||||
});
|
||||
}
|
||||
|
||||
// Short, natural-sounding test lines per language — same idea as the fixed
|
||||
// German phrase in the "Test route" box above the table, just localized so a
|
||||
// route's own language column previews sensibly instead of always reading
|
||||
// English/German regardless of what the route actually routes.
|
||||
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ápida de la velocidad de reproducción.',
|
||||
IT: 'Questo è un rapido test della velocità di riproduzione.',
|
||||
PT: 'Este é um teste rápido da velocidade de reprodução.',
|
||||
NL: 'Dit is een snelle test van de afspeelsnelheid.',
|
||||
PL: 'To jest krótki test szybkości odtwarzania.',
|
||||
};
|
||||
|
||||
// Synthesizes the row's output voice directly (bypassing the app/voice
|
||||
// routing-table lookup that /v1/audio/speech would do) so speed changes are
|
||||
// audible instantly without saving the route first. Applies speed via the
|
||||
// <audio> element's own playbackRate for an immediate preview — the actual
|
||||
// routed traffic gets a proper pitch-preserving ffmpeg tempo change
|
||||
// server-side (core/audio.py:_change_tempo), which is slower but higher
|
||||
// quality than just is appropriate for a quick "does this sound right" check.
|
||||
async function previewRouteSpeed(row, btn) {
|
||||
const outputVoice = row.querySelector('.route-output')?.value.trim();
|
||||
if (!outputVoice) { toast('Set an output voice first', 'error'); return; }
|
||||
const backend = row.querySelector('.route-backend')?.value || 'voice_clone';
|
||||
const speed = Math.max(0.5, Math.min(2, parseFloat(row.querySelector('.route-speed')?.value) || 1));
|
||||
const lang = (row.querySelector('.route-lang')?.value || 'EN').toUpperCase();
|
||||
const text = ROUTE_SPEED_PREVIEW_TEXT[lang] || ROUTE_SPEED_PREVIEW_TEXT.EN;
|
||||
|
||||
const audio = $('routing-sound-preview');
|
||||
if (!audio) return;
|
||||
if (_routeSoundPlayingButton && _routeSoundPlayingButton !== btn) _routeSoundPlayingButton.textContent = '▶';
|
||||
if (_routeSpeedPreviewButton && _routeSpeedPreviewButton !== btn) _routeSpeedPreviewButton.querySelector('.mdi')?.classList.replace('mdi-pause', 'mdi-play');
|
||||
_routeSpeedPreviewButton = btn;
|
||||
const icon = btn.querySelector('.mdi');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const blob = await fetchTtsPreviewBlob(outputVoice, text, 'wav', '', backend);
|
||||
audio.hidden = false;
|
||||
audio.src = URL.createObjectURL(blob);
|
||||
audio.playbackRate = speed;
|
||||
icon?.classList.replace('mdi-play', 'mdi-pause');
|
||||
audio.onended = () => icon?.classList.replace('mdi-pause', 'mdi-play');
|
||||
audio.onpause = () => { if (_routeSpeedPreviewButton === btn) icon?.classList.replace('mdi-pause', 'mdi-play'); };
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
icon?.classList.replace('mdi-pause', 'mdi-play');
|
||||
toast('Preview failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
let _routeSpeedPreviewButton = null;
|
||||
|
||||
function useRouteSound(path, target) {
|
||||
const selected = _routeSoundPickerTarget || {};
|
||||
const row = selected.row || document.querySelector('.routing-row');
|
||||
@ -201,6 +257,10 @@ function renderRoutingList() {
|
||||
<select class="route-lang" aria-label="Route language">${routeSelectOptions(String(r.language || '*').toUpperCase())}</select>
|
||||
<select class="route-backend" aria-label="Route backend" title="Voice Clone uses the normal TTS URL, Streaming uses the streaming URL, Voice Design uses vd_ presets, NVIDIA Magpie uses fixed NVIDIA voices, NVIDIA Zeroshot/Flow use saved library WAVs as audio prompts.">${routeBackendOptions(r.backend || 'voice_clone')}</select>
|
||||
<span class="route-output-wrap"><input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset"></span>
|
||||
<div class="route-speed-cell">
|
||||
<input class="route-speed" type="number" min="0.5" max="2" step="0.05" value="${Number(r.speed) > 0 ? r.speed : 1}" title="Playback speed multiplier (0.5x-2x). 1 = normal speed.">
|
||||
<button type="button" class="btn-secondary route-speed-preview" title="Preview how this voice sounds at this speed"><span class="mdi mdi-play"></span></button>
|
||||
</div>
|
||||
<div class="route-sound-cell">
|
||||
<input class="route-before-sound" value="${escHtml(r.before_sound || '')}" placeholder="sounds/start.wav" list="routing-sound-options">
|
||||
<button class="btn-secondary route-sound-pick" data-target="before" title="Browse and preview uploaded sounds">Pick</button>
|
||||
@ -233,6 +293,7 @@ function readRoutingForm() {
|
||||
language: row.querySelector('.route-lang').value || '*',
|
||||
backend: row.querySelector('.route-backend').value || 'voice_clone',
|
||||
output_voice: row.querySelector('.route-output').value.trim(),
|
||||
speed: Math.max(0.5, Math.min(2, parseFloat(row.querySelector('.route-speed').value) || 1)),
|
||||
before_sound: row.querySelector('.route-before-sound').value.trim(),
|
||||
after_sound: row.querySelector('.route-after-sound').value.trim(),
|
||||
};
|
||||
@ -509,6 +570,11 @@ $('routing-list')?.addEventListener('change', e => {
|
||||
readRoutingForm();
|
||||
});
|
||||
$('routing-list')?.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 row = pickBtn.closest('.routing-row');
|
||||
|
||||
@ -180,9 +180,12 @@ function attachSeedFinder(voiceId, body) {
|
||||
pinInput.value = voiceObj.seed;
|
||||
pinStatus.textContent = `✓ Pinned seed ${voiceObj.seed}`;
|
||||
}
|
||||
// A designed voice (no reference WAV) can only run through Voice Design —
|
||||
// Voice Clone/Streaming fail outright for it every time.
|
||||
if (voiceObj.origin === 'designed' || !voiceObj.has_ref) backendEl.value = 'voice_design';
|
||||
// A voice with no reference WAV can only run through Voice Design — Voice
|
||||
// Clone/Streaming fail outright for it. But Voice Design has no seed
|
||||
// parameter at all (confirmed against the model source), so seed-hunting a
|
||||
// designed voice that already HAS a reference clip needs to default to
|
||||
// Voice Clone, the only backend a pinned seed here can actually affect.
|
||||
if (!voiceObj.has_ref) backendEl.value = 'voice_design';
|
||||
|
||||
let _cancelled = false;
|
||||
let _currentAudio = null;
|
||||
|
||||
@ -182,6 +182,20 @@ function _stuEnterPhase(n) {
|
||||
} else if (n === 4) {
|
||||
_stuBorrow('reh-phase-3', 'stu-stage-slot');
|
||||
_stuBorrow('reh-cast-list', 'stu-mecast-slot');
|
||||
// #reh-tone-warn is a descendant of #reh-phase-3, already moved above —
|
||||
// pull just this one element back out into the toggle row's own spare
|
||||
// width instead of leaving it to render as its own full-width banner
|
||||
// further down. Order matters: this must run after the #reh-phase-3
|
||||
// borrow above, since that's what puts #reh-tone-warn somewhere this
|
||||
// second, more specific borrow can find and re-extract it from.
|
||||
_stuBorrow('reh-tone-warn', 'stu-tone-warn-slot');
|
||||
if (typeof _abInitPauseUI === 'function') _abInitPauseUI();
|
||||
// A stacked-sticky "toggle above the transport bar" version of this was
|
||||
// tried and reverted — confirmed live to break scrolling entirely on a
|
||||
// real window (the wrapped multi-row toolbar plus a second sticky header
|
||||
// above it could exceed viewport height, leaving nothing scrollable
|
||||
// visible at all). Needs isolated testing with the actual wrapped
|
||||
// toolbar height before trying again, not a same-turn redeploy.
|
||||
_stuCallSuppressingNav(async function () {
|
||||
if (window.rehState && rehState.lines && rehState.lines.length) {
|
||||
if (typeof buildScriptPage === 'function') buildScriptPage();
|
||||
@ -238,6 +252,12 @@ document.getElementById('stu-mode-audiobook')?.addEventListener('change', functi
|
||||
rehState.skipDescriptions = !audiobookMode;
|
||||
const t = document.getElementById('reh-skip-desc-toggle');
|
||||
if (t) t.checked = rehState.skipDescriptions;
|
||||
// The toggle only controls WHETHER narration is voiced, not with what —
|
||||
// flipping it on with no narrator voice assigned looked identical to it
|
||||
// doing nothing at all. Point at the fix instead of failing silently.
|
||||
if (audiobookMode && !rehState.narratorVoice && typeof toast === 'function') {
|
||||
toast('No narrator voice assigned yet — assign one in the Voices phase so narration actually plays', 'error');
|
||||
}
|
||||
}
|
||||
const details = document.getElementById('stu-mecast-details');
|
||||
if (details) details.hidden = audiobookMode;
|
||||
|
||||
@ -203,6 +203,48 @@ $('fetch-tts-voices-btn').addEventListener('click', async () => {
|
||||
finally { $('fetch-tts-voices-btn').disabled = false; }
|
||||
});
|
||||
|
||||
// ── Emotion quick-pick (Try a Voice / "Quick Play") ─────────────────────────
|
||||
// Fish-Speech ignores the free-text style-instruction field entirely (it only
|
||||
// reads inline [tag] emotion markers from the TEXT itself) — typing an
|
||||
// emotion there silently does nothing on that backend, which is exactly the
|
||||
// bug reported and fixed for the Rehearser/Studio pipeline (see
|
||||
// _rehInlineTone / _rehEmotionEnglishTag in rehearser.js). This reuses that
|
||||
// same translation table so a Fish-Speech voice actually reacts to the pick,
|
||||
// while style-aware backends (VoiceDesign/CustomVoice) keep working through
|
||||
// the instruct field exactly as before.
|
||||
function _ttsIsFishBackend(id) { return /fish/i.test(id || ''); }
|
||||
function _ttsApplyEmotionTag(text, emotionValue) {
|
||||
const backend = $('tts-backend-select')?.value || '';
|
||||
if (!_ttsIsFishBackend(backend) || !emotionValue) return text;
|
||||
if (/^\s*\[/.test(text)) return text; // already carries an inline tag
|
||||
const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotionValue) : '';
|
||||
return tag ? `[${tag}] ${text}` : text;
|
||||
}
|
||||
(function initPreviewEmotionPicker() {
|
||||
const sel = $('preview-emotion-select');
|
||||
if (!sel || typeof REH_EMOTIONS === 'undefined') return;
|
||||
REH_EMOTIONS.forEach(e => {
|
||||
if (!e.value) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = e.value;
|
||||
o.textContent = `${e.emoji} ${e.label}`;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.addEventListener('change', () => {
|
||||
const backend = $('tts-backend-select')?.value || '';
|
||||
const help = $('preview-emotion-help');
|
||||
if (_ttsIsFishBackend(backend)) {
|
||||
// Fish reads emotion from an inline [tag] in the text, not the
|
||||
// style-instruction field — applied automatically at synth/save time.
|
||||
if (help) { help.style.display = sel.value ? 'block' : 'none'; help.textContent = sel.value ? 'Applied as an inline [tag] in the text for Fish-Speech — the style instruction field below is ignored by this backend.' : ''; }
|
||||
} else {
|
||||
if (help) help.style.display = 'none';
|
||||
const styleInput = $('preview-style-instruction');
|
||||
if (styleInput && sel.value) styleInput.value = sel.value;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
$('tts-backend-select').addEventListener('change', () => {
|
||||
const sel = $('tts-voice-select');
|
||||
sel.innerHTML = '<option value="">— select after fetch —</option>';
|
||||
@ -211,6 +253,7 @@ $('tts-backend-select').addEventListener('change', () => {
|
||||
previewBlob = null;
|
||||
$('save-preview-mp3-btn').disabled = true;
|
||||
$('save-preview-btn').disabled = true;
|
||||
$('preview-emotion-select')?.dispatchEvent(new Event('change'));
|
||||
});
|
||||
|
||||
$('tts-voice-select').addEventListener('change', updatePreviewVoiceMatchPanel);
|
||||
@ -285,7 +328,14 @@ async function _ttsPreviewFetchWithRetry(body, tries) {
|
||||
function _ttsBackendForVoice(voiceId, fallbackBackend) {
|
||||
if (!voiceId || voiceId === 'me') return fallbackBackend;
|
||||
const v = (window._voices || []).find(x => x.id === voiceId);
|
||||
if (v && (v.origin === 'designed' || !v.has_ref)) return 'voice_design';
|
||||
// Only route to voice_design when there is genuinely no reference clip to
|
||||
// clone from. `origin === 'designed'` used to force voice_design even once
|
||||
// a voice HAD a saved reference — but Voice Design has no seed parameter at
|
||||
// all (confirmed against the model source), so every read of an
|
||||
// already-designed voice was a fresh, unpinned roll instead of a
|
||||
// reproducible clone read. Once a designed voice has a reference clip, it's
|
||||
// exactly as clonable as any other voice, and should be for consistency.
|
||||
if (v && !v.has_ref) return 'voice_design';
|
||||
return fallbackBackend;
|
||||
}
|
||||
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false, extra = null) {
|
||||
@ -399,7 +449,8 @@ $('preview-native-speed')?.addEventListener('change', function () {
|
||||
});
|
||||
|
||||
$('preview-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();
|
||||
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, instruct=$('preview-style-instruction').value.trim();
|
||||
const text=_ttsApplyEmotionTag($('preview-text-area').value.trim(), $('preview-emotion-select')?.value);
|
||||
const applyPersona = $('preview-persona-toggle')?.checked || false;
|
||||
if(!backend) { toast('No available TTS backend','error'); return; }
|
||||
if(!voice) { toast('Select a TTS voice','error'); return; }
|
||||
@ -428,7 +479,8 @@ $('preview-btn').addEventListener('click', async () => {
|
||||
finally { $('preview-btn').disabled=false; }
|
||||
});
|
||||
$('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();
|
||||
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, instruct=$('preview-style-instruction').value.trim();
|
||||
const text=_ttsApplyEmotionTag($('preview-text-area').value.trim(), $('preview-emotion-select')?.value);
|
||||
if(!backend) { toast('No available TTS backend','error'); return; }
|
||||
if(!voice || !text) return;
|
||||
const btn = $('save-preview-mp3-btn');
|
||||
|
||||
@ -60,10 +60,51 @@
|
||||
|
||||
<!-- ── Phase 4: Perform & Export ───────────────────────────────── -->
|
||||
<div class="stu-phase" id="stu-phase-4" hidden>
|
||||
<label class="stu-mode-toggle">
|
||||
<input type="checkbox" id="stu-mode-audiobook">
|
||||
<span><span class="mdi mdi-headphones"></span> Generate full audiobook (voice the Narrator too)</span>
|
||||
</label>
|
||||
<div style="display:flex; align-items:flex-start; gap:14px; flex-wrap:wrap;">
|
||||
<label class="stu-mode-toggle" style="flex-shrink:0">
|
||||
<input type="checkbox" id="stu-mode-audiobook">
|
||||
<span><span class="mdi mdi-headphones"></span> Generate full audiobook (voice the Narrator too)</span>
|
||||
</label>
|
||||
<!-- Borrowed here from the Stage toolbar (#reh-tone-warn) — it
|
||||
used to render as its own full-width banner below the
|
||||
toolbar, leaving this whole row's spare width unused right
|
||||
above it. Same element, same id, just relocated. -->
|
||||
<div id="stu-tone-warn-slot" style="flex:1; min-width:200px"></div>
|
||||
</div>
|
||||
<!-- Pause lengths applied when synthesising the full audiobook —
|
||||
mergeWavBlobs otherwise butts every line together with zero
|
||||
gap, which reads as scenes/paragraphs jump-cutting with no
|
||||
beat between them. Settings persist per-browser (not per-book)
|
||||
via localStorage since they're a pacing preference, not book
|
||||
content. -->
|
||||
<details class="card" id="stu-pause-details" style="margin-bottom:14px">
|
||||
<summary style="cursor:pointer;font-weight:700;padding:4px 0"><span class="mdi mdi-timer-sand"></span> Pacing (pauses between paragraphs & chapters)</summary>
|
||||
<div class="settings-grid settings-behavior-grid" style="margin-top:10px">
|
||||
<div class="s-field">
|
||||
<label>Pause between paragraphs</label>
|
||||
<div style="display:flex; align-items:center; gap:6px">
|
||||
<input type="number" id="stu-pause-paragraph" min="0" max="20" step="0.5" style="width:80px">
|
||||
<span>seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label>Pause between chapters</label>
|
||||
<div style="display:flex; align-items:center; gap:6px">
|
||||
<input type="number" id="stu-pause-chapter" min="0" max="60" step="0.5" style="width:80px">
|
||||
<span>seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label>Chapter transition sound <small style="opacity:.6;font-weight:400">(optional)</small></label>
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap">
|
||||
<input type="file" id="stu-chapter-sfx-file" accept="audio/*" style="max-width:220px">
|
||||
<button type="button" class="btn-secondary btn-sm" id="stu-chapter-sfx-clear"><span class="mdi mdi-close"></span> Clear</button>
|
||||
<span id="stu-chapter-sfx-current" class="s-hint"></span>
|
||||
</div>
|
||||
<span class="s-hint">Plays once before each chapter's pause, e.g. a page-turn or chime. Any audio format your browser can play.</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<!-- "I play this" lives on the per-character cast cards, not the
|
||||
Stage line view — only relevant in Rehearse mode. -->
|
||||
<details class="card" id="stu-mecast-details" style="margin-bottom:14px">
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
<select id="conv-tts-backend-select" aria-label="TTS backend"><option value="">Checking...</option></select>
|
||||
<button class="btn-secondary" id="conv-tts-fetch-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
|
||||
<select id="conv-tts-voice-select" aria-label="Voice"><option value="">— fetch voices —</option></select>
|
||||
<select id="conv-emotion-select" aria-label="Reply emotion" title="Applies to every reply — for Fish-Speech, as an inline tag; for other backends, as a style instruction"><option value="">Emotion…</option></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
<div class="engine-setup-col no-border" style="flex:1">
|
||||
<label class="engine-setup-label"><span class="mdi mdi-tune"></span> Synthesis Options</label>
|
||||
<div class="engine-setup-controls" style="flex-wrap:nowrap">
|
||||
<select id="reader-emotion-select" title="Quick-pick emotion — for Fish-Speech, applied as an inline tag; for other backends, fills the style field" style="width:110px"><option value="">Emotion…</option></select>
|
||||
<input type="text" id="reader-instruct" placeholder="Optional style / tone" autocomplete="off" style="width:140px">
|
||||
<select id="reader-chunk-mode" title="Voice consistency" style="width:120px">
|
||||
<option value="sentence">Per sentence</option>
|
||||
|
||||
@ -312,6 +312,10 @@
|
||||
</div>
|
||||
|
||||
<div class="reh-console-actions">
|
||||
<label class="chunk-toggle-label" style="font-size:11px;gap:4px;white-space:nowrap" title="Skip narration (scene headings & action descriptions) during playback — uncheck to have the Narrator voice read them">
|
||||
<input type="checkbox" id="reh-skip-desc-toggle">
|
||||
<span>Skip narrator</span>
|
||||
</label>
|
||||
<button class="btn-secondary btn-sm" id="reh-tb-synth-all" title="Pre-synthesize all TTS lines for instant playback">
|
||||
<span class="mdi mdi-lightning-bolt"></span> Synth all
|
||||
</button>
|
||||
@ -330,10 +334,6 @@
|
||||
<button class="btn-secondary btn-sm" id="reh-tb-clean-cache" title="Delete cached audio files on disk for lines that no longer match this script (edited/removed since they were synthesized)">
|
||||
<span class="mdi mdi-broom"></span> Clean cache
|
||||
</button>
|
||||
<label class="chunk-toggle-label" style="font-size:11px;gap:4px;white-space:nowrap" title="Skip scene headings & action descriptions during playback">
|
||||
<input type="checkbox" id="reh-skip-desc-toggle" checked>
|
||||
<span>Skip desc.</span>
|
||||
</label>
|
||||
<button class="btn-secondary btn-sm" id="reh-bulk-toggle" title="Bulk edit lines — select to ignore, hide or delete"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select</button>
|
||||
<button class="btn-secondary btn-sm" id="reh-edit-script-btn" title="Edit script & title"><span class="mdi mdi-pencil"></span></button>
|
||||
<button class="btn-secondary btn-sm" id="reh-tb-save" title="Save to library"><span class="mdi mdi-content-save-outline"></span> Save</button>
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
<div class="card">
|
||||
<div class="routing-grid routing-header">
|
||||
<div>On</div><div>App</div><div>Input voice</div><div>Language</div>
|
||||
<div>Backend</div><div>Output voice</div><div>Before sound</div><div>After sound</div><div></div>
|
||||
<div>Backend</div><div>Output voice</div><div>Speed</div><div>Before sound</div><div>After sound</div><div></div>
|
||||
</div>
|
||||
<datalist id="routing-voice-options"></datalist>
|
||||
<datalist id="routing-sound-options"></datalist>
|
||||
|
||||
@ -61,6 +61,16 @@
|
||||
<div class="field">
|
||||
<textarea id="preview-text-area" placeholder="Enter the text you want to synthesize…" style="min-height:120px">Hello! This is a voice preview from TTS Voice Creator - Clone and Design.</textarea>
|
||||
</div>
|
||||
<div class="field" style="margin-top:10px">
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
Emotion
|
||||
<span style="font-weight:400;color:var(--subtext)">(optional — quick pick)</span>
|
||||
</label>
|
||||
<select id="preview-emotion-select">
|
||||
<option value="">— none / type your own below —</option>
|
||||
</select>
|
||||
<span id="preview-emotion-help" class="note" style="display:none;margin-top:4px"></span>
|
||||
</div>
|
||||
<div class="field" style="margin-top:10px">
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
Style instruction
|
||||
|
||||
@ -1837,7 +1837,10 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
|
||||
/* ── Routing ────────────────────────────────────────────────────────────── */
|
||||
.routing-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.routing-grid { display: grid; grid-template-columns: 42px minmax(104px,.7fr) minmax(92px,.55fr) 82px minmax(112px,.6fr) minmax(140px,1fr) minmax(120px,.65fr) minmax(120px,.65fr) 42px; gap: 8px; align-items: center; }
|
||||
.routing-grid { display: grid; grid-template-columns: 42px minmax(104px,.7fr) minmax(92px,.55fr) 82px minmax(112px,.6fr) minmax(140px,1fr) 96px minmax(120px,.65fr) minmax(120px,.65fr) 42px; gap: 8px; align-items: center; }
|
||||
.route-speed-cell { display: flex; align-items: center; gap: 4px; }
|
||||
.route-speed-cell input { width: 100%; min-width: 0; text-align: center; }
|
||||
.route-speed-preview { flex-shrink: 0; width: 30px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.routing-header { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); }
|
||||
.routing-header div { font-size: 14px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.routing-row { border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 8px 10px; }
|
||||
@ -4861,17 +4864,29 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
}
|
||||
.reh-console-transport { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
|
||||
.reh-console-progress { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 80px; }
|
||||
.reh-console-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; flex-wrap: wrap; }
|
||||
/* flex-shrink:0 with the flexbox-default min-width:auto meant this block could
|
||||
never shrink below its unwrapped natural width (confirmed live: ~1560px of
|
||||
buttons in a 524px-wide bar) — flex-wrap never got a chance to kick in since
|
||||
the item was never considered "too wide to fit" by the wrapping algorithm,
|
||||
so on any real (non-ultrawide) window the tail of the toolbar — font size,
|
||||
page mode, Train, Exit — silently fell off-screen with no scrollbar to
|
||||
reach it. Allowing it to shrink (and explicitly zeroing min-width, since
|
||||
flex items default to min-width:auto regardless of any width you set) lets
|
||||
it wrap onto additional rows within the available space instead. */
|
||||
.reh-console-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 1; min-width: 0; flex-wrap: wrap; }
|
||||
|
||||
/* Synth progress bar */
|
||||
/* Tone / style backend warning bar */
|
||||
.reh-tone-warn {
|
||||
display: flex; align-items: center; gap: 8px; padding: 7px 14px;
|
||||
display: flex; align-items: flex-start; gap: 8px; padding: 7px 14px;
|
||||
background: rgba(234,179,8,.1); border-bottom: 1px solid rgba(234,179,8,.35);
|
||||
font-size: 12px; color: #92400e;
|
||||
}
|
||||
.reh-tone-warn[hidden] { display: none; }
|
||||
.reh-tone-warn .mdi { color: #d97706; font-size: 14px; flex-shrink: 0; }
|
||||
/* Aligns the alert icon with the table's own text baseline rather than the
|
||||
whole (now taller, multi-row) bar. */
|
||||
.reh-tone-warn > .mdi { margin-top: 3px; }
|
||||
.reh-tone-warn-close {
|
||||
margin-left: auto; background: none; border: none; cursor: pointer;
|
||||
color: #92400e; font-size: 13px; line-height: 1; padding: 2px 4px; border-radius: 3px;
|
||||
@ -4879,6 +4894,27 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
}
|
||||
.reh-tone-warn-close:hover { opacity: 1; background: rgba(0,0,0,.06); }
|
||||
|
||||
/* Backend tone/identity comparison table — replaced a run-on sentence with a
|
||||
button wedged mid-clause (confirmed live: read badly and wrapped worse).
|
||||
Same two facts, laid out as two columns instead of two clauses. */
|
||||
.reh-tone-cmp { border-collapse: collapse; font-size: 12px; }
|
||||
.reh-tone-cmp th {
|
||||
text-align: left; font-weight: 600; padding: 2px 14px 4px 0; color: #92400e;
|
||||
opacity: .75; white-space: nowrap;
|
||||
}
|
||||
.reh-tone-cmp td { padding: 3px 14px 3px 0; white-space: nowrap; }
|
||||
.reh-tone-cmp tr:first-child td { padding-top: 0; }
|
||||
.reh-tone-cmp-current td:first-child { font-weight: 700; }
|
||||
.reh-tone-cmp-current-tag {
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: #92400e; opacity: .7;
|
||||
}
|
||||
.reh-tone-cmp-yes { color: #15803d; }
|
||||
.reh-tone-cmp-no { color: #b91c1c; opacity: .8; }
|
||||
.reh-tone-cmp-yes .mdi, .reh-tone-cmp-no .mdi { font-size: 13px; }
|
||||
.reh-tone-switch-btn { padding: 2px 10px !important; font-size: 11px !important; }
|
||||
.reh-tone-cmp-note { margin: 4px 0 0; font-size: 11.5px; opacity: .85; }
|
||||
|
||||
/* Synth progress bar — more prominent while running */
|
||||
.reh-synth-bar {
|
||||
display: flex; align-items: center; gap: 10px; padding: 7px 16px;
|
||||
@ -4912,7 +4948,19 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
|
||||
/* Play font-size stepper (+A / -A) */
|
||||
.reh-fontsize-ctl { display: inline-flex; gap: 2px; }
|
||||
.reh-fontsize-btn { font-weight: 700; padding: 2px 7px; }
|
||||
/* No padding override here on purpose — confirmed live these two buttons
|
||||
rendered ~18px shorter than every sibling toolbar button (2px 7px vs the
|
||||
base .btn-secondary.btn-sm's 9px 18px) because this rule fought that
|
||||
padding instead of inheriting it. Font weight is the only real
|
||||
customization these need. */
|
||||
/* Padding now matches every sibling button exactly, but a residual gap
|
||||
remained — plain "A" + superscript text computes a shorter natural
|
||||
line-height than an icon+text button, and guessing at a line-height
|
||||
multiplier to compensate (tried 1: overshot the other way, to 35.7px
|
||||
against siblings' 43px) is fragile. Match the sibling toolbar buttons'
|
||||
actual height directly instead of trying to reverse-engineer it via
|
||||
line-height. */
|
||||
.reh-fontsize-btn { font-weight: 700; height: 43px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.reh-fontsize-btn .reh-fontsize-plus, .reh-fontsize-btn .reh-fontsize-minus { font-size: 9px; vertical-align: super; margin-left: 1px; }
|
||||
|
||||
/* Act heading on A4 page */
|
||||
@ -4924,6 +4972,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Chapter marker — a horizontal rule with the chapter's own title/number
|
||||
between the two halves, so a chapter break is visible while scrolling the
|
||||
script itself and not just audible as a pause in the exported audiobook. */
|
||||
.reh-chapter-marker {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin: 32px 0 20px; position: relative;
|
||||
}
|
||||
.reh-chapter-marker-line { flex: 1; height: 1px; background: var(--border); }
|
||||
.reh-chapter-marker-label {
|
||||
font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
|
||||
font-size: 13px; color: var(--subtext); white-space: nowrap; padding: 0 2px;
|
||||
}
|
||||
.reh-chapter-marker:hover .reh-edit-btn { opacity: 1; }
|
||||
|
||||
/* Inline edit button — shown on hover */
|
||||
.reh-edit-btn {
|
||||
opacity: 0; transition: opacity .12s; border: 1px solid transparent;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user