Skip to content

Release Notes v1.10.0

dudarenok-maker edited this page Jul 5, 2026 · 4 revisions

Castwright v1.10.0

Released 2026-07-04. View on GitHub.


A craft, reach, and trust release. Castwright gains three more performed languages — Spanish, French and German, after English and Russian (five in all) — and recognises a manuscript's language the moment you import it. Performances get more expressive and more correct: characters gasp, sigh and laugh with line-by-line direction that's now detected by default, you can pin a whole book to the higher-quality voice model in one action, numbers/dates/currency are spoken the way you'd read them aloud, and a new LLM Script Review proposes — and now even reassigns — who-said-what, reaching across a chapter boundary to settle a tagless opening line. See the cast you've carried across a whole series and lift a card worth sharing, and switch on a new acoustic check that catches a voice drifting out of character even when the words are right. A second graphics card can now share the load — each voice engine placed on the card you choose — and a manuscript-analysis pill shows every analysis pass as it runs while holding Generate until it's done — all on the usual hardened footing.


✨ Headline features

🌍 Three new performed languages: Spanish, French & German (new) — fs-41 / fs-50

Castwright performs Spanish-, French- and German-language books end to end — five languages in all (en, ru, es, fr, de) — and recognises a manuscript's language the moment you import it.

  • Spanish books, full-castes is now a claimed language: the analyzer reads Spanish (per-language dialogue conventions, copyright-boilerplate stripping), the cast and their descriptions are produced in Spanish, and Qwen designs and performs voices that speak it — validated end-to-end by an on-box Spanish canary (a full Spanish-calibrated chapter render, operator-accepted; cast attribution scored 13/13 recall by a new eval harness) before es was switched on (#1011, #1015, #1016, #1019, #1031, #1032).
  • French & German books, full-castfr and de flipped supported:true after operator audio acceptance of designed FR/DE Coalfall samples (plan 229): both ship full language packs (chapter-heading lexicons, front-matter terms, the analyzer heuristic localization from #1054, and the now-active fs-53 normalisation engines) and route through the same Latin-Qwen design/synthesis path as Spanish (#1101, closes #974).
  • Language detection on import + a confirm-screen selector — every manuscript is script/franc-detected server-side on import; the confirm screen shows the detected language and a "detected but not yet supported" banner for languages Castwright can't perform yet, so there are no mid-pipeline surprises (#1011).
  • Language-agnostic structure — chapter splitting, Unicode heading normalisation, non-English front-matter / generic-NCX detection, non-English quote + Unicode-case shout audio-tag recognition (incl. German „…"), and the English-only roster/attribution + narrator-flip recovery guards all gated off for non-English books (#1011, #1013, #1014, #1015).
  • The voice library knows the language — derived voices carry a per-voice languageCode from the Qwen manifest; the global voices view gains a language facet, and the cast picker hides language-ineligible voices (hide-with-count + show-all) and warns when a designed voice is cleared for a language mismatch (#1020, #1021).
  • Fail-loud on an unsupported languagesidecarLanguageName now throws for a language the sidecar can't perform instead of silently defaulting to English (#1022).

🎬 Series memory — see the cast you've carried (new) — fe-40

The voices you designed, returning book after book, now made visible.

  • Carried-cast chip + consistency sparkline — a series on your shelf shows a chip (Your cast · N voices, M books) and a sparkline of principal-cast consistency across its books. It surfaces only above a ≥3-carried-character / ≥3-confirmed-book / ≥1-designed-voice threshold, so an all-preset shelf stays quiet (#986).
  • Reveal panel — tapping the chip opens a dialog (full-screen sheet on phone) with the carried roster sorted by voice kind (designed / cloned first), a per-book marker row showing where each character appears, and share / export actions (#986).
  • Shareable cast card + exports — a portrait social card leads on the designed-voice count and names every carried character (the wordmark + castwright.ai always present); fe-43 adds client-side PNG capture (html-to-image, lazy-loaded) plus a schema-versioned JSON export of the series, books, and carried cast (#991).
  • "Carried" vocabulary + table-view chip — the reuse badge now reads Carried across the cast and confirm-cast screens (copy-only; the lifecycle "Matched" pill is untouched), and the chip also renders in the library table view (fe-41 / fe-42, #991).
  • Counts the whole reused cast, unifiedderiveSeriesMemory now judges voice-consistency on both identity facets (the cross-book reuse voiceId and the per-engine voice name), so a character who debuts in book 1 (null voiceId) is no longer dropped, and a second bespoke-voice merge pass reunites a character split by alias/spelling drift — while two genuinely different voices stay separate. On a real 8-book library the carried cast went from 10 → 47 with no false voice-changes (#1153, closes #1152, #1154).

🎙️ Acoustic voice-drift detection (new, opt-in) — srv-36 Phase 1

A new ear for a fault the word- and loudness-checks can't hear.

  • Timbre-drift QA gate — an opt-in check (SEG_SPK_ENABLED, default off) that embeds each rendered segment with an ECAPA-TDNN speaker fingerprint and compares it to a hybrid per-character voice centroid, catching lines where a character's voice drifts out of character even when the words are correct and the loudness is fine — drift the ASR and audio-QA gates miss (#987).
  • Per-character 3-tier verdict — severe / inconclusive / voice-match against each character's own clean-render thresholds (not a single global cosine), calibrated and pinned on real on-box renders with an operator listen (27/27 extreme-tail flags were real drift, 0 false positives across two series) (#987).
  • Detection free, auto-fix opt-in + gated — the issues outline is free; the acoustic auto-repair (qa.speaker.autoRepair, default off) re-renders a flagged segment and re-embeds with a margin-based accept, and is gated behind Cast Pass entitlement when hosted (#987, #990).

🎚️ The Quality (1.7B) tier — per character or a whole book (new) — fs-56 / fs-66

Castwright's everyday voices stay quick; when you want a book at its best, a larger, more expressive Qwen model is one choice away — for a single character or the whole cast.

  • Selectable 1.7B "Quality" tier per character — a new qwen3-tts-1.7b engine option (cast voice picker → "Higher quality (1.7B)") routes that character's synthesis through the larger, more expressive Qwen model on both the single and batch render paths, with a lazy per-voice prompt cache. The fast 0.6B model stays the default; the 1.7B loads on demand (PRELOAD_QWEN_BASE17 keeps it warm) and is offloaded under the one-heavy-model-at-a-time VRAM rule (#1008).
  • Pin a whole book in one tap — a cast-roster action pins every confirmed character (across the series) to the qwen3-tts-1.7b tier via a new POST /api/books/{bookId}/cast/tier endpoint (with a series-wide "Reset tier"); the existing Regenerate Model picker's 1.7B choice is the same whole-book path. The write touches only ttsModelKey, never the voice-identity axis, and needs no migration — no shipped render changes (#1136, closes #1134, plan 233).
  • 1.7B ⇒ expressive, one concept — the prosody gate is now a pure function of the resolved model key (is17b) at the synth resolver, so any route that lands a group on 1.7B — per-cast, bulk pin, or regen override — gets the expressive delivery below without a second flag. The separate per-book "Live expressive delivery (1.7B)" toggle (#1100) and the synthesiseChapter liveInstruct synth option were removed; liveInstruct survives only as an analysis-time "annotations produced" signal (#1136).
  • A tier prompt when you press Generate — generation start now opens a StartGenerationModal that surfaces the quality-tier choice (defaulting to 1.7B when the cast is already pinned to it, else 0.6B) and applies it across the whole cast before the run begins; non-Qwen books start directly. The Generate header also reads the cast's effective tier (effectiveEngineLabel — a single label when uniform, Mixed: A + B when split) instead of the session-global picker, so a 1.7B-pinned cast no longer mislabels itself "0.6B" (#1161, closes #1159, #1160).
  • Managed from the Model Manager — the Qwen 1.7B-Base appears as its own inventory row with load / unload / remove, alongside the 0.6B (#1012).

🎭 Expressive performance — emotion in character, detected by default (new) — fs-55 / fs-57 / fs-65

At the Quality tier (above), characters perform emotion in their own voice — and Castwright now writes and delivers the small human sounds between the words, automatically.

  • Emotion variants that still sound like the character — angry / sad / whisper and the rest are now minted from the character's own voice (decode the base voice's reference codes → re-derive on the 1.7B model → instruct the emotion → distil back to the fast 0.6B), instead of being independently re-sampled by VoiceDesign. The drift where an emotion variant became a different-sounding voice is gone — measured at 0.014 ECAPA cosine distance from the base (threshold 0.30), operator-confirmed by ear. Per-emotion instructs, temperatures (1.6 / 1.8) and gain (whisper ×0.45, angry ×1.5) are calibrated for the anchored pipeline, with the demo book + voice library re-minted against the locked settings; scripts/remint-anchored-variants.mjs re-mints already-designed (drifted) variants through the new pipeline (#1008 closes fs-55, #1023).
  • Non-verbal vocalizations — a new Stage-3 analysis pass (audiobook-instruct-annotation) writes lifelike reactions — gasps, sighs, laughter ("Ah!", "Haah…", "Haha!") — directly into a line's text, plus a free-text English instruct for how to deliver it. It annotates sentences in-place (strict, non-re-attributing, idempotent, multilingual: vocalization text in the book's language, the instruct in English); the "Detect emotions" button runs it too (#1095, closes #997).
  • Live, context-aware delivery on the 1.7B Base — synthesis routes through a live per-line ICL+instruct path: one batched generate forward carries heterogeneous per-item instruct_ids, neutral lines ride the pinned NEUTRAL_INSTRUCT="" no-op (measured on-box, C2 gate), with an instruct length cap + raw-generate drift guard. A synth-side precedence ladder (resolveInstructForGroup) picks the instruct: manual › analyzer instruct › emotion-derived English phrase › neutral. Gated purely by the 1.7B tier — the 0.6B / Kokoro / Coqui audio paths stay byte-identical and a pre-fs-57 analysis loads unchanged (#1095).
  • Detected on by default — an "Expressive directions" toggle on the analysis form is checked by default; when a freshly analysed book reaches analysis-complete this session the annotation pass auto-fires — keyed on the library book's status (active and background), so a backgrounded book still gets annotated. The pass is idempotent (a prosodyAnnotated watermark runs it once); unchecking pre-analysis is honored as an explicit opt-out. The per-book flag was renamed liveInstructprosodyEnabled — an analysis-time intent signal (absent ⇒ on), never a synth gate (#1142, closes #1129).
  • Honest progress, safe on restart, clean chunking — global prosody-detection progress is surfaced alongside the other analysis stages (later folded into the unified Status pill — see Smaller features), and a 1.7B book whose expressive directions are switched off shows an inline hint so you don't render flat by accident. The auto-trigger seed is gated on the first loaded library snapshot, so reopening the app with a backlog no longer fires the prosody pass for the whole shelf at once (a boot seed-race that pinned the GPU and stalled page loads). The annotation pass and the fs-58 Script Review pass chunk an oversized chapter through the shared owned-core chunker; srv-54 orders each chunk's cross-reference context structurally rather than by colliding per-chapter sentence id (#1142, #1128, #1147, #1151 closes #1150).
  • Delivery validated + regression-locked — an on-box A/B over a mixed-emotion passage confirmed rich per-sentence instruct at a fixed temperature gives the best prosody and is the fastest path, so no per-emotion-temperature batching was added (#1098). A committed, opt-in golden-audio regression pins the 1.7B live-instruct path's speaker-identity stability across instructs (ECAPA cosine ≤ 0.0125 measured), the per-emotion loudness deltas, and the batched RTF (#1113, closes #1099). The srv-31 ASR content-QA gate tolerates vocalization tokens (lexical words still fully scored), and fs-58 Script Review preserves vocalization text + instruct (#1095).

🔢 Numbers, dates & currency, spoken aloud (new) — fs-53

Automatic, language-aware text normalisation at the synthesis boundary.

  • Spoken-form expansion$1,200 → "one thousand two hundred dollars", 1999 → "nineteen ninety-nine", 50% → "fifty percent", Dr. → "Doctor" — for every language Castwright performs (en, es, ru, fr, de). The fr/de engines shipped with fs-53 and went live automatically when fr/de flipped supported:true (#1101) (#1094, closes #976).
  • Russian raised floor — years as ordinals in the correct case for the closed set of year-governing prepositions, dates as neuter-ordinal day + genitive month (3 января → "третье января"), currency 1/2–4/5+ agreement (рубль/рубля/рублей), a 1/2 gender heuristic (один/одна, два/две) (#1094).
  • Always-on and invisible — no toggle; the original manuscript text is never mutated, so captions, the manuscript view and quote-audit still show the written form. New server/src/tts/normalize/ module (expandForSpeech(text, langCode) over a shared classifier layer + five per-language engines); the no-langCode path is byte-identical to today (#1094).
  • Keeps the ASR content-QA gate aligned (load-bearing) — the per-sentence WER gate is now fed the same fs-53-normalised spoken form the audio was synthesised from, so expanded numbers no longer trigger false drift / wasted re-records / spurious asrSuspect, sidestepping the non-English number-WER gap (#1084) by pre-spelling the numbers (#1094).

📝 LLM Script Review — fix who-said-what in a click (new) — fs-58 (Unit A)

An operator-triggered, per-chapter, read-only LLM pass that proposes annotation repairs and applies the accepted ones client-side by dispatching the existing manual-edit reducers (analyzer Ollama/Gemini only — no TTS engine load).

  • Five repair classes (Unit A)strip_tag (drop a stray attribution tag), split (a sentence spanning two speakers), extract_dialogue (pull a dialogue span out of a narrator run), merge (re-join adjacent same-speaker narration), fix_emotion (correct a clearly-wrong per-quote emotion) (#1047, closes #998).
  • A 6th class — validate_instruct (fs-58) — keeps the fs-57 per-line direction honest: it strips or repairs a sentence's always-English instruct when it contradicts the line, is malformed, leaks spoken content, or was written in the book's language, and fixes a misfired vocalization — multilingual by construction (the line may be en/ru/es/fr/de; the instruct stays English). Repairs apply client-side via a tri-state text reducer that can't wipe a vocalization flag, and a liveInstruct-rendered chapter now reads stale on an instruct edit while a Kokoro/base render correctly never false-flags (a per-group, post-fallback-gated instructHash) (#1116, closes #1041).
  • Review then accept — a "Review Script" button (per-chapter, or whole-book with a free-tier RPD warning mirrored from rate-limit.ts) streams suggestions over SSE into a ScriptReviewDiff modal grouped by class with per-class / per-change toggles; accepted ops apply in the browser and mark the chapter's audio stale via boundary_move (#1047).
  • Correctness guards — anchors resolve client-side at accept time against live sentence text (one-pass index map, TOCTOU-safe; planApply re-validates every accepted op), and merge leaves a persisted, book-scoped tombstone (mergedAwayKeys) so a later re-analysis can't resurrect a merged-away sentence id (#1047). Also folds in srv-51 — the SSE guard now distinguishes no_such_chapter from no_attribution (#1045).
  • Two more op kinds — reattribute + flag_nonstory (Unit B) — Script Review can now reassign a line to a different or newly-created character (an inline form whose primary button reads "Create character" or "Reattribute to «X»" as you type) and flag a non-story sentence (a stray heading, a page number) so it's excluded from the render while keeping its slot in the chapter. Excluded lines lose their split/drag affordances, a flagged-non-story rendered line marks the chapter precisely stale, and a failed reattribute batch or sidebar character-create surfaces a toast with a clean reset / keep-open (#1118, #1122, #1138).
  • Cross-chapter context for reattribute (new) — fs-64 — the Script Review prompt now carries the prior chapter's final two-speaker exchange as read-only context, gated on a genuine alternating exchange, so the LLM can resolve a tagless chapter-opening line by following the cross-chapter turn-taking. Strictly additive: when the gate fails the prompt is byte-identical to today, and there's no schema / api-types / frontend change (#1149, closes #1120).

🖥️ Multi-GPU: place each voice engine on the card you choose (new) — multi-gpu

A second graphics card stops sitting idle. Every TTS-sidecar engine is now placeable on a specific GPU (or the CPU), and you can see — and choose — where each one actually lives.

  • Per-engine device placement — every engine honours an indexed cuda:N pin through its own native dialect: torch (.to("cuda:N")) for Qwen, Coqui (fp16 + DeepSpeed preserved on the indexed card, the headline fix) and the speaker embedder, CTranslate2 (device_index) for Whisper ASR, and ONNX-Runtime (device_id) for Kokoro — all through a single _parse_device grammar adapter (#1180).
  • A per-engine device picker — Advanced Configuration → Voice engine & device turns the Qwen/Coqui/Kokoro device knobs into a dropdown built from GET /api/gpu/devices (auto / cpu / each detected cuda:N, labelled with the card's name and free VRAM) instead of a free-text box; a stale or hand-typed value stays selectable rather than silently changing (#1205).
  • Honest visibilityGET /api/gpu/devices lists the cards torch enumerates (name / total / free MB), and /health gains an additive gpus[] reporting each card's torch_reserved_mb and a resident[] of which engines actually live on it — with a cpu_fallback marker on any engine whose requested-cuda silently resolved to CPU, so a fallback is never hidden (#1180).
  • Degrade loudly — an out-of-range pin (e.g. cuda:9) raises a clear load error instead of a raw CUDA crash the supervisor would crash-loop, retiring the old CUDA_VISIBLE_DEVICES=1,0 stop-gap (#1180).
  • Cross-card placement is now genuinely safe — a per-card VRAM floor extends the self-exit safeguard to every card, not just the first; the Qwen-VoiceDesign↔Kokoro handoff only serialises when both are actually pinned to the same card (fully concurrent otherwise); and a device pin that's structurally too small for its engine now self-heals into a clear held-down state — instead of an endless respawn loop — with an explicit recovery path once the assignment is fixed. Two coarse guards also stop a CPU-pinned engine or a CPU-confirmed analyzer from needlessly serialising against GPU work it was never going to contend with (#1221).
  • A pin that survives a driver renumber, and tells you when something's wrong (new) — a device pin now persists as the card's stable UUID (not a raw index that can shuffle after a driver update or a reboot), still shown as the familiar cuda:N. The picker now surfaces exactly what's wrong when it can't be trusted: a "card no longer found" badge if the pinned card vanished, a "fell back to CPU" badge if an engine silently landed off any GPU, and a pre-flight warning before applying a pin that doesn't have enough free VRAM for that engine. A new read-only row shows the analyzer's live GPU/CPU state (it isn't app-pinnable — a user/OS-managed Ollama daemon). If you're still using the old CUDA_VISIBLE_DEVICES stop-gap in server/.env, Advanced Configuration now flags it — it silently overrides every per-engine pin below it — with a one-time cutover guide. Auto-recovering from a repeated bad pin remains a follow-up (#1224, closes #1222).

⏳ Analysis progress on the Status pill, with a Generate hold (new) — fe-45

The two manuscript-analysis passes — Detect emotions and Review Script — now report live on the top-bar Status pill, and Generate waits until they finish so you never queue work over a half-analysed book.

  • A live "Analysing" rung — while Detect-emotions or Review Script runs, the Status pill shows an "Analysing" sub-stage with a percent ticker, visible from every view (not just Manuscript). The pill ladder is now Generating › Loading model › Analysing › analysis-substage › Designing, and the old standalone prosody pill is retired — the Status pill is now the single place to watch analysis (#1187).
  • Generate held per book — the Generate button is disabled for a book while its analysis runs, with a plain-language reason ("Wait — emotions are still being detected" / "Wait — script review is in progress"); the two analysis passes are also mutually exclusive per book since they share the analyzer. The hold is enforced at the queue layer too, so a background auto-resume can't slip past it (#1187).
  • Holds across tabs — analysis progress is synced over BroadcastChannel, so the pill and the Generate hold stay consistent in every open tab; the eager emotion-detection auto-trigger is guarded against double-firing (#1187).
  • "Voice engines" — the Status-popover model-control section is relabelled from "TTS engines" to "Voice engines" (#1187), and the app-wide copy rename (fe-44) is now complete across the cast/lifecycle/profile-drawer eviction banners, the voice library panel, voice preview, and Model Manager help text (#1189, closes #1182).

✨ Smaller features & UX

  • Promote a manuscript's first sentence to its chapter title (new) — when an unstructured import leaves the real chapter title sitting as the chapter's first narrated sentence, a "Use first line as title" button on the Manuscript view's chapter-heading row renames the chapter to that (cleaned) sentence and permanently removes it from narration, with an inline confirm showing the exact text before committing. Picks the sentence by minimum id, not array position, so it survives a re-analysis merge (#1211, closes #1210).
  • Series Memory reveal panel polish (#1200) — the modal is wider (max-w-2xl) with a grid name column that no longer wraps a long name like "Skulduggery Pleasant", and the carried-cast list now sorts by total lines spoken (descending) instead of alphabetically, so the biggest parts lead; and the library-card carried-cast sparkline no longer stretches to full width (#1200, #1254).
  • Honest streamed voice-design progress (#1089) — the single-design progress bar now streams real per-phase progress from the sidecar (best-effort report_progress → a loopback-only /api/internal/design-progress relay → the existing SSE → a monotonic Redux phase), replacing a hardcoded ~15 s ETA, an always-firing "GPU may be busy" warning, and fake phases. Opt-in per job: the bulk "Design full cast" and REST design paths send byte-identical sidecar bodies and are unaffected. The per-phase budgets behind the bar's sub-fill and ETA are now calibrated to on-box design timings (loading ~16.7 s / designing ~19.9 s / distilling ~0.3 s / rendering ~20.3 s), so the estimate reflects a real first design rather than a placeholder (#1114, closes #1092).
  • Per-regenerate model override, and the modal keeps its footer on-screen (#1080, #1144) — the Regenerate modal gains a Model picker (Qwen 0.6B / 1.7B), so a chapter can be re-rendered at the 1.7B Quality tier without re-casting every character; the choice threads through (and persists on) the queue entry, absent → the session default, byte-identical to before (closes #1079). Its height is also now capped with internal scroll so the action footer stays reachable on a short window / phone (closes #1135).
  • Emotion-variant mint fallback when the 1.7B is absent (srv-52, #1096) — if the Qwen 1.7B-Base is not installed or corrupt, the server transparently mints the requested emotion variant via the old /qwen/design-voice path (persona + emotion instruct) instead of failing. A transient VRAM OOM stays a loud failure (VoiceDesign would OOM identically). Never silent — a server log line, a per-character note on the bulk Design toast, and a durable mintMethod: "design-voice-fallback" marker that remint-anchored-variants.mjs upgrades once the 1.7B is back (closes #1091).
  • QA issue affordances (#1073) — issue markers expose their reasons on hover (a title per amber bar + on the "N issues" caption and the MiniPlayer ⚠ jump buttons), and the preview-player waveform now fills the scrubber (flex-1 bars) instead of leaving a flat tail (closes #1070, #1071).
  • Manuscript review-session batch + analyzer model pill (#1065) — Review-Script flyout z-index (z-50/picker-surface), a dark-mode-readable emotion/confirm popover, an empty-state for a zero-suggestion review, excluded-chapter scoping for Detect-emotions / whole-book Review-Script, and the resolved analyzer model now surfaced as a pill in the Status popover (closes #1059–#1062, #1064).
  • Direct any line by hand — manual per-line instruct editing (new) — fs-56 (#1109) — a 🎬 chip on every manuscript line (the narrator's included) opens a popover to write or refine that line's free-text delivery direction — the top "manual" rung of the fs-57 manual › analyzer › emotion-derived › neutral resolver ladder, now reachable from the UI. The chip is muted with a "needs the 1.7B Quality tier" caption when the book's expressive delivery is off, and a manual edit conservatively marks a rendered chapter stale. No schema change — it reuses the existing instruct field (a new setSentenceInstruct reducer; "manual wins" is still the fill-only applyDetectedInstruct).
  • Auto-nudge to voice a newly-created character (new) — fs-63 (#1141) — when a Script Review reattribute creates a new off-roster character, Castwright pushes a busy-aware toast nudging you to design it a voice (Qwen only; it waits while the GPU is mid-render and merge-dedupes), so a freshly split-out speaker doesn't silently fall back to a narrator voice. apply-proposed now returns the createdCharacters {id, name} that powers the nudge.
  • Script Review surfaces a failed chapter (#1126) — a chapter whose review fails now raises a toast instead of opening an empty diff modal — wired through both the SSE handler and the detect-emotions / instruct handlers.
  • On-brand checkbox component (new, #1235) — every checkbox in the app (~20 across account settings, cast, and modals) rendered as a raw, unstyled <input type="checkbox"> — the browser's default blue accent, since the repo has no @tailwindcss/forms plugin to restyle it. A new shared Checkbox primitive (src/components/primitives.tsx) replaces all of them: a magenta-filled square with a white checkmark, matching focus ring, and the same 44px touch target, so it finally reads as part of Castwright rather than a browser default.
  • Chapter counts + ETA on the analysis progress surfaces (new, #1219) — Detect-emotions and Review-Script now show "Chapter 3 of 12 · ~2m left" instead of a bare percentage. Three SSE routes (annotate-emotion.ts, instruct-annotation.ts, script-review.ts) track an observed ms/char pacing rate per pass (no fixed baseline, no floor — a lighter sibling of analysis.ts's heavier ETA machinery; a failed chapter still counts its wall-clock time toward the rate) and emit chapterIndex/totalChapters/estRemainingMs on every phase event. A shared pure formatter renders the enriched text identically in the Status-popover's substage row, the existing Detect-emotions inline chip, and a brand-new Review-Script inline chip (previously a static "Reviewing…" label with no progress feedback at all). Because "Detect emotions" is really two full passes over the same chapters, prosody-thunk.ts reconciles them into one combined, non-resetting ETA (pass 1: its own remaining time plus a projection of pass 2's full duration; pass 2: its own number once available) instead of two independently-resetting counters. The compact top-bar Status pill stays untouched/terse by design. Builds on fe-45/#1187's per-book activeStreams substage plumbing (see plan 236).

🏗️ Under the hood

  • Optional GPU path for the drift embed (srv-47)SPK_DEVICE=cuda is now safe to set: weighted-VRAM-semaphore-gated (like the ASR engine), idle-evicting (default 120 s), with load-time CPU degrade/demote and an /embed poison fence that now covers the model load. CPU stays the hard default; a one-time WARN fires if cuda is set under a GPU budget < 2, where the embed would serialise behind synth and run slower than the free CPU path (#1003).
  • The 1.7B Quality tier, tuned for an 8 GB card (#1161, #1163) — pinning a whole cast to 1.7B on an 8 GB GPU used to OOM-storm (the supervisor recycling mid-batch). The fix is no longer a conservative batch cap: the per-chapter readiness gate now polls GET /health instead of POSTing /load (which was warming the 0.6B base as a side effect and letting it squat a pure-1.7B render), a run-start reconcileResidentQwenTiers evicts any Qwen tier the run won't use, and with the 0.6B gone the 1.7B batch defaults return to 32 / 3600 (measured RTF 0.49, ~1.7 GB margin under the soft recycle on the 8 GB box). Tier-aware batch knobs remain the small-card safety valve (closes #1158, #1162).
  • Design-side VRAM exclusion (#1155) — bulk Design full cast on an 8 GB card recycled the sidecar once per voice because VoiceDesign (1.7B) and the 1.7B-Base were never evicting each other. design_voice now drops a resident _base17 and mint_variant drops a resident _design before loading — mirroring the existing synth-path / Kokoro eviction — so only one heavy 1.7B model is resident at a time and the per-voice recycle storm stops (closes #1156).
  • UTF-8 request bodies in the sidecar (#1023) — the sidecar now parses 3-byte UTF-8 JSON request bodies, so a line containing an em dash (or other non-ASCII punctuation) no longer 400s on synthesis.
  • Admin model-name telemetry (#1173) — the admin Resource Trends rows now carry a human-friendly TTS model label, so a render's tier is legible at a glance.
  • LAN pairing no longer crashes server startup (#1170) — the pair-redeem rate-limiter read the client IP with a bare keyGenerator, which express-rate-limit v8 rejects at construction (ERR_ERL_KEY_GEN_IPV6) — throwing at import time so the LAN dev server never bound its port. Wrapping it in ipKeyGenerator (which normalises IPv6 to a /56 subnet so v6 clients can't rotate host bits past the cap) fixes startup and unreddens pairing.test.ts.
  • srv-36 Phase-2 measurement harness (#1009, #1018) — the cross-book voice-consistency evaluator that backs the per-book report: blind-listen harness + synth request-builder, voice_index walk/join + inventory probe (voiceUuid via cast join), cross-book metric helpers with pre-registered go/no-go thresholds, a per-axis report assembler, and a --counts sizing helper (clean segments per voiceUuid per book). Operator-run measurement, not a render-path change.
  • Assembly no longer stalls behind the voice-drift score pass (#1037) — with the srv-36 voice-drift gate on (SEG_SPK_ENABLED=1), a chapter could synthesise every sentence, emit chapter_assembling, then hang until the 720 s no-progress watchdog killed it (synth-timeout) — making it look VRAM/gate-correlated. The real cause: the whole-book scoreBook render-integrity pass ran inline on the chapter-completion path and, for a too-thin/bimodal character, re-rendered its sample 12× through the sidecar for an ECAPA audition while feeding no progress to the per-chapter watchdog. The score pass no longer blocks chapter finalization (closes #1029).
  • Design Qwen voices without a Gemini key (srv-48) — a voice-design persona (the natural-language instruct that seeds each character's designed voice) can now be generated by a local Ollama model instead of Gemini, via a local | gemini provider toggle (PERSONA_GEN_ENGINE, default gemini; PERSONA_GEN_LOCAL_MODEL blank-inherits the analyzer's model), so a fully offline / no-cloud-key install can design its full cast. It mirrors the analyzer's explicit opt-in (no silent cross-provider fallback) and finally wires the previously-disconnected analyzer.gemini.voiceStyleModel registry knob. The local path is GPU-coexistence-aware on a constrained card — it evicts the idle sidecar model under a full-budget, fail-closed reverse-evict and falls back to CPU while a render is in flight so it never disturbs a live synthesis — shared across the single-design, generate-all, and bulk "Design full cast" paths (the bulk job gains a local-only persona pre-pass) (#1052).
  • ~2× faster QA re-records (#1072) — the signal-QA (plan 179) and ASR content-QA (srv-31) re-record loops are now round-based and batched via a reusable synthGroupsBatched helper: each round re-synthesises all still-failing groups in one batched call, keeps the best take, and drops the recovered ones. Recovers the unbatched-re-record RTF regression (~1.9–2.0 → batched-floor target ~1.2 on a KotLC chapter) while preserving best-of-N, the per-group budget, 0.6B/1.7B batch isolation, abort + recycle-recovery, and the no-progress watchdog. The initial body pass is byte-identical (closes #1069).
  • ASR content-QA, fewer false positives (#1086, #1087, #1088) — compound-word re-tokenisation (CurvebusterCurve Buster, good byegoodbye) and confident Whisper boilerplate hallucinations (OceansofPDF.com, "subtitles by…", "thanks for watching") no longer flag drift; calibration-pangram bleed (#1074) is now quarantined — dropped to brief silence + hard-flagged suspect/quarantined after the re-record budget fails, instead of shipped to the listener; and the classifyTranscript pass is language-aware with per-language maxWer knobs scaffolded (SEG_ASR_MAX_WER_ES/_RU, values owed on-box calibration). English byte-identical (closes #1083, #1085; refs #1074, #1084).
  • QA-gate false positives, round 2 — fewer wasted re-records (#1190, #1192) — a duration-runaway floor (qa.seg.minRunawaySec, default 3 s) stops flagging one-word lines as "runaway"; fuzzy compound-bridging and a short-reference substitution backstop stop a Whisper word-split ("Skulduggery""Skull Duggery") or a single misheard word on a 1–2 word line from forcing a re-record; loop detection now runs under the short-line floor too, so a genuine repeated-line defect still flags. A static dry-run over a 7,452-segment corpus took duration-runaway false positives from 54 → 0 and ASR-drift false positives from 124 → 14 residual, with every known real defect still flagged. Each change is behind its own disable knob; definitive on-box RTF improvement is owed (closes #1191).
  • QA cost made observable — rerecordRtf telemetry (#1194) — the admin throughput table gains a QA column showing the re-record wall-clock cost separately from synth RTF, so the cost the false-positive fixes above remove is actually visible; gated on genuine per-chapter concurrency so contended renders show "n/a" instead of a misleading number. GET /api/generation/stats also gets its first real OpenAPI coverage.
  • Localized minor-cast fold + roster guard for es/ru/fr/de (#1054) — the descriptor fold (isDescriptorName) and the roster-coverage guard (validateRosterCoverage/validateAttributionCoverage), previously English-only, now recover under-rostered speakers for Spanish/Russian/French/German via a new descriptor-grammar.ts + the per-order tag-grammar.ts substrate (both-orders detection, Unicode-safe boundaries, a German capitalized-title skip). This is the real fix for the on-box Berrin/Ivo loss (#1028 was rostered-only); English byte-identical. The fr/de heuristics are now live since those languages shipped in #1101 (closes #1050, #1051).
  • fe-40 carried-cast fixes (#1058) — the carried counter is now fork-safe: deriveSeriesMemory replaces a directional matchedFrom tail-walk with union-find connected components, correctly handling a voice designed in a later book and reused into an earlier one; and the reveal panel adopts the house modal pattern (full-screen sheet on phone, sm:max-h-[90vh] + internal scroll on desktop) so a large carried cast no longer pushes the footer off-screen (closes #1055, #1056).
  • Graceful missing-voice failure (#1066) — synthesising a Qwen voice/variant with no cached .pt embedding now returns a clean 409 voice_not_designed ("design it first, then play the sample") instead of an opaque 500; the CUDA-poison fence is correctly skipped (a missing .pt never poisons the context) (closes #1063).
  • srv-43 voiceUuid .pt orphan: heal + prevent (#1067, #1075, #1077, #1081) — a voice designed before its uuid was stamped kept its .pt at the legacy qwen-<voiceId> key, so once a uuid landed every resolver pointed at a missing qwen-<uuid>.pt → silent Kokoro fallback + a no-op "Design full cast → Emotion variants". scripts/repair-qwen-voice-uuid-keys.mjs (group-by-name, fixes even cyclic-reuse voices) re-keys the files (23/26 healed on the live workspace), scripts/normalise-stale-qwen-voice-names.mjs makes record names honest, and the recurrence is gated permanently — a variant design no longer mints a fresh uuid (it reuses the base's), so the orphan can never re-form. scripts/invalidate-stale-qwen-base-samples.mjs clears the stranded base voice-sample .mp3s, and cast-review "Play 12s" now injects voiceUuid for sample-cache-key parity with the profile drawer.
  • srv-50 — shared loadPostFoldSentencesByChapter (#1068) — extracts the helper fs-58 deliberately copied byte-for-byte into the annotate-emotion and script-review routes into server/src/store/post-fold-sentences.ts (−88 lines of duplication; pure refactor) so the two routes can't silently drift.
  • Non-English dialogue no longer lost in attribution (#1123) — the evidence verifier now folds German and guillemet quote glyphs („…", «…»), so a quote whose glyph the matcher didn't fold can't drop the line to the narrator — a class of "characters vanish in a non-English book" bug.
  • Stale-on-text-edit (#1112) — editing a rendered sentence's text now flags its chapter stale (an OR with the boundary-move heuristic), so a manual wording fix can't ship the old audio (#1105).
  • Merge keeps its emotion + direction (#1130) — merging adjacent sentences now hydrates and book-scopes the survivor's emotion/instruct, so a merge can't silently drop a line's per-quote emotion or expressive direction.
  • Cleaner EPUB/MOBI chapter bodies (#1133) — a chapter-title heading duplicated at the top of an EPUB/MOBI chapter body is stripped, so the title isn't read aloud twice.
  • Two dev-box pytest repairs (#1145, closes #1140) — fixes two sidecar test failures that only surfaced on a bootstrapped dev box (the venv-gated tier CI skips).
  • Model Manager's Voice model picker exposes Qwen3-TTS 1.7B alongside 0.6B (#1171) — the default TTS model picker (Account → Models & engines → Model Manager) was missing Qwen3-TTS 1.7B; it's now a first-class picker choice with the tier's hint, and picking it correctly warm-loads the Qwen Base at sidecar startup instead of silently falling through to Kokoro's preload — previously costing a cold load on the first 1.7B chapter. TTS_MODEL_KEY_VALUES gains 'qwen3-tts-1.7b' (the PUT allow-list was the one model-key surface that hadn't been updated when the engine landed). The preload knob itself was reworked shortly after into a flat per-engine toggle — see the toggle-dedup entry below (#1234).
  • Security hardening — two CodeQL alerts resolved (#1199) — a stack-trace info-exposure path and a polynomial-time (ReDoS) regex flagged by CodeQL are fixed; no user-visible behaviour change.
  • Analyzer device row tells "idle" apart from "unreachable"; PUT /api/config stops re-fetching the sidecar device list per key (#1227, closes #1225) — the Advanced Configuration read-only analyzer-device row showed "Unknown" almost always, because Ollama's default keep_alive had usually already evicted the model by the time someone opened the page; it now distinguishes Idle (Ollama reachable, nothing loaded) from Unreachable (daemon didn't answer). Separately, a PUT /api/config patching all three tts.*.device knobs in one request used to pay one sidecar /devices round-trip per key — it now resolves the list once per request.
  • Setup checker and Status popover now diagnose why the voice engine or analyzer isn't ready, with a working one-click fix where a safe automated fix exists (new, #1252) — replaces the flat pass/fail on each of the four Setup blockers (sidecar, tts, ffmpeg, analyzer) with a structured diagnosis (18 fail causes across ordered, first-match-wins chains, e.g. venv-missing/venv-broken/supervisor-exhausted/no-engine-installed/ollama-unreachable) plus a BlockerFixAction component driving the safe automated fixes (venv bootstrap, engine installs, sidecar restart, Ollama install/pull, navigate-to-setting). One shared, pure diagnosis engine (server/src/routes/setup-diagnosis.ts) and one shared polling hook feed both the Setup wizard and the header Status popover, so they can't drift into disagreeing about the same blocker. Also merges the sidecar supervisor's trip-recovery and plain-exhaustion recovery into one resetAndRespawn(), and adds a probe distinguishing a missing venv from one whose install was interrupted partway through (closes #1251).
  • Advanced Configuration's docs/local-llm.md links stop 404ing in a production build (#1226, closes #1223) — the CUDA env-shadow banner and the read-only analyzer-device row both link to docs/local-llm.md, which resolved fine against the Vite dev server (it serves the whole repo) but 404'd once built, since docs/ isn't part of the shipped dist/ bundle. A new scripts/sync-docs-to-public.mjs, wired as prebuild, mirrors it into public/docs/ (git-ignored, regenerated every build) so both links resolve in dist too.
  • Preload-at-startup toggle dedup (#1234) — Model Manager's "Eager-load Kokoro/Qwen at startup" checkboxes duplicated Advanced Configuration's tts.preload.kokoro/tts.preload.qwen/tts.preload.qwenBase17 knobs (same PRELOAD_KOKORO/PRELOAD_QWEN/PRELOAD_QWEN_BASE17 env vars), with the Advanced Settings copy silently winning whenever it was ever touched and no UI indication that the Model Manager checkbox had gone inert. The Model Manager toggle and the eagerLoadKokoro/eagerLoadQwen user-setting fields are removed; Advanced Configuration is now the sole owner. This also drops the old implicit coupling where only the resolved default engine's preload toggle took effect and the other engine was forced lazy — each tts.preload.* knob is now a flat, independent boolean (matching how tts.preload.coqui already worked), so e.g. Kokoro and Qwen can both be pinned to preload at once if you want the VRAM cost. A one-time migration translates any existing eagerLoadKokoro/eagerLoadQwen value into an equivalent tts.preload.* override on first read after upgrade, so effective preload behaviour doesn't silently change.
  • Prod index.html no longer cached for an hour; startup banner prints the served build (#1240)express.static served index.html with the same Cache-Control: max-age=3600 as the content-hashed /assets/* bundle, so an already-open browser tab could keep rendering a stale build for up to an hour after a rebuild + restart. index.html now gets Cache-Control: no-cache (always revalidates); hashed assets keep the 1h cache. vite build also writes dist/build-manifest.json (sha/branch/dirty/build-time), which the prod launcher (start-prod.bat / npm run start:prod) now prints at startup, so the served commit is visible before you ever load the app (closes #1238).
  • fs-54: Audiobookshelf export robustness — series metadata (M4B FFMETADATA + mp3-folder ID3), an ABS-native metadata.json + folder cover.jpg, an M4B option on the Audiobookshelf tile, author-level sync-folder nesting (breaking layout change for existing Voice-tile/mp3-zip sync-folder users) — plus a new global Export status pill mirroring Analysis/Generation/Design. Closes #978. (#1250)
  • Design specs (not yet shipped) — the #1002 expressive-TTS roadmap has now largely landed (see the headline features above), leaving faster rendering via a Qwen Code2Wav torch.compile codec path (timing/bench instrumentation landed in #1193; the compile path itself stays deferred — #989) and two parked next-gen engine designs still on the drawing board; plus backlog triage, a README claims block, and srv-36 ship-notes / spec housekeeping (#990, #994, #995, #980, #971, #970).
  • Two server stacks can't fight over the one sidecar (#1036) — a dev server (:8080) and a start:lan build (:8443) running at once shared the single global :9000 sidecar, and each stack's supervisor saw the other's healthy-but-foreign sidecar as "unfit" and kill/respawned it — an endless recycle storm that stalled any in-flight generation (repeated chapter_recovering). The owning server now drops a .run/tts.owner.json owner note (pid + parent pid); a second server that finds a live foreign owner refuses to start with an actionable FATAL line instead of standing up a rival supervisor (closes #1030).
  • Auto-recycle a wedged (not crashed) TTS sidecar (srv-50, #1248, closes #1243) — a sidecar that hangs mid-request instead of crashing (observed 2026-07-03: an eGPU dropping off the CUDA bus mid-call left the process alive but unresponsive) used to squat on :9000 indefinitely — the existing exit-driven crash-loop supervisor never fires because the process never exits, and the chapter stall watchdog only failed the chapter, leaving a human to notice and manually taskkill it. A new forceSidecarRecycle helper reuses the exact kill primitive POST /api/sidecar/restart already used, wired into three trigger points (readiness-poll exhaustion, a synthesis-phase chapter stall, and an exhausted in-loop recycle budget) so the existing respawn path brings up a fresh process automatically. Also closes a previously-unprotected ASR verify() call — the exact call that hung in the 2026-07-03 incident — with the same per-call timeout every synth call site already has. Follow-up filed: srv-51 (#1247) for the one remaining unprotected call site (title-narration synth).
  • Friendly LAN hostnames. dev:lan/start:lan now serve https://castwright.dev.local:5173 / https://castwright.local:8443 alongside the existing raw-IP URLs, via a new mDNS responder (scripts/mdns-responder.mjs) — no new manual step, resolves automatically from iOS/Android/macOS LAN devices. Plan 239 (#1246).
  • mDNS responder now warns on an external kill (#1257, closes #1249) — the exit handler's code !== null exemption was meant to protect the intentional kill() path, but that path was already covered by the killedIntentionally flag one line above; its only real effect was to silently swallow every other null-code exit too (an OS OOM-kill, an external pkill, a crash by signal). castwright.local now logs [mdns] responder … exited unexpectedly in those cases instead of quietly going dark with no diagnostic trail. No functional change — the LAN-IP fallback already worked correctly regardless (closes #1249).

Full changelog: v1.9.0...v1.10.0

Clone this wiki locally