feat: Read Aloud reader, multi-voice audiobook casting, character sheets (v1.7.0)
Read Aloud (new "Vorlesen" tab):
- PDF (real page render + overlay highlight) / TXT reader with live word
highlighting, voice + speed, per-sentence synthesis-state colours, zoom
(fit-width/height, two-page, ±), resume, and a server-side book library
(syncs across devices; per-unit MP3 audio fetched on demand).
Book -> multi-speaker audiobook:
- "Cast as audiobook" attributes dialogue to characters via the LLM
(guillemet/quote-style aware, turn-taking, recent-context), with a
deterministic speech-tag fallback. Editable preview, non-blocking live
casting panel, then auto-saved as a reopenable Script Rehearser play.
- Audiobook export: synthesise every cast line -> one MP3 per chapter.
Character sheets:
- LLM-extracted, self-filling RPG-style sheets (with page+quote sources)
in both Read Aloud and the Rehearser.
Also: MP3 storage + per-page/sentence export, voice-library "Precompute
embeddings" pre-warm, German "Vorlesen" i18n + flag language toggle,
large-PDF performance (lazy raster, buffer/canvas eviction, yielded parse),
and the Seed Finder changelog entry.
New: routes/reader.py, POST /api/attribute-dialogue, POST /api/character-sheets,
static/js/{reader,audiobook,character-sheets}.js, static/sections/s-reader.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
fff6e7e096
commit
36437478a6
1
.gitignore
vendored
1
.gitignore
vendored
@ -37,6 +37,7 @@ APP_ERKLÄRUNG.md
|
|||||||
config/settings.json
|
config/settings.json
|
||||||
config/voice_design_presets.json
|
config/voice_design_presets.json
|
||||||
config/tts_routes.json
|
config/tts_routes.json
|
||||||
|
config/reader_library/
|
||||||
logs/*.log
|
logs/*.log
|
||||||
logs/*.log.*
|
logs/*.log.*
|
||||||
|
|
||||||
|
|||||||
34
CHANGELOG.md
34
CHANGELOG.md
@ -7,7 +7,41 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.7.0] — 2026-06-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Seed Finder** — in **My Voices**, each voice's inspector has a 🎲 **Seed Finder** panel: generate a sample for a range of seeds (the TTS engine produces a slightly different take per seed), play each result, and click **★ Use seed N** to pin your favourite — saved immediately to the TTS server's `voices.json`. New `/api/tts-voice-seed` proxy, module `static/js/seed-finder.js`.
|
||||||
|
- **Voice library — Precompute embeddings**: a **Precompute** button warms all active voices so the TTS engine builds and caches each voice's speaker embedding (`.pt`) ahead of time, making the *first* playback of a voice instant (better Time-To-First-Audio) instead of paying the one-time analysis cost on first use. Runs with bounded concurrency, progress, and cancel. (The faster-qwen3-tts engine already prefers a cached `.pt` and auto-creates it from the reference wav when missing — the wav stays the source of truth; this just pre-warms the cache.)
|
||||||
|
- **Character sheets** — a **Character sheets** button in both **Read Aloud** and the **Script Rehearser** uses your configured LLM to extract actor-facing, RPG-style profiles for every character: Archetype, Physical Stats (metric-only), Alignment & Ethos, Core Attributes (highest/lowest), Trained Skills, Signature Inventory, Dark Secret / Fatal Flaw, Conflict Style, and Win Condition. Deduced details are marked with `*`, characters are grouped **main vs supporting**, and each sheet cites its **sources** (page number from the PDF + a short verbatim quote). Long texts are processed in chunks and merged per character (fields filled in, inventory/sources de-duplicated). Results render as scannable cards in an overlay with **Copy as Markdown**, and are cached so re-opening is instant. New endpoint `POST /api/character-sheets`, module `static/js/character-sheets.js`.
|
||||||
|
- **Book → multi-speaker audiobook** — a **Cast as audiobook** button in Read Aloud turns a novel into a cast-able script. An LLM scans the current scope (selection / page range / whole book, chunked with a running character roster so the same speaker keeps one name throughout) and attributes every segment to a **Narrator** or a **character**, with a per-line **emotion**. New endpoint `POST /api/attribute-dialogue` and module `static/js/audiobook.js`; reuses the rehearser's casting, per-line tone, and synthesis. Failed chunks fall back to narration so a book always casts; progress is shown and cancellable.
|
||||||
|
- **Live casting view**: while attributing, a **wide, non-blocking, minimisable panel** shows a scrolling feed of each line with its assigned speaker (and emotion) plus a **character roster that fills up** with per-character line counts — instead of a bare modal progress bar. You can **minimise it and keep using the app** (any tab), then re-open to watch progress; when it finishes it parks as a **"✓ Review & cast"** panel rather than auto-popping, so it waits for you if you wandered off.
|
||||||
|
- **Calmer messaging**: a passage the LLM can't attribute is no longer shown as a red "attribution failed: Error" toast — it's a quiet "read by the narrator" note in the feed, with a single neutral summary ("N passages had no detected dialogue") in the review step.
|
||||||
|
- **Editable preview**: before handing off, a review overlay lists every segment with an editable **speaker** (autocompletes from detected characters) and **emotion** so mis-attributions are fixed in seconds; "Open in Rehearser" applies the edits and lands you at the Cast phase.
|
||||||
|
- **Audiobook export** (rehearser): an **Audiobook** button synthesises every cast line as MP3 (bounded concurrency, progress, cancel) and downloads **one MP3 per chapter** (split on Chapter/Kapitel/Part/Prologue… headings or act/scene markers), or a single file when no chapters are detected.
|
||||||
|
- **Saved as a reopenable rehearsal**: handing the cast off to the Rehearser now also **saves it to the Script Rehearser library (Bibliothek)** automatically, so the attributed script + cast + per-line emotions persist — reopen it anytime to change speakers/voices/lines and synthesise or export the audiobook.
|
||||||
|
- **Read Aloud** — a new sidebar tab that turns the app into a text-to-speech document reader. Import a **PDF** (rendered to its real page layout via pdf.js) or a **.txt / .md** file, pick any voice + backend and a **reading speed** (0.5×–2×), then press play: the document is read sentence-by-sentence while the **word being spoken is highlighted** in place (overlay box on the PDF page, inline highlight in text mode), with the view auto-scrolling to follow. Click any word to jump there. Reuses the rehearser's word-timing + pdf.js loader and the existing `/api/tts-preview` pipeline — no backend changes. New files `static/sections/s-reader.html` and `static/js/reader.js`.
|
||||||
|
- **Read Aloud — synthesis-state overlay**: every sentence is colour-coded by state — **red** (not synthesised), **yellow** (synthesising), **green** (ready/cached), **blue** (currently reading) — shown as a translucent overlay on the PDF page and as a tint in text mode, with a legend.
|
||||||
|
- **Read Aloud — PDF zoom controls**: Fit width, Fit height, Two-page spread, and zoom in/out with a live percentage. Word geometry is stored scale-independently so zoom re-renders instantly and highlights stay aligned; fit modes track window resizes.
|
||||||
|
- **Read Aloud — resume**: the last reading position is remembered per document, so re-opening the same file resumes where you left off.
|
||||||
|
- **Read Aloud — book library**: **Save to library** stores the original PDF/text together with its **synthesised audio** and reading position in the browser (IndexedDB). A "My books" shelf lists saved documents with audio- and read-progress bars; reopen one to continue right where you left off with the already-synthesised pages intact — handy for working through long books. Reading position auto-saves on pause / stop / leaving the tab.
|
||||||
|
- **Read Aloud — MP3 storage & export**: audio is now synthesised and stored as **MP3** (far smaller than WAV, so books fit comfortably in the browser library). An **Export MP3** control downloads the synthesised audio as **one file per page** (sections combined) or **one file per sentence**, with meaningful filenames like `Title - p01 - 03.mp3` / `Title - p01.mp3`. Any not-yet-synthesised sentences in scope are rendered first.
|
||||||
|
- **Read Aloud — voice consistency**: addresses the slight timbre/prosody drift you hear when each sentence is generated separately. A **"Voice consistency"** selector synthesises in larger continuous chunks — *per sentence* (responsive), *per paragraph* (steadier), or *per page* (steadiest) — so a whole passage is one generation. Optional **Seed** and **Temperature** inputs pin the generation (forwarded to backends that support them, with graceful fallback), **Normalise loudness** evens out volume between chunks on playback, and a backend hint flags cloned/zero-shot engines that re-sample per request and suggests remedies. Chunk mode + seed/temperature/normalise are saved with library books.
|
||||||
|
- **Read Aloud — synthesise ahead**: a **Synthesise** button pre-renders audio for gap-free reading, scoped to **all**, a **page range** (PDF), or a **click-selected sentence range**. Select mode is a guided, persistent step flow — click a start sentence (it pulses as the anchor), then the end; the mode stays active with step hints so you can keep refining, and you leave it with the **Done** button or **Esc**. Synthesis runs with bounded concurrency, shows progress, and can be cancelled; the synthesis-state colours fill in green as each sentence completes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Audiobook casting — smarter speaker attribution**: the LLM prompt now reasons about **conversational turn-taking** (in a two-person exchange speakers alternate, so untagged lines are attributed by context rather than dumped as “Unknown”), and each passage is given the **recent dialogue** from the previous one so a conversation continues correctly across passage boundaries. The deterministic fallback (used when the LLM is unavailable) also got a conservative two-person turn-taking fill and a stop-list that rejects common German non-name words (Sofort, Stimme, Frage, Plötzlich…), so it no longer invents bogus characters.
|
||||||
|
- **Character sheets — self-filling across the book**: sheets now build up progressively — each passage receives the **sheet-so-far** (with which fields each character still needs) and the model fills gaps and refines instead of starting from scratch, so details accumulate as more of the book is read.
|
||||||
|
- **Read Aloud — library now lives on the server (syncs across devices)**: saved books, their synthesised audio, and reading position were previously stored only in the browser (IndexedDB), so a book saved on the laptop never appeared on the desktop. The library now persists under the server's config volume (`reader_library/<id>/` with `meta.json`, the source document, and per-unit MP3s) via new `/api/reader/docs…` endpoints. Any device pointed at the same server sees the same "My books" shelf; opening a book is instant and its audio streams **per chunk on demand** (nothing is bulk-downloaded), and saves stay incremental (only new chunks upload).
|
||||||
|
- **Language switcher** — replaced the sidebar language dropdown with a **flag toggle** next to the "Voice Creator" headline (click to switch interface language). Added German strings for **Read Aloud** ("Vorlesen") and its UI.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Read Aloud — memory & smoothness for long books**: decoded audio (uncompressed PCM) is now kept only for a small window around the playhead and re-decoded from the cached MP3 on demand; off-screen PDF page canvases are released and re-rastered on return — together these bound memory on big books (previously both grew unbounded and could crash long sessions). The next chunk is **pre-decoded** during playback for gapless transitions, transport actions no longer scan every unit (single tracked "reading" index), PDF sentences/units are built **incrementally per page** (no end-of-parse spike), and library **saves are incremental** — only newly-synthesised chunks are written (a separate per-unit audio store), instead of rewriting the whole book each save.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Audiobook casting — German (and other) quote styles not recognised**: dialogue marked with German guillemets `»…«` / `„…“` / `›…‹`, French `«…»`, curly `“…”`, CJK `「…」`, or em-dash speech was treated as narration, so books like German novels cast everything to the narrator. The LLM prompt now explicitly handles all these styles (with guillemets called out), passages with **no quotation marks skip the LLM entirely** (so genuine narration isn't shown as a failure), PDF line-break **hyphenation is mended** (`Schwer- tes` → `Schwertes`) for clean speech, and if the LLM call fails on a passage that *does* contain quotes, a **deterministic fallback** splits out the dialogue **and attributes speakers from speech tags** (`»…«, sagte Riskan` → Riskan; pronouns rejected) so the book stays castable with real names even when the LLM is offline.
|
||||||
|
- **Read Aloud — auto-scroll**: while reading, the view now scrolls only the document pane instead of the whole window, so the currently-spoken line no longer slides up under the app header out of view.
|
||||||
|
- **Read Aloud — backend dropdown stuck on "Checking…"**: the reader's TTS-backend select is now populated by the shared backend refresh and fetched on demand when the section opens, so it fills reliably even if backends finish loading after you're already on the tab.
|
||||||
|
- **Read Aloud — large PDFs froze the page** ("this page is not responding"): the page-parse loop now yields to the browser periodically (with a "Reading PDF… page x / n" indicator), and per-sentence status overlays are created lazily per page instead of all at once. A 60-page book now imports with a max main-thread stall of ~40 ms (was multi-second), creating only the visible pages' overlays.
|
||||||
- **Chunked TTS — "Failed to fetch" on long text**: `splitTextIntoChunks` only split on sentence terminators (`.!?`), so newline-delimited text (e.g. German bullet lists or care-plan notes) was never split — the full page was sent as one request, causing a TCP timeout that the browser surfaced as "Failed to fetch". Fixed by processing each line individually before applying the sentence regex. Also moved `generation.js` from deferred batch E into the main feature batch so `generateChunkedTts` is always defined before the user can click Generate.
|
- **Chunked TTS — "Failed to fetch" on long text**: `splitTextIntoChunks` only split on sentence terminators (`.!?`), so newline-delimited text (e.g. German bullet lists or care-plan notes) was never split — the full page was sent as one request, causing a TCP timeout that the browser surfaced as "Failed to fetch". Fixed by processing each line individually before applying the sentence regex. Also moved `generation.js` from deferred batch E into the main feature batch so `generateChunkedTts` is always defined before the user can click Generate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -435,6 +435,227 @@ def _extract_json_block(text: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/character-sheets")
|
||||||
|
async def character_sheets(request: Request):
|
||||||
|
"""Extract actor-facing RPG-style character sheets from a passage.
|
||||||
|
|
||||||
|
Body: {text, known_characters:[...], language, llm_url, model}
|
||||||
|
The text may contain "[p.N]" page markers so the model can cite sources.
|
||||||
|
Returns: {sheets:[{name, aliases, archetype, physical, alignment,
|
||||||
|
attribute_high, attribute_low, skills, inventory:[...], secret,
|
||||||
|
conflict_style, win_condition, tier:"main"|"supporting",
|
||||||
|
sources:[{page, quote}]}], characters:[names]}
|
||||||
|
Deduced (not explicit) values are marked with a trailing " *".
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
text: str = (data.get("text") or "").strip()
|
||||||
|
known: list = data.get("known_characters") or []
|
||||||
|
existing: str = (data.get("existing") or "").strip() # partial sheets so far (progressive fill)
|
||||||
|
language: str = (data.get("language") or "").strip()
|
||||||
|
_settings = _load_settings()
|
||||||
|
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||||
|
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(400, "No text provided")
|
||||||
|
|
||||||
|
lang_hint = f" The text language is {language}; write the sheet in that language." if language else ""
|
||||||
|
system = (
|
||||||
|
"You are an expert dramaturge and tabletop RPG game master building character sheets that FILL UP "
|
||||||
|
"as a book is read passage by passage. Read this passage and extract playable, action-oriented "
|
||||||
|
"character sheets — sheets an actor can use to immediately know how to PLAY the character.\n"
|
||||||
|
"PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any "
|
||||||
|
"NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or "
|
||||||
|
"blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty "
|
||||||
|
"if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue "
|
||||||
|
"and actions when reasonable, and mark any deduced value with a trailing ' *'.\n"
|
||||||
|
f"{lang_hint}\n"
|
||||||
|
"For each character output these fields:\n"
|
||||||
|
"- name, aliases\n"
|
||||||
|
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
|
||||||
|
"- physical: age, build, vocal quality, posture. Use ONLY the metric system for any height/weight.\n"
|
||||||
|
"- alignment: strict moral code + the one line they will never cross\n"
|
||||||
|
"- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility…)\n"
|
||||||
|
"- skills: what they are demonstrably good at in the story\n"
|
||||||
|
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
|
||||||
|
"- secret: dark secret or fatal flaw\n"
|
||||||
|
"- conflict_style: fight, flight, or manipulate — how they act when cornered\n"
|
||||||
|
"- win_condition: the specific event that would make them feel they have won\n"
|
||||||
|
"- tier: 'main' or 'supporting'\n"
|
||||||
|
"- sources: array of {page, quote} — the page number from the nearest [p.N] marker and a short "
|
||||||
|
"verbatim quote that supports the sheet (1-3 entries). Use null page if unknown.\n"
|
||||||
|
"Reuse the EXACT names from the known-characters list for returning characters. "
|
||||||
|
"Respond with STRICT JSON only:\n"
|
||||||
|
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","alignment":"",'
|
||||||
|
'"attribute_high":"","attribute_low":"","skills":"","inventory":[],"secret":"",'
|
||||||
|
'"conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":""}]}]}\n/no-think'
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
||||||
|
+ ("Sheets so far (fill gaps / refine; keep solid facts):\n" + existing + "\n\n" if existing else "")
|
||||||
|
+ "Passage:\n" + text
|
||||||
|
)
|
||||||
|
payload: dict = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
],
|
||||||
|
"temperature": 0.4,
|
||||||
|
"max_tokens": 3500,
|
||||||
|
}
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
|
headers={"Authorization": "Bearer no-key"}, timeout=240,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
raw = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
|
||||||
|
|
||||||
|
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||||
|
sheets = []
|
||||||
|
for cand in (content, _extract_json_block(content)):
|
||||||
|
if not cand:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(cand)
|
||||||
|
if isinstance(parsed, dict) and isinstance(parsed.get("sheets"), list):
|
||||||
|
sheets = parsed["sheets"]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
clean, names = [], []
|
||||||
|
for s in sheets:
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
name = str(s.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
inv = s.get("inventory")
|
||||||
|
if isinstance(inv, str):
|
||||||
|
inv = [x.strip() for x in inv.split(",") if x.strip()]
|
||||||
|
elif not isinstance(inv, list):
|
||||||
|
inv = []
|
||||||
|
src = s.get("sources") if isinstance(s.get("sources"), list) else []
|
||||||
|
s.update({"name": name, "inventory": inv[:3],
|
||||||
|
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
|
||||||
|
"sources": src[:3]})
|
||||||
|
clean.append(s)
|
||||||
|
names.append(name)
|
||||||
|
return {"sheets": clean, "characters": names}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/attribute-dialogue")
|
||||||
|
async def attribute_dialogue(request: Request):
|
||||||
|
"""Split a prose passage into attributed segments for a multi-voice audiobook.
|
||||||
|
|
||||||
|
Body: {text, known_characters:[...], language, llm_url, model}
|
||||||
|
Returns: {segments:[{speaker, type:"narration"|"dialogue", text, emotion}], characters:[names]}
|
||||||
|
The frontend calls this per chunk, passing the running character roster so the
|
||||||
|
same speaker keeps the same name across the whole book.
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
text: str = (data.get("text") or "").strip()
|
||||||
|
known: list = data.get("known_characters") or []
|
||||||
|
recent: str = (data.get("recent") or "").strip() # last few attributed lines, for continuity
|
||||||
|
language: str = (data.get("language") or "").strip()
|
||||||
|
_settings = _load_settings()
|
||||||
|
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||||
|
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(400, "No text provided")
|
||||||
|
|
||||||
|
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
|
||||||
|
system = (
|
||||||
|
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
|
||||||
|
"Split the passage into consecutive segments in reading order. For each segment output:\n"
|
||||||
|
"- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. "
|
||||||
|
"Use 'Unknown' ONLY as an absolute last resort.\n"
|
||||||
|
"- type: 'narration' or 'dialogue'\n"
|
||||||
|
"- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration\n"
|
||||||
|
"- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration\n"
|
||||||
|
f"{lang_hint}\n"
|
||||||
|
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\n"
|
||||||
|
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
|
||||||
|
"French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line "
|
||||||
|
"starts with — or – (Spanish/French/Polish style).\n"
|
||||||
|
"German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n"
|
||||||
|
"ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n"
|
||||||
|
"1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns "
|
||||||
|
"(er/sie/he/she) to the actual name from nearby context.\n"
|
||||||
|
"2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES "
|
||||||
|
"every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.\n"
|
||||||
|
"3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the "
|
||||||
|
"'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the "
|
||||||
|
"known-characters list. Reuse the EXACT known names.\n"
|
||||||
|
"4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and "
|
||||||
|
"context — this should be rare. Prefer the most likely named character over 'Unknown'.\n"
|
||||||
|
"RULES:\n"
|
||||||
|
"- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.\n"
|
||||||
|
"- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts "
|
||||||
|
"into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.\n"
|
||||||
|
"- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.\n"
|
||||||
|
"Respond with STRICT JSON only:\n"
|
||||||
|
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}\n/no-think'
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
||||||
|
+ ("Recent dialogue (the immediately preceding lines — continue the same conversation/turn-taking):\n" + recent + "\n\n" if recent else "")
|
||||||
|
+ "Passage:\n" + text
|
||||||
|
)
|
||||||
|
payload: dict = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
],
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": min(4096, max(700, len(text) // 2 + 800)),
|
||||||
|
}
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
|
headers={"Authorization": "Bearer no-key"}, timeout=180,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
raw = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"LLM attribution failed: {e}")
|
||||||
|
|
||||||
|
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||||
|
segments = []
|
||||||
|
for cand in (content, _extract_json_block(content)):
|
||||||
|
if not cand:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(cand)
|
||||||
|
if isinstance(parsed, dict) and isinstance(parsed.get("segments"), list):
|
||||||
|
segments = parsed["segments"]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Normalise + collect speaker roster
|
||||||
|
clean, chars = [], []
|
||||||
|
for seg in segments:
|
||||||
|
if not isinstance(seg, dict):
|
||||||
|
continue
|
||||||
|
t = str(seg.get("text") or "").strip()
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
sp = str(seg.get("speaker") or "Narrator").strip() or "Narrator"
|
||||||
|
typ = "dialogue" if str(seg.get("type") or "").lower().startswith("dial") else "narration"
|
||||||
|
if typ == "narration":
|
||||||
|
sp = "Narrator"
|
||||||
|
emo = str(seg.get("emotion") or "").strip()
|
||||||
|
clean.append({"speaker": sp, "type": typ, "text": t, "emotion": emo})
|
||||||
|
if typ == "dialogue" and sp.lower() != "narrator" and sp not in chars:
|
||||||
|
chars.append(sp)
|
||||||
|
return {"segments": clean, "characters": chars}
|
||||||
|
|
||||||
|
|
||||||
# ── Audio effects ─────────────────────────────────────────────────────────────
|
# ── Audio effects ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
|
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
|
||||||
|
|||||||
172
routes/reader.py
Normal file
172
routes/reader.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"""Server-side Read Aloud library so saved books sync across devices.
|
||||||
|
|
||||||
|
Stores each document under the writable config volume:
|
||||||
|
<CONFIG_DIR>/reader_library/<id>/
|
||||||
|
meta.json metadata (title, kind, idx, voice, counts, …)
|
||||||
|
source.pdf | source.txt the original document (for re-rendering)
|
||||||
|
audio/<idx>.mp3 per-unit synthesised audio (incremental)
|
||||||
|
|
||||||
|
Endpoints are deliberately small (file I/O) and mirror the previous IndexedDB
|
||||||
|
shape so the frontend swap is mechanical.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
|
|
||||||
|
from core.constants import CONFIG_DIR
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_LIB = CONFIG_DIR / "reader_library"
|
||||||
|
|
||||||
|
|
||||||
|
def _lib() -> Path:
|
||||||
|
_LIB.mkdir(parents=True, exist_ok=True)
|
||||||
|
return _LIB
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_dir(doc_id: str) -> Path:
|
||||||
|
# ids are server-generated uuid hex; reject anything else (path safety)
|
||||||
|
if not doc_id or not doc_id.isalnum() or len(doc_id) > 40:
|
||||||
|
raise HTTPException(400, "Bad document id")
|
||||||
|
return _lib() / doc_id
|
||||||
|
|
||||||
|
|
||||||
|
def _read_meta(d: Path) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads((d / "meta.json").read_text("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_indices(d: Path) -> list[int]:
|
||||||
|
ad = d / "audio"
|
||||||
|
if not ad.is_dir():
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for f in ad.glob("*.mp3"):
|
||||||
|
try:
|
||||||
|
out.append(int(f.stem))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reader/docs")
|
||||||
|
async def reader_list_docs():
|
||||||
|
lib = _lib()
|
||||||
|
docs = []
|
||||||
|
for d in lib.iterdir():
|
||||||
|
if not d.is_dir():
|
||||||
|
continue
|
||||||
|
meta = _read_meta(d)
|
||||||
|
if not meta:
|
||||||
|
continue
|
||||||
|
meta["id"] = d.name
|
||||||
|
meta["synthCount"] = len(_audio_indices(d))
|
||||||
|
docs.append(meta)
|
||||||
|
docs.sort(key=lambda m: m.get("updated") or "", reverse=True)
|
||||||
|
return {"docs": docs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/reader/docs")
|
||||||
|
async def reader_save_doc(request: Request):
|
||||||
|
meta = await request.json()
|
||||||
|
doc_id = str(meta.get("id") or "").strip()
|
||||||
|
if doc_id:
|
||||||
|
d = _doc_dir(doc_id)
|
||||||
|
if not d.is_dir():
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
else:
|
||||||
|
doc_id = uuid.uuid4().hex
|
||||||
|
d = _lib() / doc_id
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
meta["created"] = meta.get("created")
|
||||||
|
meta.pop("id", None)
|
||||||
|
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), "utf-8")
|
||||||
|
return {"id": doc_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reader/docs/{doc_id}")
|
||||||
|
async def reader_get_doc(doc_id: str):
|
||||||
|
d = _doc_dir(doc_id)
|
||||||
|
meta = _read_meta(d)
|
||||||
|
if not meta:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
meta["id"] = doc_id
|
||||||
|
meta["audioIdx"] = _audio_indices(d)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/reader/docs/{doc_id}")
|
||||||
|
async def reader_delete_doc(doc_id: str):
|
||||||
|
d = _doc_dir(doc_id)
|
||||||
|
if d.is_dir():
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/reader/docs/{doc_id}/progress")
|
||||||
|
async def reader_progress(doc_id: str, request: Request):
|
||||||
|
d = _doc_dir(doc_id)
|
||||||
|
meta = _read_meta(d)
|
||||||
|
if not meta:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
body = await request.json()
|
||||||
|
if "idx" in body:
|
||||||
|
meta["idx"] = body["idx"]
|
||||||
|
if "updated" in body:
|
||||||
|
meta["updated"] = body["updated"]
|
||||||
|
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), "utf-8")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/reader/docs/{doc_id}/source")
|
||||||
|
async def reader_put_source(doc_id: str, request: Request, ext: str = "pdf"):
|
||||||
|
d = _doc_dir(doc_id)
|
||||||
|
if not d.is_dir():
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
ext = "txt" if ext.lower() == "txt" else "pdf"
|
||||||
|
body = await request.body()
|
||||||
|
# clear any existing source of the other type
|
||||||
|
for old in d.glob("source.*"):
|
||||||
|
old.unlink()
|
||||||
|
(d / f"source.{ext}").write_bytes(body)
|
||||||
|
return {"ok": True, "bytes": len(body)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reader/docs/{doc_id}/source")
|
||||||
|
async def reader_get_source(doc_id: str):
|
||||||
|
d = _doc_dir(doc_id)
|
||||||
|
for ext, media in (("pdf", "application/pdf"), ("txt", "text/plain")):
|
||||||
|
f = d / f"source.{ext}"
|
||||||
|
if f.is_file():
|
||||||
|
return FileResponse(str(f), media_type=media)
|
||||||
|
raise HTTPException(404, "Source not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reader/docs/{doc_id}/audio")
|
||||||
|
async def reader_list_audio(doc_id: str):
|
||||||
|
return {"idx": _audio_indices(_doc_dir(doc_id))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/reader/docs/{doc_id}/audio/{idx}")
|
||||||
|
async def reader_put_audio(doc_id: str, idx: int, request: Request):
|
||||||
|
d = _doc_dir(doc_id)
|
||||||
|
if not d.is_dir():
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
(d / "audio").mkdir(exist_ok=True)
|
||||||
|
(d / "audio" / f"{idx}.mp3").write_bytes(await request.body())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reader/docs/{doc_id}/audio/{idx}")
|
||||||
|
async def reader_get_audio(doc_id: str, idx: int):
|
||||||
|
f = _doc_dir(doc_id) / "audio" / f"{idx}.mp3"
|
||||||
|
if not f.is_file():
|
||||||
|
raise HTTPException(404, "Audio not found")
|
||||||
|
return FileResponse(str(f), media_type="audio/mpeg")
|
||||||
@ -22,7 +22,7 @@ mkdirSync(outDir, { recursive: true });
|
|||||||
const MAIN = [
|
const MAIN = [
|
||||||
'voice-picker', 'voice-inspector', 'voice-sources', 'fishaudio-browser',
|
'voice-picker', 'voice-inspector', 'voice-sources', 'fishaudio-browser',
|
||||||
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
||||||
'benchmark', 'stt', 'rehearser-parse', 'rehearser',
|
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||||
].map(n => join(jsDir, n + '.js'));
|
].map(n => join(jsDir, n + '.js'));
|
||||||
|
|
||||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
|
||||||
from core.constants import STATIC_DIR, _BufferHandler
|
from core.constants import STATIC_DIR, _BufferHandler
|
||||||
from routes import admin, settings, library, stt, sources, docker, tts, conversation
|
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
@ -84,6 +84,7 @@ app.include_router(sources.router)
|
|||||||
app.include_router(docker.router)
|
app.include_router(docker.router)
|
||||||
app.include_router(tts.router)
|
app.include_router(tts.router)
|
||||||
app.include_router(conversation.router)
|
app.include_router(conversation.router)
|
||||||
|
app.include_router(reader.router)
|
||||||
|
|
||||||
# ── Static files ──────────────────────────────────────────────────────────────
|
# ── Static files ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -146,6 +146,7 @@
|
|||||||
<div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
|
<div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
|
||||||
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
|
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" data-nav-section="s-reader" onclick="navTo('s-reader')"> <span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span> Read Aloud</div>
|
||||||
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
|
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
|
||||||
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
|
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
|
||||||
|
|
||||||
@ -259,6 +260,7 @@
|
|||||||
<section class="page-section" id="s-settings" style="display:none"></section>
|
<section class="page-section" id="s-settings" style="display:none"></section>
|
||||||
<section class="page-section" id="s-llms" style="display:none"></section>
|
<section class="page-section" id="s-llms" style="display:none"></section>
|
||||||
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
||||||
|
<section class="page-section" id="s-reader" style="display:none"></section>
|
||||||
<section class="page-section" id="s-conversation" style="display:none"></section>
|
<section class="page-section" id="s-conversation" style="display:none"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
468
static/js/audiobook.js
Normal file
468
static/js/audiobook.js
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
// ── Book → multi-speaker audiobook ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Bridges the Read Aloud reader and the Script Rehearser: an LLM scans the
|
||||||
|
// document (in the current scope — selection / page range / whole book),
|
||||||
|
// attributes every segment to a speaker ("Narrator" or a character) with an
|
||||||
|
// emotion, then hands the result to the Script Rehearser as a cast-able script
|
||||||
|
// so each character gets its own voice. The rehearser is the editable preview:
|
||||||
|
// you fix any mis-attribution, cast voices, and synthesise there.
|
||||||
|
//
|
||||||
|
// Reuses: /api/attribute-dialogue (LLM), readerState + readerScopeIndices()
|
||||||
|
// (reader.js), parseScript / detectCharacters / rehState / rehDefaultLlmUrl
|
||||||
|
// (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js).
|
||||||
|
|
||||||
|
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
|
||||||
|
const _audiobook = { running: false, cancel: false };
|
||||||
|
|
||||||
|
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
||||||
|
// German »...«/„...“, French «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech.
|
||||||
|
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「]|(?:^|\n)\s*[—–]\s/;
|
||||||
|
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
|
||||||
|
|
||||||
|
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
|
||||||
|
// so the audiobook reads cleanly and speaker tags aren't split.
|
||||||
|
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
|
||||||
|
|
||||||
|
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
|
||||||
|
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
|
||||||
|
const AB_NOTNAME = new Set([
|
||||||
|
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
|
||||||
|
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
|
||||||
|
// common sentence-initial adverbs / interjections / abstractions that are NOT characters
|
||||||
|
'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort',
|
||||||
|
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich',
|
||||||
|
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
|
||||||
|
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
|
||||||
|
function audiobookGuessSpeaker(after, before) {
|
||||||
|
let m;
|
||||||
|
const ok = n => (n && !AB_NOTNAME.has(n)) ? n : null;
|
||||||
|
// NOTE: no 'i' flag — names must be genuinely capitalized; German speech verbs
|
||||||
|
// after a quote are lowercase, so this rejects pronouns like "sagte er".
|
||||||
|
// after the quote: ", sagte Riskan" / "sagte Riskan" (verb → name)
|
||||||
|
if ((m = new RegExp('^[\\s,;–-]*' + AB_SPEECH_VERBS + '\\s+(?:der|die|das|ein|eine)?\\s*' + _AB_NAME).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||||||
|
// after the quote: ", Riskan sagte" (name → verb)
|
||||||
|
if ((m = new RegExp('^[\\s,;–-]*' + _AB_NAME + '\\s+' + AB_SPEECH_VERBS).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||||||
|
// before the quote: "Riskan sagte:" / "Riskan fragte"
|
||||||
|
if ((m = new RegExp(_AB_NAME + '\\s+' + AB_SPEECH_VERBS + '[\\s:,–-]*$').exec(before || ''))) { const r = ok(m[1]); if (r) return r; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic fallback: split a passage into narration + dialogue by quotation
|
||||||
|
// spans and attribute speakers from the surrounding speech tags. Used when the LLM
|
||||||
|
// is unavailable so dialogue — and as many speakers as possible — are never lost.
|
||||||
|
const AB_QUOTE_SPAN = /»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|"([^"]+)"|「([^」]+)」|『([^』]+)』/g;
|
||||||
|
function audiobookSplitByQuotes(text) {
|
||||||
|
const spans = []; let m;
|
||||||
|
AB_QUOTE_SPAN.lastIndex = 0;
|
||||||
|
while ((m = AB_QUOTE_SPAN.exec(text))) {
|
||||||
|
spans.push({ start: m.index, end: AB_QUOTE_SPAN.lastIndex, quote: (m[1] || m[2] || m[3] || m[4] || m[5] || m[6] || m[7] || '').trim() });
|
||||||
|
}
|
||||||
|
if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }];
|
||||||
|
const out = []; let last = 0;
|
||||||
|
for (let k = 0; k < spans.length; k++) {
|
||||||
|
const sp = spans[k];
|
||||||
|
const pre = text.slice(last, sp.start);
|
||||||
|
if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' });
|
||||||
|
if (sp.quote) {
|
||||||
|
const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length);
|
||||||
|
const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown';
|
||||||
|
out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' });
|
||||||
|
}
|
||||||
|
last = sp.end;
|
||||||
|
}
|
||||||
|
const tail = text.slice(last);
|
||||||
|
if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' });
|
||||||
|
return audiobookTurnTaking(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO
|
||||||
|
// distinct named speakers are established nearby (conservative: won't guess in a
|
||||||
|
// monologue, so it rarely invents a wrong name).
|
||||||
|
function audiobookTurnTaking(segs) {
|
||||||
|
let a = null, b = null; // two most recent distinct named speakers (b = latest)
|
||||||
|
for (const s of segs) {
|
||||||
|
if (s.type !== 'dialogue') continue;
|
||||||
|
if (s.speaker && s.speaker !== 'Unknown') {
|
||||||
|
if (s.speaker !== b) { a = b; b = s.speaker; }
|
||||||
|
} else if (a && b && a !== b) {
|
||||||
|
s.speaker = a; // the other of the two → alternate
|
||||||
|
const t = a; a = b; b = t; // rotate so the next Unknown alternates back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
|
||||||
|
function audiobookLlmModel() { return $('reh-llm-model')?.value || ''; }
|
||||||
|
function audiobookLang() { return $('reh-design-lang')?.value || ''; }
|
||||||
|
|
||||||
|
// Gather the plain text of the current reader scope (selection > page range > all).
|
||||||
|
function audiobookScopeText() {
|
||||||
|
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
||||||
|
const raw = readerScopeIndices().map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress overlay ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function audiobookProgress(total) {
|
||||||
|
let ov = document.getElementById('audiobook-overlay');
|
||||||
|
if (!ov) {
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.id = 'audiobook-overlay';
|
||||||
|
ov.className = 'audiobook-overlay';
|
||||||
|
ov.innerHTML = `<div class="audiobook-box">
|
||||||
|
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
|
||||||
|
<div class="audiobook-msg" id="audiobook-msg">Analysing…</div>
|
||||||
|
<div class="reader-synth-track"><div class="reader-synth-fill" id="audiobook-fill"></div></div>
|
||||||
|
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="audiobook-cancel">Cancel</button></div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; });
|
||||||
|
}
|
||||||
|
ov.hidden = false;
|
||||||
|
const fill = ov.querySelector('#audiobook-fill');
|
||||||
|
const msg = ov.querySelector('#audiobook-msg');
|
||||||
|
return {
|
||||||
|
update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; },
|
||||||
|
done() { ov.hidden = true; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const _AB_PALETTE = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#14b8a6', '#6366f1', '#d946ef'];
|
||||||
|
|
||||||
|
// Live casting view: a scrolling feed of attributed lines + a character roster
|
||||||
|
// that fills up as speakers are discovered. Far clearer than a bare bar.
|
||||||
|
function audiobookCastView(total) {
|
||||||
|
document.getElementById('audiobook-overlay')?.remove();
|
||||||
|
// A floating, NON-blocking, minimisable panel — keep working in the app and
|
||||||
|
// come back to watch progress (no backdrop, no modal lock).
|
||||||
|
const ov = document.createElement('div');
|
||||||
|
ov.id = 'audiobook-overlay'; ov.className = 'ab-castpanel';
|
||||||
|
ov.innerHTML = `
|
||||||
|
<div class="ab-castpanel-head" id="ab-cv-head">
|
||||||
|
<span class="mdi mdi-drama-masks"></span>
|
||||||
|
<span class="ab-castpanel-title">Casting audiobook</span>
|
||||||
|
<span class="ab-castpanel-count" id="ab-cv-count">passage 0 / ${total}</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="ab-castpanel-btn" id="ab-cv-min" title="Minimise — keep working, come back later"><span class="mdi mdi-window-minimize"></span></button>
|
||||||
|
<button class="ab-castpanel-btn" id="ab-cv-x" title="Cancel"><span class="mdi mdi-close"></span></button>
|
||||||
|
</div>
|
||||||
|
<div class="reader-synth-track ab-castpanel-bar"><div class="reader-synth-fill" id="ab-cv-fill"></div></div>
|
||||||
|
<div class="ab-cv-body">
|
||||||
|
<div class="ab-cv-feed" id="ab-cv-feed"></div>
|
||||||
|
<div class="ab-cv-side">
|
||||||
|
<div class="ab-cv-side-head">Characters found</div>
|
||||||
|
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">listening…</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ab-castpanel-foot" id="ab-cv-foot" hidden></div>`;
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
const setMin = min => {
|
||||||
|
ov.classList.toggle('minimized', min);
|
||||||
|
const ic = ov.querySelector('#ab-cv-min .mdi');
|
||||||
|
if (ic) ic.className = 'mdi ' + (min ? 'mdi-window-maximize' : 'mdi-window-minimize');
|
||||||
|
};
|
||||||
|
ov.querySelector('#ab-cv-min').addEventListener('click', () => setMin(!ov.classList.contains('minimized')));
|
||||||
|
ov.querySelector('#ab-cv-x').addEventListener('click', () => { if (_audiobook.running) _audiobook.cancel = true; else ov.remove(); });
|
||||||
|
ov.querySelector('#ab-cv-head').addEventListener('click', e => { if (ov.classList.contains('minimized') && !e.target.closest('button')) setMin(false); });
|
||||||
|
const fill = ov.querySelector('#ab-cv-fill'), count = ov.querySelector('#ab-cv-count');
|
||||||
|
const feed = ov.querySelector('#ab-cv-feed'), chars = ov.querySelector('#ab-cv-chars');
|
||||||
|
const roster = new Map(); // name -> { count, color }
|
||||||
|
const colorFor = name => {
|
||||||
|
if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] });
|
||||||
|
return roster.get(name).color;
|
||||||
|
};
|
||||||
|
const renderRoster = () => {
|
||||||
|
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||||
|
chars.innerHTML = items.length
|
||||||
|
? items.map(([n, info]) => `<span class="ab-chip" style="--c:${info.color}"><span class="ab-chip-dot"></span>${escHtml(n)}<b>${info.count}</b></span>`).join('')
|
||||||
|
: '<span class="ab-cv-empty">listening…</span>';
|
||||||
|
};
|
||||||
|
const MAXROWS = 80;
|
||||||
|
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
|
||||||
|
return {
|
||||||
|
update(done) { if (fill) fill.style.width = (done / total * 100) + '%'; if (count) count.textContent = `passage ${done} / ${total}`; },
|
||||||
|
addSegments(segs) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const s of segs) {
|
||||||
|
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
|
||||||
|
if (dialog) { const c = colorFor(s.speaker); roster.get(s.speaker).count++;
|
||||||
|
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
|
||||||
|
} else {
|
||||||
|
row.innerHTML = `<span class="ab-cv-spk">Narrator</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
|
||||||
|
}
|
||||||
|
frag.appendChild(row);
|
||||||
|
}
|
||||||
|
feed.appendChild(frag); trim(); renderRoster();
|
||||||
|
},
|
||||||
|
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
|
||||||
|
// Park the panel in a "done" state with a Review button instead of auto-popping
|
||||||
|
// the preview — so it waits for you if you wandered off to do something else.
|
||||||
|
complete(summary, onOpen) {
|
||||||
|
if (count) count.textContent = 'done';
|
||||||
|
if (fill) fill.style.width = '100%';
|
||||||
|
ov.querySelector('#ab-cv-min').hidden = true;
|
||||||
|
ov.querySelector('#ab-cv-x').title = 'Dismiss';
|
||||||
|
const foot = ov.querySelector('#ab-cv-foot');
|
||||||
|
foot.hidden = false;
|
||||||
|
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review & cast</button>`;
|
||||||
|
foot.querySelector('#ab-cv-review').addEventListener('click', () => { ov.remove(); onOpen(); });
|
||||||
|
// gently nudge the panel open if it was minimised while you were away
|
||||||
|
ov.classList.add('ab-castpanel-done');
|
||||||
|
},
|
||||||
|
done() { ov.remove(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attribution → script handoff ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function audiobookCast() {
|
||||||
|
if (_audiobook.running) return;
|
||||||
|
const text = audiobookScopeText();
|
||||||
|
if (!text) { toast('Import a document first', 'error'); return; }
|
||||||
|
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
|
||||||
|
|
||||||
|
const chunks = (typeof splitTextIntoChunks === 'function')
|
||||||
|
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
|
||||||
|
: [text];
|
||||||
|
|
||||||
|
_audiobook.running = true; _audiobook.cancel = false;
|
||||||
|
const view = audiobookCastView(chunks.length);
|
||||||
|
const llm_url = audiobookLlmUrl(), model = audiobookLlmModel(), language = audiobookLang();
|
||||||
|
|
||||||
|
const allSegments = [];
|
||||||
|
const roster = [];
|
||||||
|
let narrationOnly = 0; // passages with no quotes at all — legitimately all narration
|
||||||
|
let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
if (_audiobook.cancel) break;
|
||||||
|
view.update(i);
|
||||||
|
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
|
||||||
|
if (!audiobookHasDialogue(chunks[i])) {
|
||||||
|
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' };
|
||||||
|
allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
|
||||||
|
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown')
|
||||||
|
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/attribute-dialogue', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), recent, language, llm_url, model }),
|
||||||
|
});
|
||||||
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||||
|
data = await r.json();
|
||||||
|
} catch (_) { data = null; }
|
||||||
|
|
||||||
|
let segs = data && Array.isArray(data.segments) ? data.segments : [];
|
||||||
|
if (!segs.length) {
|
||||||
|
// LLM unavailable or returned nothing, but this passage HAS quotes —
|
||||||
|
// extract dialogue + attribute speakers from speech tags so it isn't lost.
|
||||||
|
segs = audiobookSplitByQuotes(chunks[i]);
|
||||||
|
degraded++;
|
||||||
|
const named = segs.filter(s => s.type === 'dialogue' && s.speaker !== 'Unknown').length;
|
||||||
|
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
|
||||||
|
}
|
||||||
|
// harvest speaker names (from LLM or tag heuristic) into the running roster
|
||||||
|
(data && data.characters || []).forEach(n => { if (n && n !== 'Unknown' && !roster.includes(n)) roster.push(n); });
|
||||||
|
segs.forEach(s => { if (s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown' && !roster.includes(s.speaker)) roster.push(s.speaker); });
|
||||||
|
segs.forEach(s => allSegments.push(s));
|
||||||
|
view.addSegments(segs);
|
||||||
|
}
|
||||||
|
view.update(chunks.length);
|
||||||
|
} finally {
|
||||||
|
_audiobook.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_audiobook.cancel) { view.done(); toast('Casting cancelled', 'error'); return; }
|
||||||
|
if (!allSegments.length) { view.done(); toast('No segments produced', 'error'); return; }
|
||||||
|
|
||||||
|
_audiobook.segments = allSegments;
|
||||||
|
_audiobook.roster = roster;
|
||||||
|
_audiobook.narratedPassages = narrationOnly;
|
||||||
|
_audiobook.degraded = degraded;
|
||||||
|
// 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 summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
|
||||||
|
view.complete(summary, audiobookShowPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editable attribution preview ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function audiobookShowPreview() {
|
||||||
|
const segs = _audiobook.segments || [];
|
||||||
|
const roster = _audiobook.roster || [];
|
||||||
|
document.getElementById('audiobook-preview')?.remove();
|
||||||
|
const ov = document.createElement('div');
|
||||||
|
ov.id = 'audiobook-preview';
|
||||||
|
ov.className = 'audiobook-overlay';
|
||||||
|
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
|
||||||
|
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
|
||||||
|
const charCount = speakerSet.filter(n => n !== 'Narrator' && n !== 'Unknown').length;
|
||||||
|
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
|
||||||
|
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
|
||||||
|
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast & lines
|
||||||
|
<span class="audiobook-count">${segs.length} segments · ${charCount} character${charCount !== 1 ? 's' : ''}</span></div>
|
||||||
|
<div class="audiobook-msg">Fix any wrong speaker or emotion, then open in the Script Rehearser to assign voices.${_audiobook.narratedPassages ? ` <span class="audiobook-narr-note">${_audiobook.narratedPassages} passage${_audiobook.narratedPassages !== 1 ? 's' : ''} had no dialogue (narrator).</span>` : ''}${_audiobook.degraded ? ` <span class="audiobook-narr-note">${_audiobook.degraded} passage${_audiobook.degraded !== 1 ? 's' : ''} used quick detection — set the “Unknown” speakers.</span>` : ''}</div>
|
||||||
|
<datalist id="audiobook-roster">${opts}</datalist>
|
||||||
|
<div class="audiobook-seglist" id="audiobook-seglist"></div>
|
||||||
|
<div class="audiobook-actions">
|
||||||
|
<button class="btn-secondary btn-sm" id="audiobook-preview-cancel">Cancel</button>
|
||||||
|
<button class="btn-primary btn-sm" id="audiobook-preview-open"><span class="mdi mdi-account-music-outline"></span> Open in Rehearser</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
|
||||||
|
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
|
||||||
|
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
|
||||||
|
<div class="audiobook-seg-text">${escHtml(s.text)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
||||||
|
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function audiobookApplyPreviewAndOpen() {
|
||||||
|
const segs = _audiobook.segments;
|
||||||
|
document.querySelectorAll('#audiobook-seglist .audiobook-seg-sp').forEach(inp => {
|
||||||
|
const i = +inp.dataset.i; const v = inp.value.trim() || 'Narrator';
|
||||||
|
segs[i].speaker = v;
|
||||||
|
segs[i].type = (v.toLowerCase() === 'narrator') ? 'narration' : 'dialogue';
|
||||||
|
});
|
||||||
|
document.querySelectorAll('#audiobook-seglist .audiobook-seg-emo').forEach(inp => {
|
||||||
|
const i = +inp.dataset.i; segs[i].emotion = inp.value.trim();
|
||||||
|
});
|
||||||
|
const { script, emotions } = audiobookBuildScript(segs);
|
||||||
|
audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a rehearser script (CAPS speaker + line; narration as plain paragraphs)
|
||||||
|
// and a parallel list of per-dialogue-line emotions (same order as dialog lines).
|
||||||
|
function audiobookBuildScript(segments) {
|
||||||
|
let script = '';
|
||||||
|
const emotions = [];
|
||||||
|
for (const s of segments) {
|
||||||
|
const t = (s.text || '').trim(); if (!t) continue;
|
||||||
|
const isDialogue = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
|
||||||
|
if (isDialogue) {
|
||||||
|
script += '\n' + s.speaker.toUpperCase() + '\n' + t + '\n';
|
||||||
|
emotions.push(s.emotion || '');
|
||||||
|
} else {
|
||||||
|
script += '\n' + t + '\n'; // narration → narrator reads these
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { script: script.trim(), emotions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function audiobookOpenInRehearser(script, title, dialogueEmotions) {
|
||||||
|
if ($('reh-script-text')) $('reh-script-text').value = script;
|
||||||
|
if ($('reh-script-title')) $('reh-script-title').value = title;
|
||||||
|
if (typeof navTo === 'function') navTo('s-rehearser');
|
||||||
|
// Reuse the rehearser's own parse flow (builds lines, cast, jumps to Cast phase)
|
||||||
|
const btn = $('reh-parse-btn');
|
||||||
|
if (btn) btn.click();
|
||||||
|
else if (typeof parseScript === 'function') { rehState.lines = parseScript(script); }
|
||||||
|
// Apply per-line emotions to dialog lines in order (Phase 3: emotion-aware narration)
|
||||||
|
let speakers = 0, lines = 0;
|
||||||
|
if (window.rehState && Array.isArray(rehState.lines)) {
|
||||||
|
let k = 0;
|
||||||
|
rehState.lines.forEach(l => { if (l.type === 'dialog') { const e = dialogueEmotions[k++]; if (e) l.emotion = e; lines++; } });
|
||||||
|
speakers = Object.keys(rehState.cast || {}).filter(s => !String(s).includes('NARRATOR')).length;
|
||||||
|
}
|
||||||
|
// Persist as a reopenable rehearsal so the cast/lines aren't lost — find it under
|
||||||
|
// Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise.
|
||||||
|
let saved = false;
|
||||||
|
if (typeof saveToLibrary === 'function') {
|
||||||
|
try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) {}
|
||||||
|
}
|
||||||
|
toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audiobook export (rehearser): synthesise every line → MP3 per chapter ────
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function audiobookLineVoice(l) {
|
||||||
|
if (l.type === 'dialog') {
|
||||||
|
const c = rehState.cast[l.speaker] || {};
|
||||||
|
return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion) : '') };
|
||||||
|
}
|
||||||
|
return { voice: rehState.narratorVoice, instruct: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function audiobookExport() {
|
||||||
|
if (_audiobook.running) return;
|
||||||
|
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
|
||||||
|
if (!rehState.backend) { toast('Select a TTS backend in the rehearser first', 'error'); return; }
|
||||||
|
if (typeof _ensureNarrator === 'function') _ensureNarrator();
|
||||||
|
|
||||||
|
const speakable = i => {
|
||||||
|
const l = rehState.lines[i];
|
||||||
|
if (!l || l.ignored || l.hidden) return false;
|
||||||
|
if (l.type === 'dialog') { const c = rehState.cast[l.speaker]; return !!(c && c.voice && c.voice !== 'me'); }
|
||||||
|
return !!(rehState.narratorVoice && (l.text || '').trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bucket speakable lines into chapters (by chapter headings / act / scene)
|
||||||
|
const buckets = [];
|
||||||
|
let cur = null;
|
||||||
|
rehState.lines.forEach((l, i) => {
|
||||||
|
if (audiobookIsChapter(l)) { cur = { title: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text).trim().slice(0, 50), idx: [] }; buckets.push(cur); }
|
||||||
|
if (speakable(i)) { if (!cur) { cur = { title: '', idx: [] }; buckets.push(cur); } cur.idx.push(i); }
|
||||||
|
});
|
||||||
|
const allIdx = buckets.flatMap(b => b.idx);
|
||||||
|
if (!allIdx.length) { toast('Nothing to synthesise — cast voices first', 'error'); return; }
|
||||||
|
|
||||||
|
_audiobook.running = true; _audiobook.cancel = false;
|
||||||
|
const prog = audiobookProgress(allIdx.length);
|
||||||
|
const mp3 = new Map();
|
||||||
|
let done = 0;
|
||||||
|
const queue = allIdx.slice();
|
||||||
|
const worker = async () => {
|
||||||
|
while (queue.length && !_audiobook.cancel) {
|
||||||
|
const i = queue.shift();
|
||||||
|
const l = rehState.lines[i];
|
||||||
|
const { voice, instruct } = audiobookLineVoice(l);
|
||||||
|
const text = (typeof _rehInlineTone === 'function')
|
||||||
|
? _rehInlineTone(stripMarkdown(l.text), l.emotion)
|
||||||
|
: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text);
|
||||||
|
try { mp3.set(i, await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, rehState.backend)); } catch (_) {}
|
||||||
|
prog.update(++done, `Synthesising line ${done} / ${allIdx.length}…`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try { await Promise.all(Array.from({ length: Math.min(2, allIdx.length) }, worker)); }
|
||||||
|
finally { prog.done(); _audiobook.running = false; }
|
||||||
|
|
||||||
|
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
|
||||||
|
const title = (typeof readerSafeName === 'function' ? readerSafeName($('reh-script-title')?.value || 'Audiobook') : ($('reh-script-title')?.value || 'Audiobook'));
|
||||||
|
const realChapters = buckets.filter(b => b.title).length > 0;
|
||||||
|
let files = 0;
|
||||||
|
for (let c = 0; c < buckets.length; c++) {
|
||||||
|
const blobs = buckets[c].idx.map(i => mp3.get(i)).filter(Boolean);
|
||||||
|
if (!blobs.length) continue;
|
||||||
|
const blob = new Blob(blobs, { type: 'audio/mpeg' });
|
||||||
|
const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';
|
||||||
|
const name = (realChapters || buckets.length > 1)
|
||||||
|
? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.mp3`
|
||||||
|
: `${title}.mp3`;
|
||||||
|
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
||||||
|
files++;
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
toast('Exported audiobook · ' + files + (realChapters ? ' chapter MP3 file(s)' : ' MP3 file(s)'), 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wiring ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$('reader-audiobook-btn')?.addEventListener('click', audiobookCast);
|
||||||
|
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
|
||||||
232
static/js/character-sheets.js
Normal file
232
static/js/character-sheets.js
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// ── Character sheets ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Actor-facing, RPG-style character sheets extracted by the user's LLM from a
|
||||||
|
// document (Read Aloud) or a script (Script Rehearser). Each claim cites a
|
||||||
|
// source (page + short quote). Shared by both sections via one overlay.
|
||||||
|
//
|
||||||
|
// Reuses: /api/character-sheets (LLM), readerState/readerScopeIndices (reader.js),
|
||||||
|
// rehState/stripMarkdown/rehDefaultLlmUrl (rehearser.js), splitTextIntoChunks
|
||||||
|
// (generation.js), $ / escHtml / toast (utils.js).
|
||||||
|
|
||||||
|
const CS_CHUNK_CHARS = 4000;
|
||||||
|
const _cs = { running: false, cancel: false, cache: {} };
|
||||||
|
|
||||||
|
function csLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
|
||||||
|
function csLlmModel() { return $('reh-llm-model')?.value || ''; }
|
||||||
|
function csLang() { return $('reh-design-lang')?.value || ''; }
|
||||||
|
|
||||||
|
// Build page-annotated text from the current reader scope ([p.N] at page changes).
|
||||||
|
function csReaderText() {
|
||||||
|
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
||||||
|
let out = '', lastPage = -1;
|
||||||
|
for (const i of readerScopeIndices()) {
|
||||||
|
const u = readerState.sentences[i];
|
||||||
|
const pg = u.words?.[0]?.page;
|
||||||
|
if (readerState.mode === 'pdf' && pg != null && pg !== lastPage) { out += `\n[p.${pg + 1}] `; lastPage = pg; }
|
||||||
|
out += u.text + ' ';
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build text from the rehearser script (narration + "SPEAKER: line"), page markers at page breaks.
|
||||||
|
function csRehearserText() {
|
||||||
|
if (!window.rehState || !(rehState.lines || []).length) return '';
|
||||||
|
let out = '', page = 1, started = false;
|
||||||
|
for (const l of rehState.lines) {
|
||||||
|
if (l.type === 'pagebreak') { page++; out += `\n[p.${page}] `; continue; }
|
||||||
|
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(l.text || '') : (l.text || '')).trim();
|
||||||
|
if (!t) continue;
|
||||||
|
if (!started) { out += '[p.1] '; started = true; }
|
||||||
|
out += (l.type === 'dialog' && l.speaker ? l.speaker + ': ' : '') + t + '\n';
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generation (chunked + merged by character) ───────────────────────────────
|
||||||
|
|
||||||
|
// Compact summary of the sheets built so far → tells the LLM what's known and
|
||||||
|
// which fields each character still needs, so it fills gaps instead of restarting.
|
||||||
|
function csExistingSummary(map) {
|
||||||
|
if (!map.size) return '';
|
||||||
|
const FIELDS = ['archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
|
||||||
|
return [...map.values()].slice(0, 30).map(s => {
|
||||||
|
const missing = FIELDS.filter(f => !(s[f] || '').trim());
|
||||||
|
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? ` — ${s.archetype}` : ''}`
|
||||||
|
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function csMerge(map, sheets) {
|
||||||
|
const SCALARS = ['aliases', 'archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
|
||||||
|
for (const s of sheets) {
|
||||||
|
const name = (s.name || '').trim(); if (!name) continue;
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (!map.has(key)) { map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }); continue; }
|
||||||
|
const e = map.get(key);
|
||||||
|
SCALARS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
|
||||||
|
if (s.tier === 'main') e.tier = 'main';
|
||||||
|
(s.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
|
||||||
|
(s.sources || []).forEach(src => { if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function csGenerate(text, cacheKey) {
|
||||||
|
if (_cs.running) return null;
|
||||||
|
if (!text) { toast('Nothing to analyse', 'error'); return null; }
|
||||||
|
const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text];
|
||||||
|
_cs.running = true; _cs.cancel = false;
|
||||||
|
const prog = csProgress(chunks.length);
|
||||||
|
const llm_url = csLlmUrl(), model = csLlmModel(), language = csLang();
|
||||||
|
const map = new Map();
|
||||||
|
const roster = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
if (_cs.cancel) break;
|
||||||
|
prog.update(i, `Reading characters · passage ${i + 1} / ${chunks.length}…`);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/character-sheets', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), existing: csExistingSummary(map), language, llm_url, model }),
|
||||||
|
});
|
||||||
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||||
|
const data = await r.json();
|
||||||
|
csMerge(map, data.sheets || []);
|
||||||
|
(data.characters || []).forEach(n => { if (!roster.includes(n)) roster.push(n); });
|
||||||
|
} catch (e) {
|
||||||
|
toast('Passage ' + (i + 1) + ' failed: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { prog.done(); _cs.running = false; }
|
||||||
|
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
|
||||||
|
const sheets = [...map.values()];
|
||||||
|
if (cacheKey) _cs.cache[cacheKey] = sheets;
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csProgress(total) {
|
||||||
|
let ov = document.getElementById('cs-progress');
|
||||||
|
if (!ov) {
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay';
|
||||||
|
ov.innerHTML = `<div class="audiobook-box">
|
||||||
|
<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>
|
||||||
|
<div class="audiobook-msg" id="cs-progress-msg">Analysing…</div>
|
||||||
|
<div class="reader-synth-track"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
|
||||||
|
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
ov.querySelector('#cs-progress-cancel').addEventListener('click', () => { _cs.cancel = true; });
|
||||||
|
}
|
||||||
|
ov.hidden = false;
|
||||||
|
const fill = ov.querySelector('#cs-progress-fill'), msg = ov.querySelector('#cs-progress-msg');
|
||||||
|
return {
|
||||||
|
update(d, label) { if (fill) fill.style.width = (d / total * 100) + '%'; if (msg && label) msg.textContent = label; },
|
||||||
|
done() { ov.hidden = true; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function csField(label, value) {
|
||||||
|
if (!value) return '';
|
||||||
|
return `<div class="cs-row"><dt>${label}</dt><dd>${escHtml(String(value))}</dd></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csCardHtml(s) {
|
||||||
|
const inv = (s.inventory || []).filter(Boolean);
|
||||||
|
const invHtml = inv.length ? `<div class="cs-row"><dt>Signature Inventory</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>` : '';
|
||||||
|
const attrs = (s.attribute_high || s.attribute_low)
|
||||||
|
? `<div class="cs-row"><dt>Core Attributes</dt><dd>▲ ${escHtml(s.attribute_high || '—')} · ▼ ${escHtml(s.attribute_low || '—')}</dd></div>` : '';
|
||||||
|
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
|
||||||
|
const srcHtml = sources.length
|
||||||
|
? `<div class="cs-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${sources.map(x => `${x.page != null ? '<b>p.' + escHtml(String(x.page)) + '</b> ' : ''}${x.quote ? '“' + escHtml(x.quote) + '”' : ''}`).join(' · ')}</div>` : '';
|
||||||
|
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}">
|
||||||
|
<div class="cs-head">
|
||||||
|
<span class="cs-name">${escHtml(s.name)}</span>
|
||||||
|
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
||||||
|
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</span>
|
||||||
|
</div>
|
||||||
|
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
|
||||||
|
<dl class="cs-fields">
|
||||||
|
${csField('Physical', s.physical)}
|
||||||
|
${csField('Alignment & Ethos', s.alignment)}
|
||||||
|
${attrs}
|
||||||
|
${csField('Trained Skills', s.skills)}
|
||||||
|
${invHtml}
|
||||||
|
${csField('Dark Secret / Fatal Flaw', s.secret)}
|
||||||
|
${csField('Conflict Style', s.conflict_style)}
|
||||||
|
${csField('Win Condition', s.win_condition)}
|
||||||
|
</dl>
|
||||||
|
${srcHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csToMarkdown(sheets) {
|
||||||
|
const sec = t => `\n## ${t}\n`;
|
||||||
|
let md = '# Character Sheets\n';
|
||||||
|
for (const tier of ['main', 'supp']) {
|
||||||
|
const list = sheets.filter(s => (s.tier === 'main') === (tier === 'main'));
|
||||||
|
if (!list.length) continue;
|
||||||
|
md += sec(tier === 'main' ? 'Main characters' : 'Supporting characters');
|
||||||
|
for (const s of list) {
|
||||||
|
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
|
||||||
|
if (s.aliases) md += `*aka ${s.aliases}*\n`;
|
||||||
|
const f = (l, v) => v ? `- **${l}:** ${v}\n` : '';
|
||||||
|
md += f('Physical', s.physical) + f('Alignment & Ethos', s.alignment)
|
||||||
|
+ f('Core Attributes', [s.attribute_high && '▲ ' + s.attribute_high, s.attribute_low && '▼ ' + s.attribute_low].filter(Boolean).join(' · '))
|
||||||
|
+ f('Trained Skills', s.skills) + f('Signature Inventory', (s.inventory || []).join(', '))
|
||||||
|
+ f('Dark Secret / Fatal Flaw', s.secret) + f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
|
||||||
|
const src = (s.sources || []).filter(x => x && x.quote).map(x => `${x.page != null ? 'p.' + x.page + ' ' : ''}“${x.quote}”`).join('; ');
|
||||||
|
if (src) md += `- *Sources:* ${src}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function csShow(sheets, title) {
|
||||||
|
document.getElementById('cs-overlay')?.remove();
|
||||||
|
const ov = document.createElement('div');
|
||||||
|
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
|
||||||
|
const main = sheets.filter(s => s.tier === 'main');
|
||||||
|
const supp = sheets.filter(s => s.tier !== 'main');
|
||||||
|
const group = (label, list) => list.length ? `<div class="cs-group-label">${label}</div>` + list.map(csCardHtml).join('') : '';
|
||||||
|
ov.innerHTML = `<div class="audiobook-box cs-box">
|
||||||
|
<div class="cs-titlebar">
|
||||||
|
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> ${escHtml(title || 'Character sheets')} <span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span></span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
|
||||||
|
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
|
||||||
|
ov.querySelector('#cs-copy').addEventListener('click', () => {
|
||||||
|
navigator.clipboard?.writeText(csToMarkdown(sheets)).then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
|
||||||
|
});
|
||||||
|
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry points (reader + rehearser) ────────────────────────────────────────
|
||||||
|
|
||||||
|
async function csForReader() {
|
||||||
|
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
|
||||||
|
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets'); return; }
|
||||||
|
const sheets = await csGenerate(csReaderText(), key);
|
||||||
|
if (!sheets) return;
|
||||||
|
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||||
|
csShow(sheets, readerState.title || 'Character sheets');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function csForRehearser() {
|
||||||
|
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
|
||||||
|
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
||||||
|
if (_cs.cache[key]) { csShow(_cs.cache[key], title); return; }
|
||||||
|
const sheets = await csGenerate(csRehearserText(), key);
|
||||||
|
if (!sheets) return;
|
||||||
|
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||||
|
csShow(sheets, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
|
||||||
|
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);
|
||||||
@ -22,6 +22,7 @@ const I18N_DICT = {
|
|||||||
'Library tools': 'Bibliothekswerkzeuge',
|
'Library tools': 'Bibliothekswerkzeuge',
|
||||||
'Clone a Voice': 'Stimme klonen', 'Design a Voice': 'Stimme entwerfen',
|
'Clone a Voice': 'Stimme klonen', 'Design a Voice': 'Stimme entwerfen',
|
||||||
'Get Voices Online': 'Stimmen online holen', 'Try It Out': 'Ausprobieren',
|
'Get Voices Online': 'Stimmen online holen', 'Try It Out': 'Ausprobieren',
|
||||||
|
'Read Aloud': 'Vorlesen',
|
||||||
'Script Rehearser': 'Skript-Probe', 'Library': 'Bibliothek', 'Cast': 'Besetzung',
|
'Script Rehearser': 'Skript-Probe', 'Library': 'Bibliothek', 'Cast': 'Besetzung',
|
||||||
'Stage': 'Bühne', 'Summary': 'Zusammenfassung', 'Import / Export': 'Import / Export',
|
'Stage': 'Bühne', 'Summary': 'Zusammenfassung', 'Import / Export': 'Import / Export',
|
||||||
'Conversation': 'Unterhaltung', 'Benchmark': 'Benchmark', 'Engines': 'Engines',
|
'Conversation': 'Unterhaltung', 'Benchmark': 'Benchmark', 'Engines': 'Engines',
|
||||||
@ -42,6 +43,15 @@ const I18N_DICT = {
|
|||||||
'Erzeuge Sprache aus Text mit beliebigem Backend und Stimme. Transkribiere Audio und sprich es neu ein.',
|
'Erzeuge Sprache aus Text mit beliebigem Backend und Stimme. Transkribiere Audio und sprich es neu ein.',
|
||||||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||||||
'Lade ein Skript hoch, besetze Figuren mit TTS-Stimmen oder deinem Mikrofon und probe Szene für Szene.',
|
'Lade ein Skript hoch, besetze Figuren mit TTS-Stimmen oder deinem Mikrofon und probe Szene für Szene.',
|
||||||
|
'Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.':
|
||||||
|
'Importiere ein PDF- oder Textdokument, wähle Stimme und Tempo und lass es dir vorlesen, während das gerade gesprochene Wort hervorgehoben wird.',
|
||||||
|
// Read Aloud UI
|
||||||
|
'My books': 'Meine Bücher', 'Voice consistency': 'Stimmkonstanz',
|
||||||
|
'Normalise loudness': 'Lautstärke angleichen', 'Export MP3': 'MP3 exportieren',
|
||||||
|
'Select range': 'Bereich wählen', 'Synthesise': 'Synthetisieren',
|
||||||
|
'Tone / style': 'Ton / Stil', 'Temperature': 'Temperatur', 'Speed': 'Tempo',
|
||||||
|
'Saved documents with their synthesised audio — reopen to continue.':
|
||||||
|
'Gespeicherte Dokumente mit ihrem synthetisierten Audio — zum Fortsetzen erneut öffnen.',
|
||||||
// Common buttons / labels
|
// Common buttons / labels
|
||||||
'Save changes': 'Änderungen speichern', 'Save to library': 'In Bibliothek speichern',
|
'Save changes': 'Änderungen speichern', 'Save to library': 'In Bibliothek speichern',
|
||||||
'Save to Voice Library': 'In Stimmbibliothek speichern', 'Delete voice': 'Stimme löschen',
|
'Save to Voice Library': 'In Stimmbibliothek speichern', 'Delete voice': 'Stimme löschen',
|
||||||
@ -111,16 +121,27 @@ window.setAppLang = function (lang) {
|
|||||||
location.reload();
|
location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inject the picker into the sidebar brand once the DOM is ready.
|
// Flag of a language code (flag-icons classes; English → GB).
|
||||||
|
function _langFlag(code) { return code === 'en' ? 'gb' : code; }
|
||||||
|
|
||||||
|
// Inject a flag toggle next to the "Voice Creator" headline. Clicking cycles to
|
||||||
|
// the next language (a simple toggle while there are two).
|
||||||
window.initLangPicker = function () {
|
window.initLangPicker = function () {
|
||||||
const brand = document.querySelector('.sidebar-brand');
|
const brand = document.querySelector('.sidebar-brand');
|
||||||
if (!brand || document.getElementById('app-lang-picker')) return;
|
const head = brand && brand.querySelector('h1');
|
||||||
const sel = document.createElement('select');
|
if (!head || document.getElementById('app-lang-toggle')) return;
|
||||||
sel.id = 'app-lang-picker';
|
const codes = Object.keys(I18N_LANGS);
|
||||||
sel.className = 'app-lang-picker';
|
const btn = document.createElement('button');
|
||||||
sel.setAttribute('aria-label', 'Interface language');
|
btn.id = 'app-lang-toggle';
|
||||||
sel.innerHTML = Object.entries(I18N_LANGS)
|
btn.className = 'app-lang-toggle';
|
||||||
.map(([code, name]) => `<option value="${code}"${code === _appLang ? ' selected' : ''}>${name}</option>`).join('');
|
btn.type = 'button';
|
||||||
sel.addEventListener('change', () => window.setAppLang(sel.value));
|
const nextOf = () => codes[(codes.indexOf(_appLang) + 1) % codes.length];
|
||||||
brand.appendChild(sel);
|
const render = () => {
|
||||||
|
btn.innerHTML = `<span class="fi fi-${_langFlag(_appLang)}"></span>`;
|
||||||
|
const label = 'Language: ' + I18N_LANGS[_appLang] + ' — switch to ' + I18N_LANGS[nextOf()];
|
||||||
|
btn.title = label; btn.setAttribute('aria-label', label);
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
btn.addEventListener('click', () => window.setAppLang(nextOf()));
|
||||||
|
head.appendChild(btn);
|
||||||
};
|
};
|
||||||
|
|||||||
1324
static/js/reader.js
Normal file
1324
static/js/reader.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -171,6 +171,13 @@ async function refreshTtsBackendAvailability(selected = '') {
|
|||||||
libraryTts.innerHTML = ttsBackendOptions(prev);
|
libraryTts.innerHTML = ttsBackendOptions(prev);
|
||||||
libraryTts.disabled = !availableTtsBackends().length;
|
libraryTts.disabled = !availableTtsBackends().length;
|
||||||
}
|
}
|
||||||
|
const readerBackend = $('reader-backend-select');
|
||||||
|
if (readerBackend) {
|
||||||
|
const prev = selected || readerBackend.value;
|
||||||
|
readerBackend.innerHTML = ttsBackendOptions(prev);
|
||||||
|
readerBackend.disabled = !availableTtsBackends().length;
|
||||||
|
if (typeof readerUpdateBackendHint === 'function') readerUpdateBackendHint();
|
||||||
|
}
|
||||||
document.querySelectorAll('.opt-style-backend').forEach(sel => {
|
document.querySelectorAll('.opt-style-backend').forEach(sel => {
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
sel.innerHTML = styleBackendOptions(prev);
|
sel.innerHTML = styleBackendOptions(prev);
|
||||||
|
|||||||
@ -211,9 +211,10 @@ async function createTtsStreamUrl(voice, text, instruct = '') {
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
return data.url;
|
return data.url;
|
||||||
}
|
}
|
||||||
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false) {
|
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false, extra = null) {
|
||||||
const body = {text, voice, response_format: responseFormat, instruct, backend};
|
const body = {text, voice, response_format: responseFormat, instruct, backend};
|
||||||
if (applyPersona) body.apply_persona = true;
|
if (applyPersona) body.apply_persona = true;
|
||||||
|
if (extra && typeof extra === 'object') Object.assign(body, extra); // e.g. {seed, temperature}
|
||||||
const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||||||
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||||
return await r.blob();
|
return await r.blob();
|
||||||
|
|||||||
@ -799,6 +799,44 @@ $('copy-active-voices-btn').addEventListener('click', async () => {
|
|||||||
status('Copied active voices to clipboard');
|
status('Copied active voices to clipboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Precompute speaker embeddings: fire a tiny synth per active voice so the TTS
|
||||||
|
// engine computes + caches each voice's .pt (speaker fingerprint) ahead of time,
|
||||||
|
// making first real playback instant. The engine prefers the cached .pt and only
|
||||||
|
// (re)builds it from the wav when missing — so this is purely a warm-up.
|
||||||
|
$('precompute-embeddings-btn')?.addEventListener('click', async () => {
|
||||||
|
const backend = libraryTtsBackend();
|
||||||
|
const ids = activeVoiceIds();
|
||||||
|
if (!ids.length) { toast('No active voices to precompute', 'error'); return; }
|
||||||
|
if (!confirm(`Precompute speaker embeddings for ${ids.length} active voice(s) via “${backend}”?\n\nThis warms each voice so the engine caches its .pt and first playback is instant.`)) return;
|
||||||
|
|
||||||
|
const btn = $('precompute-embeddings-btn'); if (btn) btn.disabled = true;
|
||||||
|
const ov = document.createElement('div');
|
||||||
|
ov.className = 'audiobook-overlay'; ov.id = 'precompute-overlay';
|
||||||
|
ov.innerHTML = `<div class="audiobook-box"><div class="audiobook-title"><span class="mdi mdi-flash-outline"></span> Precomputing embeddings</div>
|
||||||
|
<div class="audiobook-msg" id="pc-msg">0 / ${ids.length}</div>
|
||||||
|
<div class="reader-synth-track"><div class="reader-synth-fill" id="pc-fill"></div></div>
|
||||||
|
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="pc-cancel">Cancel</button></div></div>`;
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
let cancel = false;
|
||||||
|
ov.querySelector('#pc-cancel').addEventListener('click', () => { cancel = true; });
|
||||||
|
const fill = ov.querySelector('#pc-fill'), msg = ov.querySelector('#pc-msg');
|
||||||
|
let done = 0, ok = 0, failed = 0;
|
||||||
|
const queue = ids.slice();
|
||||||
|
const worker = async () => {
|
||||||
|
while (queue.length && !cancel) {
|
||||||
|
const id = queue.shift();
|
||||||
|
if (msg) msg.textContent = `${done} / ${ids.length} · ${id}`;
|
||||||
|
try { await fetchTtsPreviewBlob(id, 'Hallo.', 'wav', '', backend); ok++; }
|
||||||
|
catch (_) { failed++; }
|
||||||
|
done++; if (fill) fill.style.width = (done / ids.length * 100) + '%';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try { await Promise.all(Array.from({ length: Math.min(2, ids.length) }, worker)); }
|
||||||
|
finally { ov.remove(); if (btn) btn.disabled = false; }
|
||||||
|
toast(cancel ? `Cancelled — ${ok} warmed` : `Precomputed ${ok} embedding(s)${failed ? `, ${failed} skipped/failed` : ''}`,
|
||||||
|
(!ok && failed) ? 'error' : 'success');
|
||||||
|
});
|
||||||
|
|
||||||
const LIB_ADD_SAMPLE_TEXTS = {
|
const LIB_ADD_SAMPLE_TEXTS = {
|
||||||
EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.',
|
EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.',
|
||||||
DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.',
|
DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.',
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
llms: 's-llms'
|
llms: 's-llms'
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
|
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
|
||||||
|
|
||||||
function storedSection() {
|
function storedSection() {
|
||||||
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
||||||
@ -94,6 +94,9 @@
|
|||||||
|
|
||||||
function showSection(sectionId) {
|
function showSection(sectionId) {
|
||||||
if (!SECTIONS.includes(sectionId)) sectionId = 's-voices';
|
if (!SECTIONS.includes(sectionId)) sectionId = 's-voices';
|
||||||
|
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
|
||||||
|
if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop();
|
||||||
|
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
||||||
setStoredSection(sectionId);
|
setStoredSection(sectionId);
|
||||||
setSectionHash(sectionId);
|
setSectionHash(sectionId);
|
||||||
SECTIONS.forEach(function (id) {
|
SECTIONS.forEach(function (id) {
|
||||||
|
|||||||
147
static/sections/s-reader.html
Normal file
147
static/sections/s-reader.html
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<div class="section-head">
|
||||||
|
<span class="section-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span>
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Read Aloud</h2>
|
||||||
|
<p>Import a PDF or text document, pick a voice and speed, then have it read to you while the word being spoken is highlighted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-reader">
|
||||||
|
|
||||||
|
<!-- ① Import + controls ─────────────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="reader-controls">
|
||||||
|
<div class="field reader-ctl">
|
||||||
|
<label>Backend</label>
|
||||||
|
<select id="reader-backend-select" aria-label="TTS backend"><option value="">Checking backends…</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field reader-ctl">
|
||||||
|
<label>Voice</label>
|
||||||
|
<div class="reader-voice-pick">
|
||||||
|
<select id="reader-voice-select" aria-label="Voice"><option value="">— fetch or pick a voice —</option></select>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-fetch-voices-btn" title="Fetch available voices"><span class="mdi mdi-refresh"></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field reader-ctl">
|
||||||
|
<label>Speed <span id="reader-speed-label" class="reader-speed-label">1.0×</span></label>
|
||||||
|
<input type="range" id="reader-speed" min="0.5" max="2" step="0.05" value="1" aria-label="Reading speed">
|
||||||
|
</div>
|
||||||
|
<div class="field reader-ctl">
|
||||||
|
<label>Voice consistency</label>
|
||||||
|
<select id="reader-chunk-mode" title="Synthesise larger chunks so the voice stays steady across a passage">
|
||||||
|
<option value="sentence">Per sentence (responsive)</option>
|
||||||
|
<option value="paragraph">Per paragraph (steadier)</option>
|
||||||
|
<option value="page">Per page (steadiest)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field reader-ctl reader-ctl-grow">
|
||||||
|
<label>Tone / style <span class="note">(optional)</span></label>
|
||||||
|
<input type="text" id="reader-instruct" placeholder="e.g. calm, warm narration" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field reader-ctl reader-ctl-sm">
|
||||||
|
<label>Seed <span class="note">(blank = auto)</span></label>
|
||||||
|
<input type="number" id="reader-seed" placeholder="auto" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field reader-ctl reader-ctl-sm">
|
||||||
|
<label>Temperature <span class="note">(0–1)</span></label>
|
||||||
|
<input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<label class="reader-ctl reader-norm-toggle" title="Even out loudness differences between chunks during playback">
|
||||||
|
<input type="checkbox" id="reader-normalize" checked> Normalise loudness
|
||||||
|
</label>
|
||||||
|
<label class="btn-primary reader-import-btn" title="Import a PDF or text file">
|
||||||
|
<span class="mdi mdi-file-upload-outline"></span> Import
|
||||||
|
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="reader-backend-hint" id="reader-backend-hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved books library ─────────────────────────────────────────── -->
|
||||||
|
<div class="card reader-library-card" id="reader-library-card" hidden>
|
||||||
|
<div class="reader-lib-head">
|
||||||
|
<h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> My books</h2>
|
||||||
|
<span class="note">Saved documents with their synthesised audio — reopen to continue.</span>
|
||||||
|
</div>
|
||||||
|
<div class="reader-lib-list" id="reader-lib-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ② Drag & drop / empty state ─────────────────────────────────── -->
|
||||||
|
<div class="reader-dropzone" id="reader-dropzone">
|
||||||
|
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon"></span>
|
||||||
|
<strong>Drag & drop a document here</strong>
|
||||||
|
<span class="note">.pdf · .txt · .md — or use the Import button above</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ③ Transport ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="reader-transport" id="reader-transport" hidden>
|
||||||
|
<div class="reader-tb-group">
|
||||||
|
<button class="reh-tb-btn" id="reader-prev" title="Previous sentence"><span class="mdi mdi-skip-previous"></span></button>
|
||||||
|
<button class="reh-tb-btn reh-tb-play" id="reader-play" title="Play / pause"><span class="mdi mdi-play"></span></button>
|
||||||
|
<button class="reh-tb-btn" id="reader-stop" title="Stop"><span class="mdi mdi-stop"></span></button>
|
||||||
|
<button class="reh-tb-btn" id="reader-next" title="Next sentence"><span class="mdi mdi-skip-next"></span></button>
|
||||||
|
</div>
|
||||||
|
<div class="reader-progress-wrap">
|
||||||
|
<div class="reader-progress-track"><div class="reader-progress-fill" id="reader-progress-fill"></div></div>
|
||||||
|
<span class="reader-progress-label" id="reader-progress-label">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<span class="reader-doc-title" id="reader-doc-title"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ④ Synthesise-ahead bar ──────────────────────────────────────── -->
|
||||||
|
<div class="reader-synthbar" id="reader-synthbar" hidden>
|
||||||
|
<button class="btn-primary btn-sm" id="reader-synth-all" title="Pre-synthesise the chosen range for gap-free reading">
|
||||||
|
<span class="mdi mdi-lightning-bolt"></span> Synthesise <span id="reader-synth-scope">all</span>
|
||||||
|
</button>
|
||||||
|
<span class="reader-page-range" id="reader-page-range" hidden>
|
||||||
|
<span class="note">pages</span>
|
||||||
|
<input type="number" id="reader-page-from" min="1" value="1" class="reader-page-inp" aria-label="From page">
|
||||||
|
<span class="note">–</span>
|
||||||
|
<input type="number" id="reader-page-to" min="1" value="1" class="reader-page-inp" aria-label="To page">
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-select-toggle" title="Click sentences to mark a start and an end"><span class="mdi mdi-cursor-default-click-outline" id="reader-select-icon"></span> <span id="reader-select-label">Select range</span></button>
|
||||||
|
<span class="reader-sel-hint" id="reader-sel-hint" hidden></span>
|
||||||
|
<span class="reader-sel-info" id="reader-sel-info" hidden>
|
||||||
|
<span id="reader-sel-text">selection</span>
|
||||||
|
<button class="reader-sel-clear" id="reader-sel-clear" title="Clear selection">✕</button>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-save-lib" title="Save this document + its synthesised audio to your library"><span class="mdi mdi-content-save-outline"></span> Save to library</button>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-audiobook-btn" title="Let an LLM detect characters & dialogue in this scope, then open it in the Script Rehearser to cast a voice per character"><span class="mdi mdi-drama-masks"></span> Cast as audiobook</button>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-charsheets-btn" title="Extract actor-facing RPG-style character sheets (with page + quote sources) from this scope"><span class="mdi mdi-account-details-outline"></span> Character sheets</button>
|
||||||
|
<span class="reader-export">
|
||||||
|
<select id="reader-export-mode" class="reader-page-inp" style="width:auto" aria-label="Export granularity" title="Choose how to group the exported MP3 files">
|
||||||
|
<option value="page">1 MP3 / page</option>
|
||||||
|
<option value="sentence">1 MP3 / sentence</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-export-btn" title="Download the synthesised audio as MP3 (synthesises any missing sentences first)"><span class="mdi mdi-download"></span> Export MP3</button>
|
||||||
|
</span>
|
||||||
|
<div class="reader-synth-prog" id="reader-synth-prog" hidden>
|
||||||
|
<div class="reader-synth-track"><div class="reader-synth-fill" id="reader-synth-fill"></div></div>
|
||||||
|
<span class="reader-synth-label" id="reader-synth-label">0 / 0</span>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-synth-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ⑤ Zoom toolbar + status legend (PDF) ────────────────────────── -->
|
||||||
|
<div class="reader-toolbar" id="reader-toolbar" hidden>
|
||||||
|
<div class="reader-zoom" id="reader-zoom">
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-zoom-fitw" title="Fit width"><span class="mdi mdi-arrow-expand-horizontal"></span> Fit width</button>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-zoom-fith" title="Fit height"><span class="mdi mdi-arrow-expand-vertical"></span> Fit height</button>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-zoom-two" title="Two pages side by side"><span class="mdi mdi-book-open-outline"></span> Two pages</button>
|
||||||
|
<span class="reader-zoom-sep"></span>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-zoom-out" title="Zoom out"><span class="mdi mdi-minus"></span></button>
|
||||||
|
<span class="reader-zoom-pct" id="reader-zoom-pct">100%</span>
|
||||||
|
<button class="btn-secondary btn-sm" id="reader-zoom-in" title="Zoom in"><span class="mdi mdi-plus"></span></button>
|
||||||
|
</div>
|
||||||
|
<div class="reader-legend">
|
||||||
|
<span class="reader-leg reader-leg-pending">Not synthesised</span>
|
||||||
|
<span class="reader-leg reader-leg-synth">Synthesising</span>
|
||||||
|
<span class="reader-leg reader-leg-ready">Ready</span>
|
||||||
|
<span class="reader-leg reader-leg-reading">Reading</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ⑥ Document viewport ─────────────────────────────────────────── -->
|
||||||
|
<div class="reader-doc" id="reader-doc"></div>
|
||||||
|
|
||||||
|
</div><!-- /tab-reader -->
|
||||||
@ -311,6 +311,12 @@
|
|||||||
<button class="btn-secondary btn-sm" id="reh-tb-synth-all" title="Pre-synthesize all TTS lines for instant playback">
|
<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
|
<span class="mdi mdi-lightning-bolt"></span> Synth all
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-secondary btn-sm" id="reh-tb-audiobook" title="Synthesise every line and download the audiobook as MP3 (one file per chapter)">
|
||||||
|
<span class="mdi mdi-book-music-outline"></span> Audiobook
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary btn-sm" id="reh-charsheets-btn" title="Extract actor-facing RPG-style character sheets (with sources) for this script">
|
||||||
|
<span class="mdi mdi-account-details-outline"></span> Character sheets
|
||||||
|
</button>
|
||||||
<button class="btn-secondary btn-sm" id="reh-tb-resynth-stale" title="Re-synthesize only lines whose tone changed" hidden>
|
<button class="btn-secondary btn-sm" id="reh-tb-resynth-stale" title="Re-synthesize only lines whose tone changed" hidden>
|
||||||
<span class="mdi mdi-refresh" style="color:var(--red)"></span> Re-synth stale
|
<span class="mdi mdi-refresh" style="color:var(--red)"></span> Re-synth stale
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -102,6 +102,7 @@
|
|||||||
<button class="btn-secondary vl-tb-btn" id="calculate-db-btn" title="Calculate dBFS">Calc dB</button>
|
<button class="btn-secondary vl-tb-btn" id="calculate-db-btn" title="Calculate dBFS">Calc dB</button>
|
||||||
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button>
|
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button>
|
||||||
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy active voice names">Copy active</button>
|
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy active voice names">Copy active</button>
|
||||||
|
<button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes & caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vl-footer">
|
<div class="vl-footer">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user