-
Notifications
You must be signed in to change notification settings - Fork 0
Release Notes 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.
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-cast —
esis 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) beforeeswas switched on (#1011, #1015, #1016, #1019, #1031, #1032). -
French & German books, full-cast —
franddeflippedsupported:trueafter 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
languageCodefrom 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 language —
sidecarLanguageNamenow throws for a language the sidecar can't perform instead of silently defaulting to English (#1022).
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.aialways 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, unified —
deriveSeriesMemorynow judges voice-consistency on both identity facets (the cross-book reusevoiceIdand the per-engine voice name), so a character who debuts in book 1 (nullvoiceId) 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).
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).
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.7bengine 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_BASE17keeps 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.7btier via a newPOST /api/books/{bookId}/cast/tierendpoint (with a series-wide "Reset tier"); the existing Regenerate Model picker's 1.7B choice is the same whole-book path. The write touches onlyttsModelKey, 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 thesynthesiseChapterliveInstructsynth option were removed;liveInstructsurvives only as an analysis-time "annotations produced" signal (#1136). -
A tier prompt when you press Generate — generation start now opens a
StartGenerationModalthat 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 + Bwhen 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).
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.mjsre-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 Englishinstructfor 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
generateforward carries heterogeneous per-iteminstruct_ids, neutral lines ride the pinnedNEUTRAL_INSTRUCT=""no-op (measured on-box, C2 gate), with an instruct length cap + raw-generatedrift 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
prosodyAnnotatedwatermark runs it once); unchecking pre-analysis is honored as an explicit opt-out. The per-book flag was renamedliveInstruct→prosodyEnabled— 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
instructat 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).
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 flippedsupported: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-langCodepath 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 / spuriousasrSuspect, sidestepping the non-English number-WER gap (#1084) by pre-spelling the numbers (#1094).
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-Englishinstructwhen 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-gatedinstructHash) (#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 aScriptReviewDiffmodal grouped by class with per-class / per-change toggles; accepted ops apply in the browser and mark the chapter's audio stale viaboundary_move(#1047). -
Correctness guards — anchors resolve client-side at accept time against live sentence text (one-pass index map, TOCTOU-safe;
planApplyre-validates every accepted op), andmergeleaves 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 distinguishesno_such_chapterfromno_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).
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:Npin 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_devicegrammar 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 detectedcuda: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 visibility —
GET /api/gpu/deviceslists the cards torch enumerates (name / total / free MB), and/healthgains an additivegpus[]reporting each card'storch_reserved_mband aresident[]of which engines actually live on it — with acpu_fallbackmarker 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 oldCUDA_VISIBLE_DEVICES=1,0stop-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 oldCUDA_VISIBLE_DEVICESstop-gap inserver/.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).
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).
- 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 agridname 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-progressrelay → 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-voicepath (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 durablemintMethod: "design-voice-fallback"marker thatremint-anchored-variants.mjsupgrades once the 1.7B is back (closes #1091). -
QA issue affordances (#1073) — issue markers expose their
reasonson hover (atitleper amber bar + on the "N issues" caption and the MiniPlayer ⚠ jump buttons), and the preview-player waveform now fills the scrubber (flex-1bars) 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
instructediting (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-57manual › analyzer › emotion-derived › neutralresolver 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 existinginstructfield (a newsetSentenceInstructreducer; "manual wins" is still the fill-onlyapplyDetectedInstruct). -
Auto-nudge to voice a newly-created character (new) — fs-63 (#1141) — when a Script Review
reattributecreates 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-proposednow returns thecreatedCharacters {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/formsplugin to restyle it. A new sharedCheckboxprimitive (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 ofanalysis.ts's heavier ETA machinery; a failed chapter still counts its wall-clock time toward the rate) and emitchapterIndex/totalChapters/estRemainingMson everyphaseevent. 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.tsreconciles 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-bookactiveStreamssubstage plumbing (see plan 236).
-
Optional GPU path for the drift embed (srv-47) —
SPK_DEVICE=cudais 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/embedpoison 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 /healthinstead 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-startreconcileResidentQwenTiersevicts 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_voicenow drops a resident_base17andmint_variantdrops a resident_designbefore 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 inipKeyGenerator(which normalises IPv6 to a/56subnet so v6 clients can't rotate host bits past the cap) fixes startup and unreddenspairing.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_indexwalk/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--countssizing 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, emitchapter_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-bookscoreBookrender-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
instructthat seeds each character's designed voice) can now be generated by a local Ollama model instead of Gemini, via alocal | geminiprovider toggle (PERSONA_GEN_ENGINE, defaultgemini;PERSONA_GEN_LOCAL_MODELblank-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-disconnectedanalyzer.gemini.voiceStyleModelregistry 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
synthGroupsBatchedhelper: 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 (
Curvebuster↔Curve Buster,good bye↔goodbye) and confident Whisper boilerplate hallucinations (OceansofPDF.com, "subtitles by…", "thanks for watching") no longer flagdrift; calibration-pangram bleed (#1074) is now quarantined — dropped to brief silence + hard-flaggedsuspect/quarantinedafter the re-record budget fails, instead of shipped to the listener; and theclassifyTranscriptpass is language-aware with per-languagemaxWerknobs 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 —
rerecordRtftelemetry (#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/statsalso 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 newdescriptor-grammar.ts+ the per-ordertag-grammar.tssubstrate (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:
deriveSeriesMemoryreplaces a directionalmatchedFromtail-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
.ptembedding now returns a clean409 voice_not_designed("design it first, then play the sample") instead of an opaque500; the CUDA-poison fence is correctly skipped (a missing.ptnever poisons the context) (closes #1063). -
srv-43 voiceUuid
.ptorphan: heal + prevent (#1067, #1075, #1077, #1081) — a voice designed before its uuid was stamped kept its.ptat the legacyqwen-<voiceId>key, so once a uuid landed every resolver pointed at a missingqwen-<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.mjsmakes 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.mjsclears the stranded base voice-sample.mp3s, and cast-review "Play 12s" now injectsvoiceUuidfor 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 intoserver/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_VALUESgains'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/configstops 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 defaultkeep_alivehad 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, aPUT /api/configpatching all threetts.*.deviceknobs in one request used to pay one sidecar/devicesround-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/failon 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 aBlockerFixActioncomponent 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 oneresetAndRespawn(), and adds a probe distinguishing a missing venv from one whose install was interrupted partway through (closes #1251). -
Advanced Configuration's
docs/local-llm.mdlinks stop 404ing in a production build (#1226, closes #1223) — the CUDA env-shadow banner and the read-only analyzer-device row both link todocs/local-llm.md, which resolved fine against the Vite dev server (it serves the whole repo) but 404'd once built, sincedocs/isn't part of the shippeddist/bundle. A newscripts/sync-docs-to-public.mjs, wired asprebuild, mirrors it intopublic/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.qwenBase17knobs (samePRELOAD_KOKORO/PRELOAD_QWEN/PRELOAD_QWEN_BASE17env 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 theeagerLoadKokoro/eagerLoadQwenuser-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 — eachtts.preload.*knob is now a flat, independent boolean (matching howtts.preload.coquialready 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 existingeagerLoadKokoro/eagerLoadQwenvalue into an equivalenttts.preload.*override on first read after upgrade, so effective preload behaviour doesn't silently change. -
Prod
index.htmlno longer cached for an hour; startup banner prints the served build (#1240) —express.staticservedindex.htmlwith the sameCache-Control: max-age=3600as 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.htmlnow getsCache-Control: no-cache(always revalidates); hashed assets keep the 1h cache.vite buildalso writesdist/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+ foldercover.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.compilecodec 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 astart:lanbuild (:8443) running at once shared the single global:9000sidecar, 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 (repeatedchapter_recovering). The owning server now drops a.run/tts.owner.jsonowner note (pid + parent pid); a second server that finds a live foreign owner refuses to start with an actionableFATALline 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
:9000indefinitely — 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 manuallytaskkillit. A newforceSidecarRecyclehelper reuses the exact kill primitivePOST /api/sidecar/restartalready 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:lannow servehttps://castwright.dev.local:5173/https://castwright.local:8443alongside 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 !== nullexemption was meant to protect the intentionalkill()path, but that path was already covered by thekilledIntentionallyflag one line above; its only real effect was to silently swallow every other null-code exit too (an OS OOM-kill, an externalpkill, a crash by signal).castwright.localnow logs[mdns] responder … exited unexpectedlyin 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
- Home
- Getting Started
- Installing Castwright
- Uploading a Book
- Manuscript Management
- Analysis & the Analyzer
- Reviewing Low-Confidence Speaker Tags
- Generating Audio
- The Quality Gate
- Listening & Revising
- Exporting