diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b066cf..6190cf3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,155 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.12.84] — 2026-07-03
+
+### Fixed
+- **Clicking "PDF" or "Text" in the pipeline stepper did nothing** — `navTo('s-reader')` auto-restores an active casting panel a tick later via a deferred `setTimeout` (nav.js), which silently undid the stepper's own `showReaderView('main')` call right after it ran. Now sets the existing `_readerSuppressCastRestore` flag (already used by the Library's "open a fresh book" flow for the same reason) before navigating.
+
+### Changed
+- **Merged the "PDF" and "Text" stepper stops into one "Source"** — both stops led to the exact same Reader screen, so distinguishing them added a step without adding a real destination. Named "Source" to match the label already used on the Reader's import card. The stepper is now 6 stops instead of 7.
+
+---
+
+## [1.12.83] — 2026-07-03
+
+### Fixed
+- **Casting feed merge/split/edit got slower as the merge work landed** — the new per-row inline-edit feature attached 2 extra event listeners to every segment row on every redraw, and merge/split rebuild the *entire* feed from scratch; for a 1000+ segment book that's thousands of extra listener attachments on every single merge/split. Moved editing to the same event-delegation pattern already used for merging and speaker assignment (one listener on the feed container, not one per row) — row creation is back to just building HTML.
+- **Reader's playback transport bar stayed visible with nothing loaded** — `readerResetDoc()` hid the synth bar and the zoom/search toolbar but never hid the play/pause/skip transport bar, so a reset or fresh session showed "0 / 0" controls with no document — one of several boxes making the empty-state Reader screen feel cluttered.
+
+### Changed
+- **Pipeline stepper made prominent, like a guided tour** — bigger numbered circles, a current-step highlight ring, and completed steps now show a checkmark and stay tinted instead of all steps looking equally flat.
+- **"Voice & synthesis settings" now collapsed by default** — it's secondary until you actually have a document loaded; this was the first thing filling the empty-state screen with a large expanded box before you'd done anything.
+- **`.ab-castpanel-inline` background set to `var(--panel)`** explicitly, per spec.
+
+---
+
+## [1.12.82] — 2026-07-03
+
+### Fixed
+- **Duplicate "opening" notifications when reopening a saved book** — a toast ("Opening book…") and the "Reading PDF… page N / total" progress pill appeared at almost the same instant and visibly overlapped on screen. Removed the toast — the progress pill already says the same thing with more detail (live page count) and stays up for the whole load instead of auto-dismissing.
+
+---
+
+## [1.12.81] — 2026-07-03
+
+### Fixed
+- **Casting toolbar icons overlapping at narrower panel widths** — `.ab-cv-topbar` (character-selection bar + undo/redo + page navigation) never wrapped, so when all three were visible at once at a moderate panel width, its fixed-width buttons got crammed together instead of flowing to a second row. Added `flex-wrap: wrap`.
+
+---
+
+## [1.12.80] — 2026-07-03
+
+### Added
+- **First slice of the Read Aloud / Rehearser merge** — three pieces of the planned unified screen land in Casting audiobook now: (1) the "paper" page look — pages are now literal white/off-white sheets (Courier New, matching Script Rehearser's Stage exactly) inside the Casting border/shadow "stacked card" treatment, instead of a plain surface-coloured box; (2) the character sidebar is now collapsible to just avatar dots via a new chevron toggle, for more room when you don't need the full list; (3) every line in the casting feed can now be edited inline (pencil icon or double-click, Ctrl+Enter to save, Esc to cancel) — previously only speaker reassignment and segment merging were possible, not correcting the text itself. More pieces of the merge (shared page navigation, folding Script Rehearser's Stage into this same view) are in progress.
+
+---
+
+## [1.12.79] — 2026-07-03
+
+### Fixed
+- **Theater Play library cards silently failed to open at all** — the real cause, on top of the missing navigation fixed in 1.12.78: opening a saved rehearsal (from Library → Theater Plays, and from Rehearser's own Library tab's open/export buttons) called `rehDbOpen()`/`REH_STORE`, a raw-IndexedDB API left over from before the app migrated rehearsals to server-backed SQLite. Neither exists anymore, so every click threw a `ReferenceError` caught silently by the surrounding try/catch — `loadRecord()` was never even reached, which is also why the 1.12.78 navigation fix alone didn't fully solve it. All three spots now use `rehDbGetById()`, the current API.
+
+## [1.12.78] — 2026-07-03
+
+### Fixed
+- **Theater Play library cards did nothing when clicked** — opening a saved rehearsal from Library → Theater Plays called `loadRecord()`, which loads the script/cast and switches to the Cast phase, but never navigated to the Rehearser section itself. Since you're sitting on the Library section when you click a card, the phase switch happened inside `#s-rehearser` while that whole section stayed hidden — so nothing visibly changed. `loadRecord()` now navigates to Script Rehearser first.
+
+---
+
+## [1.12.77] — 2026-07-03
+
+### Fixed
+- **Excessive "Unknown" speakers in Casting audiobook** — the attribution LLM's output budget had been tightened to roughly half its previous size (`len(text)*0.45+512` vs. the old `len(text)+1000`, both capped). Narration-heavy passages fit fine, but dialogue-dense scenes need far more output than input — every quoted line becomes a full JSON object — so the tighter cap truncated the response mid-JSON on exchange-heavy passages. The truncation-repair only salvages a cut that lands right after a complete segment, so most failures degraded the whole chunk to naive quote-splitting, which has no way to attribute a speaker and labels everything "Unknown". Restored the more generous budget.
+
+---
+
+## [1.12.76] — 2026-07-03
+
+### Fixed
+- **Bulk "Set tag" wiped a voice's existing tags** — applying a tag to multiple selected voices overwrote each voice's `tag` field outright instead of adding to it, so a voice tagged "Privat, Sillytavern" lost both tags the moment you bulk-tagged it with something new. It now merges the typed tag(s) into each voice's existing tag list instead of replacing it.
+
+---
+
+## [1.12.75] — 2026-07-03
+
+### Fixed
+- **Casting audiobook panel grew to fit its content instead of the browser window** — the inline casting view (`.ab-castpanel-inline`) had no bounded height, so `.ab-cv-body`'s `flex:1` had nothing to divide up; the feed grew with every passage cast, pushing the action bar ("Continue casting" / "Edit in Rehearser" / ...) further down the page and forcing a full-page scroll to reach it. It's now bounded to the viewport like the floating casting popup already was, so the feed scrolls internally and the header/action bar stay in view.
+
+---
+
+## [1.12.74] — 2026-07-03
+
+### Fixed
+- **Empty bar showing above Rehearser's Library tab (and potentially the Reader's "Extract Text" prompt)** — the new `.wf-stepper` and `.reader-extract-banner` rules set `display: flex` unconditionally, which beats the browser's default `[hidden] { display: none }` rule at equal specificity — the same bug class fixed previously for `.ab-char-bar`. Both now have an explicit `[hidden]` override so they actually disappear when hidden instead of rendering as an empty box.
+
+---
+
+## [1.12.73] — 2026-07-03
+
+### Added
+- **Explicit "Extract Text" step for PDF import** — importing a PDF now stops after loading and rasterizing its pages (fast — you can already scroll/zoom and look at it) instead of immediately and silently running text extraction. A new banner shows the page count and an "Extract Text" button; the OCR-headings checkbox now lives here since it only matters right before extraction runs. This makes the pipeline stepper's "PDF" and "Text" stops correspond to two real, distinct stages instead of one being a no-op. Reopening an already-read document from the library is unaffected — it still extracts automatically, since there's nothing to decide on a document you've already extracted before.
+
+---
+
+## [1.12.72] — 2026-07-03
+
+### Added
+- **Cross-workflow pipeline stepper** — a persistent strip above Reader/Casting and Rehearser now shows the full production pipeline (PDF → Text → Cast Audiobook → Cast Characters (optional) → Script Rehearser → Generate MP3s → Audiobook) as clickable stops. Every reachable stop jumps directly to that stage without losing state — previously "Open in Rehearser" was a one-way door with no way back except hunting through the sidebar, and re-entering the casting panel could lose track of where you were.
+
+### Fixed
+- **Casting feed silently merged pages after leaving and returning to the casting panel** — page cards ("PAGE 4", "PAGE 6"...) were reconstructed on every redraw by re-guessing each segment's position via a forward-only text search against the cached source text; if a segment's text didn't exactly re-match (LLM cleanup, dehyphenation, short lines), the search stalled and every later page boundary was silently dropped, folding several pages into one card. Segments are now stamped with their real page number at cast time and the feed redraws directly from that instead of re-guessing it.
+
+---
+
+## [1.12.71] — 2026-07-03
+
+### Changed
+- **"Not synthesised" highlight no longer paints the whole book red on import** — every sentence starts in the `pending` state, so a freshly-imported document showed wall-to-wall red before you'd asked for anything to be synthesised. The red tint (PDF overlay and text-mode word spans) now stays invisible until synthesis actually starts — clicking "Synthesise pages", pressing play, or reopening a saved document that already has some audio — at which point pending/synthesising/ready/reading colours all show normally to track real progress.
+
+---
+
+## [1.12.70] — 2026-07-03
+
+### Added
+- **Recover chapter headlines baked into a PDF as images** — pdf.js's text extraction only ever sees real text glyphs, so a stylised chapter-title graphic (e.g. "1. Kapitel", "Prolog") silently vanished from the reader with no trace it was ever there. The importer now flags any page whose real text starts well below the top edge, rasterizes just that band, and OCRs it with a locally-vendored Tesseract.js worker (German + English), splicing the recognized heading back into the sentence stream so it reads aloud and highlights normally. Toggle via the new "OCR headings" checkbox above the import dropzone (on by default).
+
+---
+
+## [1.12.61] — 2026-07-01
+
+### Fixed
+- **Cascading 429s during casting/recasting** — the frontend already sends attribution requests one at a time, but a client-side timeout only aborts the browser fetch; the backend's blocking call to the LLM kept running in its thread regardless, leaving a "ghost" request still occupying the LLM's processing slot. The next chunk (or a timeout's retry-in-halves) then fired into that busy slot and the LLM answered with 429, and the ghosts piled up faster than they drained. Outbound calls to the audiobook-attribution LLM are now serialized behind a backend lock so the app never has more than one real request in flight against the engine, no matter how many chunks or retries the frontend queues up.
+- **Casting timeouts on slow/local models** — the attribution request timeout (90s) was tuned for fast hosted APIs, but a local model generating at ~15 tokens/sec can take several minutes to finish a single passage (up to ~4000 output tokens), so nearly every request was aborted client-side before the model replied. Attribution, retry, recast, and warmup timeouts are all raised (90s→6min, 60s→3min, 75s→4min, 3min→4min) to match realistic local-inference generation time.
+
+---
+
+## [1.12.60] — 2026-07-01
+
+### Fixed
+- **Casting crash strands the panel and breaks the reader** — an unexpected per-passage error (bad LLM response shape, mid-retry network failure) could throw uncaught out of the casting loop instead of degrading gracefully to quote-splitting for that passage. The escaped error left `_audiobook.running` false with the panel stuck mid-cast; clicking "Stop Casting" then navigated back to the reader, which hit an unrelated null-pointer bug while re-rendering the PDF and showed an empty import screen. Per-passage attribution is now wrapped so any unexpected error falls back to auto-detected dialogue and the cast continues or ends in the normal "stopped early" panel instead of crashing.
+- **Character selection bar placement** — reverted to sit above the casting feed (previous change had moved it below).
+
+---
+
+## [1.12.59] — 2026-07-01
+
+### Added
+- **Resume interrupted audiobook casts** — a "Continue casting" button now appears after restoring an autosaved cast that was cut short (e.g. by a server crash), picking up at the passage it stopped on and keeping already-cast passages instead of requiring a full "Recast all".
+
+### Changed
+- **Casting panel layout** — the progress bar now sits at the bottom of the casting panel, just above the action buttons, with the "Live" jump-to-latest button above it instead of overlapping the top of the feed.
+- **Casting feed reads as paginated pages** — each source page in the casting feed now renders as its own card (white surface, thin border, soft shadow, margin between cards) on a neutral canvas background, instead of a plain dashed "Page N" divider inline with the text.
+- **Casting panel element placement** — the character selection bar now appears below the feed instead of above it, and the "N characters · M segments" summary now sits under the character sidebar instead of in the action-button footer.
+
+### Fixed
+- **Stale progress bar on restored casts** — reopening a finished or restored audiobook cast no longer re-shows the passage-count header and progress bar that the completed-state view had just hidden, which was also causing the "N% (Passage X of Y)" label to wrap and clip.
+- **Interrupted casts silently reported as 100% done** — manually fixing a speaker name, or stopping a "Recast unknown"/"2nd Quality Run" pass, used to overwrite the saved draft's progress with a `done:-1,total:-1` sentinel that displayed as "100% complete", permanently hiding that the original cast never finished. Both now preserve the real progress instead, and a successful "Recast unknown" pass now persists its corrections (previously not saved at all).
+- **Character selection bar showing as an empty box** — `.ab-char-bar` set `display:flex` unconditionally, which beat the browser's default `[hidden]` rule at equal CSS specificity, so the bar rendered as an empty box even while "hidden". Added an explicit `[hidden]` override.
+- **Stale static assets after edits** — the app loads a prebuilt bundle (`static/dist/main.min.js`) in front of the individual `static/js/*.js` files, and the cache-busting version query string wasn't being bumped, so recent fixes weren't reaching the browser. Bundle rebuilt and version bumped to force a fresh fetch.
+
+---
+
## [1.12.58] — 2026-06-30
### Fixed
diff --git a/Dockerfile b/Dockerfile
index 907a405..b936a75 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -29,6 +29,18 @@ COPY core/ core/
COPY routes/ routes/
COPY static/ static/
+# ── Vendor Tesseract.js (client-side OCR for image-based PDF chapter headings) ─
+# Skipped if already present (e.g. bind-mounted from host during development).
+RUN [ -f static/js/tesseract/tesseract.min.js ] || ( \
+ mkdir -p static/js/tesseract/lang && \
+ curl -sL -o static/js/tesseract/tesseract.min.js "https://unpkg.com/tesseract.js@5.1.1/dist/tesseract.min.js" && \
+ curl -sL -o static/js/tesseract/worker.min.js "https://unpkg.com/tesseract.js@5.1.1/dist/worker.min.js" && \
+ curl -sL -o static/js/tesseract/tesseract-core-simd-lstm.wasm.js "https://unpkg.com/tesseract.js-core@5.1.1/tesseract-core-simd-lstm.wasm.js" && \
+ curl -sL -o static/js/tesseract/tesseract-core-simd-lstm.wasm "https://unpkg.com/tesseract.js-core@5.1.1/tesseract-core-simd-lstm.wasm" && \
+ curl -sL -o static/js/tesseract/lang/deu.traineddata.gz "https://tessdata.projectnaptha.com/4.0.0_fast/deu.traineddata.gz" && \
+ curl -sL -o static/js/tesseract/lang/eng.traineddata.gz "https://tessdata.projectnaptha.com/4.0.0_fast/eng.traineddata.gz" \
+ )
+
# ── Runtime user ──────────────────────────────────────────────────────────────
RUN mkdir -p /voices/active_voices /voices/hidden_voices /home/app/.config/tts-voice-creator && \
useradd -m -s /bin/bash app && \
diff --git a/VERSION b/VERSION
index 26af9b2..aad4e4c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.12.58
+1.12.84
diff --git a/core/constants.py b/core/constants.py
index 54b5ae9..0876dcc 100644
--- a/core/constants.py
+++ b/core/constants.py
@@ -22,24 +22,24 @@ __version__: str = _read_version()
_VOICES_DIR_DEFAULT = os.environ.get("VOICES_DIR", "/voices")
_OUTPUT_DIR_DEFAULT = os.environ.get("OUTPUT_DIR", "/voices/active_voices")
-_WHISPER_DEFAULT = os.environ.get("WHISPER_URL", "http://host.docker.internal:8010")
-_TTS_DEFAULT = os.environ.get("TTS_URL", "http://host.docker.internal:8020")
-_TTS_STREAM_DEFAULT = os.environ.get("TTS_STREAM_URL", "http://host.docker.internal:8023")
-_CUSTOMVOICE_DEFAULT = os.environ.get("CUSTOMVOICE_URL", "http://host.docker.internal:8022")
-_VOICE_DESIGN_DEFAULT = os.environ.get("VOICE_DESIGN_URL", "http://host.docker.internal:8021")
-_NVIDIA_ROUTER_DEFAULT = os.environ.get("NVIDIA_SPEECH_ROUTER_URL", "http://host.docker.internal:8090")
-_NVIDIA_TTS_DEFAULT = os.environ.get("NVIDIA_MAGPIE_TTS_URL", "http://host.docker.internal:8091")
-_NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://host.docker.internal:8092")
-_NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://host.docker.internal:8093")
+_WHISPER_DEFAULT = os.environ.get("WHISPER_URL", "http://192.168.178.8:8010")
+_TTS_DEFAULT = os.environ.get("TTS_URL", "http://192.168.178.8:8020")
+_TTS_STREAM_DEFAULT = os.environ.get("TTS_STREAM_URL", "http://192.168.178.8:8023")
+_CUSTOMVOICE_DEFAULT = os.environ.get("CUSTOMVOICE_URL", "http://192.168.178.8:8022")
+_VOICE_DESIGN_DEFAULT = os.environ.get("VOICE_DESIGN_URL", "http://192.168.178.8:8021")
+_NVIDIA_ROUTER_DEFAULT = os.environ.get("NVIDIA_SPEECH_ROUTER_URL", "http://192.168.178.8:8090")
+_NVIDIA_TTS_DEFAULT = os.environ.get("NVIDIA_MAGPIE_TTS_URL", "http://192.168.178.8:8091")
+_NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://192.168.178.8:8092")
+_NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://192.168.178.8:8093")
_NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT)
-_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094")
-_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://host.docker.internal:8010")
-_WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://host.docker.internal:8085")
+_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://192.168.178.8:8094")
+_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://192.168.178.8:8010")
+_WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://192.168.178.8:8085")
_GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1"
-_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1")
+_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://192.168.178.8:8880/v1")
_VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027")
-_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8005")
-_FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://host.docker.internal:38080")
+_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://192.168.178.8:8005")
+_FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://192.168.178.8:38080")
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
_TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override
_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign")
@@ -48,7 +48,7 @@ _VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0"))
_MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_MB", "1024")) * 1024 * 1024
_MAX_PICTURE_BYTES = int(os.environ.get("MAX_PICTURE_MB", "10")) * 1024 * 1024
_MAX_SOUND_BYTES = int(os.environ.get("MAX_SOUND_MB", "25")) * 1024 * 1024
-_MAX_TTS_OUTPUT_SECONDS = float(os.environ.get("MAX_TTS_OUTPUT_SECONDS", "30"))
+_MAX_TTS_OUTPUT_SECONDS = float(os.environ.get("MAX_TTS_OUTPUT_SECONDS", "60"))
_STT_REQUEST_TIMEOUT = float(os.environ.get("STT_REQUEST_TIMEOUT", os.environ.get("REQUEST_TIMEOUT", "900")))
_BENCHMARK_TEXT = os.environ.get("VOICE_BENCHMARK_TEXT", "This is a short realtime voice benchmark.")
_BENCHMARK_SENTENCES = [
diff --git a/core/database.py b/core/database.py
index aa0d613..55592ef 100644
--- a/core/database.py
+++ b/core/database.py
@@ -50,6 +50,18 @@ CREATE TABLE IF NOT EXISTS rehearsals (
);
CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title);
+CREATE TABLE IF NOT EXISTS reader_scripts (
+ doc_id TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT 'cast',
+ title TEXT NOT NULL DEFAULT '',
+ payload TEXT NOT NULL DEFAULT '{}',
+ saved_at TEXT,
+ created TEXT,
+ updated TEXT,
+ PRIMARY KEY (doc_id, name)
+);
+CREATE INDEX IF NOT EXISTS idx_reader_scripts_doc ON reader_scripts(doc_id);
+
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT 'null',
@@ -105,6 +117,7 @@ def _row_to_char(row: sqlite3.Row) -> dict:
d = dict(row)
d["sheet"] = json.loads(d.get("sheet") or "{}")
d["analysis"] = json.loads(d.get("analysis") or "null")
+ d["color"] = d["sheet"].get("color")
return d
@@ -241,6 +254,83 @@ def reh_delete(reh_id: int) -> bool:
return True
+# ── Reader scripts / audiobook casts ─────────────────────────────────────────
+
+def reader_script_list(doc_id: str) -> list[dict]:
+ with _open() as conn:
+ rows = conn.execute(
+ "SELECT name, title, payload, saved_at, updated FROM reader_scripts WHERE doc_id=? ORDER BY updated DESC",
+ (doc_id,),
+ ).fetchall()
+ out: list[dict] = []
+ for row in rows:
+ payload: dict = {}
+ try:
+ payload = json.loads(row["payload"] or "{}")
+ except Exception:
+ payload = {}
+ out.append({
+ "name": row["name"],
+ "title": row["title"],
+ "savedAt": row["saved_at"] or payload.get("savedAt"),
+ "updated": row["updated"],
+ "segments": len(payload.get("segments", [])) if isinstance(payload.get("segments"), list) else 0,
+ "roster": payload.get("roster", []),
+ })
+ return out
+
+
+def reader_script_get(doc_id: str, name: str = "cast") -> dict | None:
+ with _open() as conn:
+ row = conn.execute(
+ "SELECT payload FROM reader_scripts WHERE doc_id=? AND name=?",
+ (doc_id, name),
+ ).fetchone()
+ if not row:
+ return None
+ try:
+ data = json.loads(row["payload"] or "{}")
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+def reader_script_put(doc_id: str, name: str, data: dict) -> dict:
+ now = _now()
+ with _open() as conn:
+ existing = conn.execute(
+ "SELECT created FROM reader_scripts WHERE doc_id=? AND name=?",
+ (doc_id, name),
+ ).fetchone()
+ created = existing["created"] if existing else now
+ conn.execute("""
+ INSERT INTO reader_scripts (doc_id, name, title, payload, saved_at, created, updated)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(doc_id, name) DO UPDATE SET
+ title=excluded.title,
+ payload=excluded.payload,
+ saved_at=excluded.saved_at,
+ updated=excluded.updated
+ """, (
+ doc_id,
+ name,
+ str(data.get("title", "")),
+ _j(data),
+ str(data.get("savedAt", "")),
+ created,
+ now,
+ ))
+ conn.commit()
+ return data
+
+
+def reader_script_delete(doc_id: str, name: str = "cast") -> bool:
+ with _open() as conn:
+ conn.execute("DELETE FROM reader_scripts WHERE doc_id=? AND name=?", (doc_id, name))
+ conn.commit()
+ return True
+
+
# ── Generic app state ─────────────────────────────────────────────────────────
def state_get(key: str, default: Any = None) -> Any:
diff --git a/routes/conversation.py b/routes/conversation.py
index 32fcc29..9854972 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -34,6 +34,14 @@ from routes.stt import _transcribe_audio, _clean_stt_backend
router = APIRouter()
+# Serializes outbound calls to the audiobook-attribution LLM. A client-side
+# timeout aborts the browser fetch but can't kill the backend's blocking
+# requests.post thread, so a slow/cold model leaves "ghost" requests still
+# occupying the LLM's single processing slot. Without this lock, the next
+# chunk (or the retry-in-halves) fires into that busy slot and the LLM
+# answers with 429s that cascade until the ghosts drain.
+_attribution_llm_lock = asyncio.Lock()
+
# ── STT hallucination filter ──────────────────────────────────────────────────
# Whisper commonly hallucinates these phrases on silence/noise.
# Treat them as "no speech detected" rather than passing them to the LLM.
@@ -104,6 +112,76 @@ def _request_timeout_seconds(value, default: float = 600.0, minimum: float = 5.0
return max(minimum, min(maximum, timeout))
+def _is_router_model_alias(model: str) -> bool:
+ return bool(re.match(r"^\s*auto[-_ ]?router", model or "", re.I))
+
+
+def _response_error_text(resp) -> str:
+ try:
+ text = resp.text or ""
+ except Exception:
+ text = ""
+ return text.strip()[:2000] or getattr(resp, "reason", "") or "Unknown upstream error"
+
+
+def _llm_chat_completion_urls(llm_url: str) -> list[str]:
+ base = (llm_url or "http://localhost:11434/v1").rstrip("/")
+ urls: list[str] = []
+ if not re.search(r"/v\d+(?:\.\d+)?$", base):
+ urls.append(f"{base}/v1/chat/completions")
+ urls.append(f"{base}/chat/completions")
+ return list(dict.fromkeys(urls))
+
+
+def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeout: float):
+ last_resp = None
+ last_exc = None
+ for url in _llm_chat_completion_urls(llm_url):
+ try:
+ resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
+ last_resp = resp
+ if resp.status_code in (404, 405) and url != _llm_chat_completion_urls(llm_url)[-1]:
+ continue
+ return resp
+ except Exception as exc:
+ last_exc = exc
+ if last_resp is not None:
+ return last_resp
+ if last_exc:
+ raise last_exc
+ raise RuntimeError("LLM request failed")
+
+
+def _fallback_attribute_response(text: str) -> dict:
+ """Deterministic fallback for attribution when an upstream LLM fails."""
+ quote_re = re.compile(
+ r"»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|\"([^\"]+)\"|「([^」]+)」|『([^』]+)』",
+ re.DOTALL,
+ )
+ segments = []
+ pos = 0
+ for match in quote_re.finditer(text):
+ if match.start() > pos:
+ narr = text[pos:match.start()].strip()
+ if narr:
+ segments.append({"speaker": "Narrator", "type": "narration", "text": narr, "emotion": ""})
+ spoken = next((g for g in match.groups() if g), "")
+ if spoken.strip():
+ segments.append({"speaker": "Unknown", "type": "dialogue", "text": spoken.strip(), "emotion": ""})
+ pos = match.end()
+ tail = text[pos:].strip()
+ if tail:
+ segments.append({"speaker": "Narrator", "type": "narration", "text": tail, "emotion": ""})
+ if not segments:
+ segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}]
+ chars = []
+ for seg in segments:
+ sp = seg.get("speaker")
+ if seg.get("type") == "dialogue" and sp and sp not in ("Narrator", "Unknown") and sp not in chars:
+ chars.append(sp)
+ return {"segments": segments, "characters": chars, "fallback": True}
+
+
def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) -> str:
if explicit_voice:
return explicit_voice
@@ -457,7 +535,7 @@ async def character_sheets(request: Request):
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,
+ Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment,
attribute_high, attribute_low, skills, inventory:[...], secret,
conflict_style, win_condition, tier:"main"|"supporting",
sources:[{page, quote}]}], characters:[names]}
@@ -466,6 +544,7 @@ async def character_sheets(request: Request):
data = await request.json()
text: str = (data.get("text") or "").strip()
known: list = data.get("known_characters") or []
+ target_mode: bool = bool(data.get("target_mode"))
existing: str = (data.get("existing") or "").strip() # partial sheets so far (progressive fill)
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
@@ -481,12 +560,25 @@ async def character_sheets(request: Request):
f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n"
f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, "
f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, "
- f"conflict_style, win_condition, archetype, aliases.\n"
+ f"conflict_style, win_condition, archetype, aliases, first_name, last_name, full_name, title.\n"
+ f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n"
f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n"
f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n"
) if language else ""
+ target_instruction = (
+ "CAST TARGET MODE:\n"
+ "- The known-characters list is the already recognized cast roster from the audiobook pass. "
+ "Treat it as TARGET PROFILES to fill, not as a list of aliases and not as proof that those names are the same person.\n"
+ "- Prefer outputting sheets for those target names when this passage reveals usable profile details. "
+ "Do not output a blank sheet just because a target is listed.\n"
+ "- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name "
+ "and put only the proven alternate form in aliases/title/full_name.\n"
+ "- You may add a brand-new character only when the passage clearly introduces one that is not in the target roster.\n"
+ "- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n"
+ ) if target_mode and known else ""
system = (
f"{lang_instruction}"
+ f"{target_instruction}"
"You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich "
"character sheets passage by passage as a book is read. Extract playable, action-oriented sheets "
"an actor can use to immediately know how to PLAY the character.\n"
@@ -496,7 +588,9 @@ async def character_sheets(request: Request):
"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"
"For each character output these fields:\n"
- "- name, aliases\n"
+ "- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n"
+ "- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n"
+ "- first_name, last_name, full_name, title: split the character identity when known. Leave unknown parts empty. Put noble/office/role labels in title (e.g. 'Henker', 'Vampir', 'Graf').\n"
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
@@ -513,20 +607,26 @@ async def character_sheets(request: Request):
"- fears: their deepest fears, phobias or dread\n"
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
"- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n"
+ "- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.\n"
+ "- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\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, line_hint} — the page number from the nearest [p.N] marker, "
- "a short verbatim quote that supports the sheet (1-5 entries), and a brief label (e.g. 'appearance', 'alignment'). "
- "Use null page if unknown.\n"
- "Reuse the EXACT names from the known-characters list for returning characters. "
+ "a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). "
+ "Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.\n"
+ "IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). "
+ "Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. "
+ "Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.\n"
+ "Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. "
+ "Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n"
"Respond with STRICT JSON only:\n"
- '{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","clothing":"",'
+ '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
- '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"",'
+ '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
'"inventory":[],"secret":"","conflict_style":"","win_condition":"",'
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
)
@@ -552,9 +652,9 @@ async def character_sheets(request: Request):
payload["model"] = model
try:
resp = await asyncio.to_thread(
- requests.post,
- f"{llm_url}/chat/completions", json=payload,
- headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=timeout_seconds,
+ _post_llm_chat_completion,
+ llm_url, payload,
+ {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
@@ -575,18 +675,42 @@ async def character_sheets(request: Request):
except Exception:
continue
clean, names = [], []
+ alias_splitter = re.compile(r"[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b", re.I)
for s in sheets:
if not isinstance(s, dict):
continue
name = str(s.get("name") or "").strip()
if not name:
continue
+ alias_raw = str(s.get("aliases") or "").strip()
+ alias_parts = [p.strip() for p in alias_splitter.split(alias_raw) if p.strip()]
+ alias_parts = [p for p in alias_parts if len(p) <= 80 and not re.match(r"^(needs?:|complete$)", p, re.I)]
+ if len(alias_raw) > 500 or len(alias_parts) > 12:
+ aliases = ""
+ else:
+ aliases = ", ".join(alias_parts[:6])
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 []
+ src_raw = s.get("sources") if isinstance(s.get("sources"), list) else []
+ src = []
+ for item in src_raw:
+ if not isinstance(item, dict):
+ continue
+ quote = str(item.get("quote") or "").strip()
+ if not quote:
+ continue
+ try:
+ page = int(item.get("page")) if item.get("page") is not None else None
+ except (TypeError, ValueError):
+ page = None
+ src.append({
+ "page": page,
+ "quote": quote[:240],
+ "line_hint": str(item.get("line_hint") or "").strip()[:60],
+ })
# Clamp moral alignment score
try:
mas = int(s.get("moral_alignment_score") or 50)
@@ -597,9 +721,9 @@ async def character_sheets(request: Request):
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
arc = "neutral"
s.update({
- "name": name, "inventory": inv[:3],
+ "name": name, "aliases": aliases, "inventory": inv[:3],
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
- "sources": src[:5],
+ "sources": src[:12],
"moral_alignment_score": mas,
"arc_direction": arc,
})
@@ -714,7 +838,10 @@ async def attribute_dialogue(request: Request):
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
- base_prompt = _settings.get("audiobook_prompt") or ""
+ prompt_override = data.get("audiobook_prompt")
+ if not isinstance(prompt_override, str):
+ prompt_override = data.get("prompt")
+ base_prompt = (prompt_override if isinstance(prompt_override, str) else "") or _settings.get("audiobook_prompt") or ""
if not base_prompt.strip():
base_prompt = (
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
@@ -742,6 +869,11 @@ async def attribute_dialogue(request: Request):
"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"
+ "- GRAMMAR CHECK FOR NARRATION: if a fragment is a reporting clause about speech "
+ "(finite speech verb + speaker/pronoun/name, e.g. 'murmelte er mit erstickter Stimme', "
+ "', entgegnete Marcian kalt', 'asked Peter quietly'), it is narration, never dialogue.\n"
+ "- Action beats are narration: a character looks, walks, laughs, stays silent, raises a hand, "
+ "turns away, etc. Only the actual quoted words are dialogue.\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"
@@ -769,21 +901,52 @@ async def attribute_dialogue(request: Request):
{"role": "user", "content": user},
],
"temperature": 0.2,
+ # Dialogue-dense passages need much more output than input: every quoted
+ # line becomes a full JSON object ({"speaker":...,"type":...,"text":...,
+ # "emotion":...}), so budgeting off raw character count alone (as a
+ # tighter cap here once did) truncates mid-JSON on exchange-heavy scenes
+ # — which the parser can't always repair, degrading the whole chunk to
+ # naive quote-splitting with every speaker labelled "Unknown".
"max_tokens": min(8192, max(2048, len(text) + 1000)),
}
if model:
payload["model"] = model
try:
- resp = await asyncio.to_thread(
- requests.post,
- f"{llm_url}/chat/completions", json=payload,
- headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=timeout_seconds,
- )
- resp.raise_for_status()
+ async with _attribution_llm_lock:
+ resp = await asyncio.to_thread(
+ _post_llm_chat_completion,
+ llm_url, payload,
+ {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds,
+ )
+ fallback_model = (_settings.get("llm_model") or "").strip()
+ if (
+ resp.status_code >= 400
+ and _is_router_model_alias(model)
+ and fallback_model
+ and fallback_model != model
+ and not _is_router_model_alias(fallback_model)
+ ):
+ retry_payload = dict(payload)
+ retry_payload["model"] = fallback_model
+ resp = await asyncio.to_thread(
+ _post_llm_chat_completion,
+ llm_url, retry_payload,
+ {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds,
+ )
+ payload = retry_payload
+ if resp.status_code >= 400:
+ print(
+ f"[attribute-dialogue] LLM failed ({resp.status_code}) for model "
+ f"{payload.get('model') or '(default)'}: {_response_error_text(resp)}"
+ )
+ return _fallback_attribute_response(text)
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or msg.get("reasoning_content") or "").strip()
+ except HTTPException:
+ raise
except Exception as e:
- raise HTTPException(502, f"LLM attribution failed: {e}")
+ print(f"[attribute-dialogue] LLM failed, using deterministic fallback: {e}")
+ return _fallback_attribute_response(text)
content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw
segments = []
diff --git a/routes/reader.py b/routes/reader.py
index aaf58a5..b53136f 100644
--- a/routes/reader.py
+++ b/routes/reader.py
@@ -18,6 +18,12 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from core.constants import CONFIG_DIR
+from core.database import (
+ reader_script_delete as db_reader_script_delete,
+ reader_script_get as db_reader_script_get,
+ reader_script_list as db_reader_script_list,
+ reader_script_put as db_reader_script_put,
+)
router = APIRouter()
@@ -191,16 +197,24 @@ async def reader_get_cover(doc_id: str):
return FileResponse(str(f), media_type="image/jpeg", headers={"Cache-Control": "max-age=31536000"})
-# ── Scripts (cast save/load) ───────────────────────────────────────────────────
-# Saves the LLM-cast result (segments + roster) as a Markdown file under
-# //Scripts/cast.md
-# so it survives browser-cache clears and works across devices.
+# ── Scripts (cast save/load/export) ────────────────────────────────────────────
+# Durable storage lives in SQLite. Markdown files are export/import-compatible
+# snapshots only: old files are still readable and get migrated into the DB on
+# first access, while new autosaves no longer create files behind the user's back.
def _scripts_dir(doc_id: str) -> Path:
d = _doc_dir(doc_id) / "Scripts"
d.mkdir(parents=True, exist_ok=True)
return d
+
+def _legacy_scripts_dir(doc_id: str) -> Path:
+ return _doc_dir(doc_id) / "Scripts"
+
+
+def _valid_script_name(name: str) -> bool:
+ return bool(name and name.replace("-", "").replace("_", "").isalnum())
+
def _cast_to_md(data: dict) -> str:
"""Serialise cast JSON as a fenced code-block Markdown file."""
meta = {k: v for k, v in data.items() if k != "segments"}
@@ -229,53 +243,98 @@ def _md_to_cast(text: str) -> dict:
@router.get("/api/reader/docs/{doc_id}/scripts")
async def reader_list_scripts(doc_id: str):
- sd = _scripts_dir(doc_id)
- scripts = []
+ _doc_dir(doc_id)
+ scripts = db_reader_script_list(doc_id)
+ seen = {s.get("name") for s in scripts}
+ sd = _legacy_scripts_dir(doc_id)
+ if not sd.is_dir():
+ return scripts
for f in sorted(sd.glob("*.md")):
+ if f.stem in seen:
+ continue
try:
data = _md_to_cast(f.read_text(encoding="utf-8"))
scripts.append({
"name": f.stem,
+ "title": data.get("title", ""),
"savedAt": data.get("savedAt"),
"segments": len(data.get("segments", [])),
"roster": data.get("roster", []),
+ "legacy": True,
})
except Exception:
- scripts.append({"name": f.stem})
+ scripts.append({"name": f.stem, "legacy": True})
return scripts
@router.get("/api/reader/docs/{doc_id}/scripts/{name}")
async def reader_get_script(doc_id: str, name: str):
- if not name.replace("-", "").replace("_", "").isalnum():
+ if not _valid_script_name(name):
raise HTTPException(400, "Bad script name")
- f = _scripts_dir(doc_id) / f"{name}.md"
+ _doc_dir(doc_id)
+ data = db_reader_script_get(doc_id, name)
+ if data is not None:
+ return data
+ f = _legacy_scripts_dir(doc_id) / f"{name}.md"
if not f.is_file():
raise HTTPException(404, "Script not found")
try:
- return _md_to_cast(f.read_text(encoding="utf-8"))
+ data = _md_to_cast(f.read_text(encoding="utf-8"))
+ db_reader_script_put(doc_id, name, data)
+ return data
except Exception as e:
raise HTTPException(500, str(e))
@router.put("/api/reader/docs/{doc_id}/scripts/{name}")
async def reader_save_script(doc_id: str, name: str, request: Request):
- if not name.replace("-", "").replace("_", "").isalnum():
+ if not _valid_script_name(name):
raise HTTPException(400, "Bad script name")
+ d = _doc_dir(doc_id)
+ if not d.is_dir():
+ raise HTTPException(404, "Document not found")
try:
data = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON")
- f = _scripts_dir(doc_id) / f"{name}.md"
- f.write_text(_cast_to_md(data), encoding="utf-8")
- return {"saved": str(f.relative_to(_LIB))}
+ db_reader_script_put(doc_id, name, data)
+ return {"saved": f"database:{doc_id}/{name}"}
+
+
+@router.get("/api/reader/docs/{doc_id}/scripts/{name}/export")
+async def reader_export_script(doc_id: str, name: str):
+ if not _valid_script_name(name):
+ raise HTTPException(400, "Bad script name")
+ _doc_dir(doc_id)
+ data = db_reader_script_get(doc_id, name)
+ if data is None:
+ f = _legacy_scripts_dir(doc_id) / f"{name}.md"
+ if not f.is_file():
+ raise HTTPException(404, "Script not found")
+ try:
+ data = _md_to_cast(f.read_text(encoding="utf-8"))
+ except Exception as e:
+ raise HTTPException(500, str(e))
+ filename = f"{name}.md"
+ title = str(data.get("title") or "").strip()
+ if title:
+ safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_")
+ if safe_title:
+ filename = f"{safe_title}_{name}.md"
+ return Response(
+ _cast_to_md(data),
+ media_type="text/markdown; charset=utf-8",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
@router.delete("/api/reader/docs/{doc_id}/scripts/{name}")
async def reader_delete_script(doc_id: str, name: str):
- if not name.replace("-", "").replace("_", "").isalnum():
+ if not _valid_script_name(name):
raise HTTPException(400, "Bad script name")
- f = _scripts_dir(doc_id) / f"{name}.md"
+ _doc_dir(doc_id)
+ db_reader_script_delete(doc_id, name)
+ f = _legacy_scripts_dir(doc_id) / f"{name}.md"
if f.is_file():
f.unlink()
return {"deleted": name}
diff --git a/routes/tts.py b/routes/tts.py
index eedfaf7..928f2b4 100644
--- a/routes/tts.py
+++ b/routes/tts.py
@@ -3,9 +3,11 @@ from __future__ import annotations
import asyncio
import io
+import json
import logging
import os
import re
+import threading
import time
import uuid
from pathlib import Path
@@ -30,6 +32,7 @@ from core.voice import (
_find_voice_audio, _active_voices_dir,
_voice_audio_files, _load_meta, _save_meta,
_AUDIO_EXTS,
+ _read_reference_text,
_voice_health,
)
from core.voice_index import indexed_voices, refresh_voice_index_background
@@ -50,6 +53,7 @@ from core.docker_client import _docker_post
logger = logging.getLogger("uvicorn.error")
router = APIRouter()
+_SEED_FINDER_VOICE_LOCK = threading.Lock()
# ── TTS backend helpers ───────────────────────────────────────────────────────
@@ -194,6 +198,98 @@ def _backend_available(backend: str, voices: list, health: bool) -> bool:
return bool(voices) or health
+def _seed_finder_voices_json_path(settings: dict) -> Path:
+ configured = Path(str(settings.get("tts_config_dir") or "/tts-config"))
+ candidates = [
+ configured / "voices.json",
+ Path("/tts-config/voices.json"),
+ Path("/config/voices.json"),
+ ]
+ for path in candidates:
+ if path.exists():
+ return path
+ return candidates[0]
+
+
+def _seed_finder_backend_ref_audio(audio: Path, settings: dict) -> str:
+ output_dir = _active_voices_dir(settings)
+ try:
+ rel = audio.resolve().relative_to(output_dir.resolve())
+ return "/voices/" + rel.as_posix()
+ except Exception:
+ pass
+ return f"/voices/{audio.name}"
+
+
+def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
+ cfg = dict(voices.get(voice) or {})
+ if cfg.get("ref_audio"):
+ return cfg
+
+ scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
+ audio = _find_voice_audio(voice, scan_dir)
+ if not audio:
+ raise RuntimeError(f"reference WAV for voice {voice!r} not found")
+ _has_ref, ref_text = _read_reference_text(audio)
+ lang = {
+ "DE": "German", "EN": "English", "GB": "English", "FR": "French",
+ "ES": "Spanish", "IT": "Italian", "PT": "Portuguese", "NL": "Dutch",
+ "PL": "Polish", "ZH": "Chinese", "JA": "Japanese", "KO": "Korean",
+ }.get(voice.split("_", 1)[0].upper(), "Auto")
+ return {
+ "ref_audio": _seed_finder_backend_ref_audio(audio, settings),
+ "language": lang,
+ "chunk_size": 4,
+ "ref_text": ref_text,
+ }
+
+
+def _seed_finder_request_audio(text: str, voice: str, settings: dict, seed: int) -> tuple[bytes, str]:
+ """Generate a Seed Finder sample from the reference WAV, not the voice's cached .pt.
+
+ The Qwen3 VoiceClone server does not accept per-request ``seed`` on
+ /v1/audio/speech; it reads the seed from voices.json. For Seed Finder we create
+ a temporary voice entry that points at the selected reference WAV, omits the
+ existing speaker embedding, sets the requested seed, generates once, then
+ restores voices.json and removes the temporary .pt cache.
+ """
+ voices_path = _seed_finder_voices_json_path(settings)
+ if not voices_path.exists():
+ raise RuntimeError(f"TTS voices.json not found at {voices_path}")
+
+ safe_voice = re.sub(r"[^A-Za-z0-9_\-.]+", "_", voice).strip("._-")[:60] or "voice"
+ temp_voice = f"__seedfinder_{safe_voice}_{int(seed)}_{uuid.uuid4().hex[:8]}"
+ temp_pt = Path("/tts-config/speakers") / f"{temp_voice}.pt"
+
+ with _SEED_FINDER_VOICE_LOCK:
+ original_text = voices_path.read_text(encoding="utf-8")
+ try:
+ voices = json.loads(original_text or "{}")
+ if not isinstance(voices, dict):
+ raise RuntimeError("voices.json is not an object")
+ cfg = _seed_finder_voice_config(voice, voices, settings)
+ cfg = dict(cfg)
+ cfg.pop("speaker_embeddings", None)
+ cfg.pop("speaker embeddings", None)
+ cfg["seed"] = int(seed)
+ voices[temp_voice] = cfg
+ voices_path.write_text(json.dumps(voices, indent=2, ensure_ascii=False), encoding="utf-8")
+ os.utime(voices_path, None)
+ time.sleep(0.35) # let the backend hot-reload voices.json before resolve_voice()
+ return _preview_request_audio(text, temp_voice, settings, "", "voice_clone")
+ finally:
+ try:
+ voices_path.write_text(original_text, encoding="utf-8")
+ os.utime(voices_path, None)
+ except Exception as exc:
+ logger.warning("Failed to restore voices.json after Seed Finder sample: %s", exc)
+ try:
+ if temp_pt.exists():
+ temp_pt.unlink()
+ except Exception:
+ pass
+
+
_TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers", "/voices")
_KOKORO_BUILTIN_VOICES = [
"af", "af_bella", "af_nicole", "af_sarah", "af_sky",
@@ -523,14 +619,23 @@ async def tts_preview(request: Request):
raise HTTPException(502, f"Persona rewrite failed: {e}")
try:
- audio, media_type = await asyncio.to_thread(_preview_request_audio, text, voice, settings, instruct, backend)
+ if backend == "voice_clone" and data.get("seed_finder") and "seed" in _overrides:
+ audio, media_type = await asyncio.to_thread(
+ _seed_finder_request_audio, text, voice, settings, int(_overrides["seed"])
+ )
+ else:
+ audio, media_type = await asyncio.to_thread(_preview_request_audio, text, voice, settings, instruct, backend)
except Exception as e:
raise HTTPException(502, f"TTS error: {e}")
- audio, media_type, ext, _duration_sec, _clipped = _prepare_proxy_audio(audio, media_type, response_format)
+ audio, media_type, ext, duration_sec, clipped = _prepare_proxy_audio(audio, media_type, response_format)
return Response(
content=audio,
media_type=media_type,
- headers={"Content-Disposition": f'inline; filename="{voice}_preview.{ext}"'},
+ headers={
+ "Content-Disposition": f'inline; filename="{voice}_preview.{ext}"',
+ "X-TTS-Audio-Duration": f"{duration_sec:.3f}" if duration_sec is not None else "",
+ "X-TTS-Audio-Clipped": "true" if clipped else "false",
+ },
)
diff --git a/static/index.html b/static/index.html
index cf572ea..75e0fbb 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,7 @@
-
+
@@ -27,7 +27,7 @@
-
+
@@ -316,7 +316,10 @@
-