- fix: base-appearance matched pair lost its concept image + metadata on reload; visual save
wrote base concept art to the record root; one save path for base and forms now
- fix: extraction no longer lets the LLM author image_prompt (source of avatar/concept drift);
avatar-only generation uses the deterministic prompt and flags an existing pair
- fix: prompt writes and Physical/Clothing edits go to the active form; review endpoint checks
each form's own pair; fingerprint parity with the browser incl. visual_* overrides
- feat: appearance forms panel (add/rename/delete/show, per-form stale flag, generate one/all)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Split the book into narration/dialogue spans from its own quotation marks and ask
the LLM only who speaks each dialogue span (POST /api/attribute-spans), so text can
no longer be fused, dropped or duplicated and a novel takes ~10 min instead of ~4 h.
Book 1 vs the hand-corrected reference: every-word-right 87.0% -> 92.3%, type
agreement 93.6% -> 97.4%; Books 2/3 (not tuned on) 90.8% / 89.9%.
- static/js/audiobook-spans.js (pure, unit-tested) + audiobook-cast-spans.js
- core/attribution_spans.py, route, tests; scripts/score_attribution.py
- default on; localStorage ab-span-mode=0 restores the classic engine
- architecture/audiobook-attribution.md, Manual, CHANGELOG 1.32.0, TODO
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- fix: Edit boxes and page-nav controls were dead clicks (handlers never exported
to root); resize from any edge/corner instead of only bottom-right
- feat: two-page spread in review (page images above, OCR lists beneath)
- feat: searchable voice picker for narrator and podcast hosts
- feat: Prepare audio synthesises the whole article ahead of playback; playback
also stays three sentences ahead
- fix: sync index.html cache-busting version with VERSION (was stuck at 1.26.12)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds "Edit boxes" mode to article review: drag an already-detected
region to move it, its corner handle to resize it, or press its x to
delete it -- for correcting a box the layout engine got wrong, not one
it missed entirely ("Draw it" still covers that case). Moving or
resizing opens a confirm panel; "Save" keeps the region's existing
text at its new position, and for text regions "Save & re-OCR" reads
the corrected area again and replaces the text.
New core/magazine.py update_region()/delete_region() and PATCH/DELETE
/api/magazine/{doc}/pages/{page}/regions/{block_id}. An image region
is re-cropped from the page render whenever its bbox changes, since
its saved file is exactly that crop.
Live-verified against the real ingested c't KI Wissen 2026 magazine:
add -> move -> move+re-OCR (correctly kept old text when the corrected
area had nothing readable) -> delete -> confirmed gone, repeat delete
404s.
275 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a "Describe" action per figure in article review: sends the cropped
image to the configured vision engine (with fallback) for a type-aware
one- or two-sentence description -- a chart gets a prompt about what it
measures and its trend, a table about headings and what stands out, etc.
Editable before approval. A new "Read figure descriptions" toggle in the
reading controls speaks these where they occur; purely additive, off by
default, and a figure with no description behaves exactly as before.
New core/magazine.py describe_visual_region()/_chain() reuse the same
reliability guards as page OCR (repetition-loop detection, sentence-
boundary truncation). New routes: POST .../images/{id}/describe
(background job), PATCH .../images/{id} (manual edit), descriptions=1 on
the article-text endpoint.
Live-verified end to end against the ingested c't KI Wissen 2026
magazine. Also found and documented a real, config-dependent limitation:
the currently-configured primary vision engine is transcription-only and
returns empty content for any descriptive prompt (confirmed with three
different prompts) -- not a code defect, but description generation
needs a genuinely caption-capable engine reachable as the fallback.
251 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Connect and "Use as LLM" on the LiteLLM Gateway card now resolve that
card's own saved key (engine_api_keys[name]) server-side in
routes/docker.py, ahead of the old global llm_api_key fallback. The
browser can no longer reliably forward a saved key at all -- it's
masked to "__configured__" in every /api/settings response the moment
it's saved -- so relying on a frontend-forwarded header alone left
Connect silently using a stale global key after LiteLLM's own key was
rotated, surfacing a misleading /api/version 404 instead of the real
401.
"Use as LLM" now goes through a dedicated /use-as-llm endpoint that
saves the exact credential Connect just verified (llm_url normalized
to .../v1, llm_api_key copied from the card), rather than a
separately-typed value that could drift again.
Also documents per-user provider-key storage as an explicit item for
the deferred multi-user/identity planning pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two reports from the same mobile session tied together: a voice-library
crash ("fmtDbfs is not defined") and a save button reporting "No audio
ready" despite a file being correctly loaded and trimmed. Root cause
was the same in both -- the module loader fetched each JS file once
with no retry, so a single dropped request on a flaky connection left
that module's functions/variables undefined while every other module
kept executing anyway, producing confusing symptoms nowhere near the
real cause.
loader.js: _load() now retries up to 3 times with backoff before
rejecting. If a module still can't load after that, the app says so
plainly via a toast (or a plain DOM banner if even utils.js failed to
load) instead of silently revealing a partially-broken page. Wired into
both the core-module fallback path and ensureFeatureModules' lazy path.
Loading every section of the app at a 375px viewport and checking for
elements running past the viewport edge (all now 0) found eight real,
independent layout bugs, each with a distinct root cause:
- .section-head's mobile wrap rule targeted .section-header, a class
nothing in the app uses -- dead since it was written. Every section's
icon/title/action row (e.g. "+ New voice") ran off-screen unreachable.
- The synth-mode toggle and Clone-a-Voice source tabs' icon-only mobile
rules matched a <span> wrapper the markup didn't have. Added it, plus
aria-labels so hiding the text doesn't erase the accessible name.
- voice-design.css loads after style.css and re-declares
.design-sample-grid unscoped -- style.css's mobile override always
lost the cascade regardless of viewport. Moved the override to the
file that actually wins.
- Three separate minmax(380-440px,1fr) engine-card grids couldn't fit
one column under ~460px. Collapsed to 1fr with min-width:0 on the
items -- the column fix alone wasn't enough, since a grid item's
content-based min-width is still a floor even inside a 1fr track.
- The routing test row (450px+ minimum) and the conversation input bar
(a single non-wrapping flex row for text field + mic + gate + three
toggles) now wrap under 767px.
- Try It Out's EQ sliders shrank to fit beside their toggle instead of
ever wrapping below it, overflowing their own squeezed container.
Forced onto their own full-width line.
Verified with a Playwright sweep of all 16 sections (0 overflowing
elements, 0 console errors) plus the existing 225-test suite and the
frontend syntax gate.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resumes an in-progress, uncommitted change from a previous session: the
live "page image + recognised text" preview shown during ingestion was
reworked to render formatted HTML (styled headings, a figure
placeholder) instead of a plain-text dump, but was left with a
duplication bug, dead code, inline styles, and no tests.
- Fixed duplication: a figure block's own transcribed text (usually its
on-screen UI copy) was rendered as an ordinary paragraph in addition
to the new figure placeholder box. Figure/chart/table/code blocks are
now represented only by the placeholder.
- Furniture/sidebar/pullquote/ad text is shown muted instead of being
dropped with no replacement, and is never promoted to a heading
regardless of font size.
- Factored the formatting logic out of the progress closure into a
standalone, unit-tested _render_ingest_preview_html().
- Replaced dead getattr(bbox, "y0", ...) calls (bbox is always a plain
[x, y, w, h] list) with direct indexing, moved statistics/logging
imports to module level, switched manual character replacement to
html.escape(), and bounded the preview by an accumulated line budget
instead of a blind slice that could truncate mid-tag.
- Replaced inline style="..." with CSS classes matching the panel's
existing dark console treatment.
- The frontend now always uses innerHTML (every text node is escaped
server-side), dropping the startsWith('<') content-type sniffing.
- Marked the preview panel aria-hidden -- decorative, and would
otherwise churn noisily for screen reader users on top of the
adjacent progress bar.
Verified against the real c't KI Wissen 2026 layout: a figure's raw
OCR text no longer appears twice, a real headline renders as <h3>, a
footer/pull-quote render muted, and all text is correctly escaped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A vision OCR engine grounds a figure far less precisely than it
transcribes one. Over all 138 figure regions of a 204-page issue the
transcribed contents were right while the boxes were routinely offset,
undersized, or sitting on the neighbouring text column -- page 23's card
grid was reported at x 0.24-0.54 where the graphic runs 0.41-0.95.
snap_visual_bboxes() reduces the page render to an ink-coverage grid,
seeds a connected-component search with the engine's box, and re-joins
tiles a gutter apart so a card grid comes back as one figure. Text
blocks are excluded from the mask, which is what stops a figure
swallowing the column beside it. A candidate must be solidly inked or
framed on nearly all of its rows and columns (admitting a white-interior
screenshot), may not shrink below 30% of the seed, and reverts when two
figures land on the same graphic; anything else keeps the box it had.
Runs before the figure crop in both OCR paths, so the images shown
during playback are framed correctly too. Replaces refine_picture_
bounds(), which only recognised coloured panels and had no text fence.
Also adds "Fix figure boundaries" in review and POST
/api/magazine/{id}/figures/refine behind it, so an issue ingested before
this pass can be corrected without re-running OCR. Verified live against
c't KI Wissen 2026: 78 of 138 boxes corrected, the rest declined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ingestion now streams each page's image and recognised text into a
console-styled preview panel (Baidu Unlimited-OCR demo style) as pages
finish, instead of a bare progress bar -- raw text is written to a
per-job ingest_preview.json file since the background-job result field
strips text-like keys for data minimization.
Split-screen review boxes and their matching list rows now show a
centred icon for the region's kind and, when a region on a shared page
belongs to a different article, a badge naming that article, so a
claimed region is never mistaken for unclaimed text.
Added a "Draw it" mode: click-drag a box directly on the page image to
add a region the layout engine missed, as typed/OCRed text or as a new
figure (add_manual_region, POST /api/magazine/{doc_id}/pages/{page}/regions).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Split-screen review: the rendered page (left) with a numbered box per
detected block, the extracted text (right) with matching numbers.
Clicking either side highlights the other. Checkboxes moved from a flat
list into this layout without changing their semantics (still gate
what gets narrated).
"OCR again": reocr_page() forces one page through the configured
vision engine again, replacing only that page's blocks -- deliberately
does NOT call build_articles(), so an already-approved article is not
silently re-grouped. A magazine-level option re-reads every page and
re-runs detection (with a confirmation, since that does reset article
status). extract_pages() gained force_vision_ocr to skip its quality
gate for this case.
Building the numbered-box review immediately did what it was built to
do -- surfaced a real bug on live production data (real magazine, real
scan, not a synthetic fixture): a photo caption at 49.9% of the page
width sat in the gutter between two genuinely separate columns and
merged them into one, so every block on the page came back column=0
and reading order fell back to pure top-to-bottom, interleaving the
two columns' sentences. Fixed two compounding causes: column boundary
detection now excludes blocks much wider than the page's median width,
and detect_columns' gutter threshold (3.5%) was itself narrower than
some real magazine gutters (~2.1% measured) -- lowered to 1.5%.
10 new/updated tests, including one built directly from the real
measured page-17 block coordinates that exposed the bug.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Magazine "forgets" the open document (real bug, confirmed live):
- A full page reload or the browser's own Back button tears down all
in-memory JS state; magInit() always defaulted to the empty library
view with no way back to what was open. Persists the last-opened
magazine id in localStorage and auto-reopens it on load, falling back
to the library only if that document no longer exists.
- The actual root cause of the reported duplicate: readerRenderLibrary()
(Studio/Reader "Source") and libraryRenderBooks() (Library > Books)
both listed EVERY document from the shared reader_library store with
no kind filter, so a magazine (kind: "magazine") was already showing
up there disguised as a plain book. The magazine never actually
vanished -- it was misfiled in a different list, which is what led to
the user re-uploading it and getting a real duplicate under a
different kind. Both listings now exclude kind: "magazine".
Confidence badge layout (Studio Characters review):
- The badge (radiobox is already positioned after the speaker name in
DOM order) rendered on its own line far below the name because both
.ab-cv-spk and .ab-cv-txt were `display: block`, forcing a line break
regardless of markup order. Wrapped the name + badge + check button in
a shared flex row (.ab-cv-spk-row) so the badge now sits directly
beside the name/after "NARRATOR" as requested, without needing to
touch the two existing incremental-patch code paths (they insert
relative to the same elements, now just nested one level deeper).
docker-socket-proxy crash loop (found while restarting for the magazine
Python changes to take effect -- unrelated to this session's earlier
work, landed in an earlier commit during the interruption):
- read_only: true left the entrypoint with nowhere to write its own
haproxy.cfg ("Read-only file system"), crash-looping the container and
leaving the app without Docker access. Removed read_only for this one
service -- cap_drop: ALL, no-new-privileges, and the :ro socket mount
already provide the real hardening.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real bugs, both confirmed live:
1. dragover's preventDefault() was gated on dataTransfer.types including
'Files' -- during a real OS file drag, Chromium does not reliably
populate that list synchronously on every dragover event, so the
condition failed often enough that preventDefault was silently
skipped. Per the HTML5 DnD spec, an element only becomes a valid drop
target once dragover (and, in some browsers, dragenter) has been
cancelled -- without that, the browser falls through to its native
"open the dropped file" behaviour, which is exactly what was
reported. Removed the gate entirely; dragenter is now also handled,
not just dragover.
2. No page-wide safety net existed, so a drop landing even slightly
outside the dropzone element bubbled to the browser's default action
uncontested. Added a window-level dragover/drop preventDefault
installed once regardless of which section is active.
This also explains the "no hover highlight" symptom: the same
gated-preventDefault bug meant dragover never reliably reached the
class-toggle code either.
Verified live: dragenter/dragover/drop each report defaultPrevented,
the highlight class appears on dragenter and clears on drop, the
dropped file populates the hidden input, and a drop dispatched directly
on <body> (simulating one that misses the zone) is also intercepted by
the new window-level guard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the plain <input type="file"> with the same reader-dropzone
component the Book/Source screen already uses (reused CSS, no new
styles needed beyond grid/layout adjustments) -- drag a PDF onto the
zone or click "Choose file", both paths route through the same hidden
input magCreate() already reads via a DataTransfer handoff, so no other
logic needed to change. Shows the chosen/dropped filename in place of
the default hint text, and resets it after a successful add.
Verified live: click-to-choose and a simulated real 'drop' DragEvent
both populate the file input correctly, dragover highlighting toggles,
no console errors from the app itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Magazine P1:
- Images shown automatically during playback (assign_images_to_articles
links each figure to the article whose column it falls in; the client
shows the last image reached in reading order as playback advances).
- "Play all articles" queues every approved article and advances
automatically at the end of each rather than stopping.
- Word-level highlighting, explicitly documented as an ESTIMATE (no
forced-alignment/timestamp source exists): each word's share of the
sentence's real measured audio duration is proportional to its
character length. A checkbox turns it off; sentence highlighting
(measured, not estimated) is unaffected.
- Driving mode: full-screen overlay, two oversized controls, reuses
whatever is already playing/queued.
- Sidebars/pull-out boxes excluded from narration automatically
(mark_sidebars: an anomalously narrow column vs. the page's regular
grid). Fixed a real defect found while building this: a full-width
headline was left in the column-width analysis and could bridge two
genuinely separate columns into one, hiding a real sidebar column
entirely -- caught by a failing test before the fix.
Second OCR engine + fallback ladder:
- KDL Frontier Parser nano wired in via vision_ocr_page_chain (ordered
engine list, first usable result wins, tesseract last resort).
Confirmed live: ~3x faster than Baidu (~12s vs ~40s/page) but returns
plain text with NO coordinate markers, unlike Baidu's labeled regions.
_whole_page_region treats the whole response as one ambiguous
full-page block (gated by ocr_quality_score) rather than discarding
usable text just because it lacks layout structure.
OCR engines page + a real UI bug:
- The OCR & Document Parsing page had no .s-engines-page[data-page="ocr"]
markup at all -- renderLocalContainers already supported an OCR grid
generically, but $('dc-grid-ocr') was always null, so the card grid
silently never rendered. Added an Active OCR Engine panel mirroring
Active Language Model exactly, plus the Local grid -- both configured
engines now appear as ready-made cards, no "Add Custom" dialog needed.
- Fixed the audiobook pipeline's "Previous/Next" nav bar bleeding into
every .section-head in the document (confirmed live on the Engines
page: a "Cast Audiobook" button and "STEP 1/8" hint that made no sense
there). Changed from a blocklist (naming each unrelated section as
noticed) to an allowlist of the pipeline's three actual destinations.
Verified live end-to-end: play-all queue building and manual advance,
word-span markup, driving-mode toggle, OCR page showing both engines as
local cards, zero stray nav bars on the Engines page, no console errors.
20 new backend tests (156 total in the magazine module).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Studio -> Magazines: library, contents tree grouped by section with
confidence/pages/listening-time/audio status, the Stage D review
screen (tick/untick layout blocks, correct title/author, approve or
reject), a reflowed article view with click-to-seek sentence
highlighting, a linked original-page view showing the source bounding
box, and a podcast panel (2-3 host pickers, script generation,
turn-by-turn playback).
Wired into the existing lazy-module system (module-manifest.json,
loader.js, nav.js) rather than a separate app. Reuses /api/tts-preview
for both narration and podcast turns -- no new audio pipeline.
Fixes found building this:
- The pane toggle used role="tab" without the full ARIA tabs pattern
(aria-selected, roving tabindex, arrow-key nav) -- fails WCAG 2.2 AA
and hid the buttons from role-based tooling. Replaced with a button
group + aria-pressed, matching the plain-click interaction it
actually has.
- Print layout cues ("(continued on page 8)") were being sent to TTS
verbatim -- narration would have spoken the parenthetical aloud.
Stripped from the narration copy only; layout.json keeps the
original for the review screen.
- has_podcast now comes from the articles listing so opening an
article without a generated script skips a doomed GET instead of a
routine 404 on every open.
Verified against the real API contract with Playwright (library,
review pre-selection, approval, sentence highlighting + PDF-page
linking, podcast approval gate, zero console errors across the flow)
and manually end-to-end against a real ingested fixture, screenshots
included. Full suite: 94 Python tests, 33/34 desktop Playwright tests
(one unrelated pre-existing flaky DOM-count assertion).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Deterministic hash-based character portraits and reference sheets
- Implemented strict early Renaissance aesthetic controls and role-specific headwear rules
- Added audiobook batch export scripts and progress tooling
- Verified book profiles with a new 'confirmed' state tracking mechanism
- Updated Pillow dependency for robust image handling
- Configured .gitignore for audiobook and audio cache exports
The caster emits fragments of noun phrases as characters — "in seinem Inneren"
-> Inneren, "die Hand" -> Hand, "mein Geliebter" -> Geliebten, "der Wind" ->
Wind. Each became a cast entry with its own voice while the real speaker's line
was lost to a phantom; measured against a hand-corrected book this was a large
part of why a fresh cast produced 73 speakers where the human had 62.
Such lines are now reset to Unknown — visibly unresolved and still recoverable
by the repair passes, rather than silently wrong. Applied to a real cast:
5 phantoms removed, 73 -> 68 speakers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consolidation ran only at cast completion, but recast/repair passes assign
speakers of their own and can re-introduce the variants just removed —
confirmed live: a freshly cast book gained "Alrik" alongside "Alrik von
Blautann" during auto-repair, splitting one character across two voices again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One character is referred to by several labels across a book — bare vs full
name, a title on its own, a stray fragment — and each variant became its own
cast entry with its own voice, so the character audibly changed voice
mid-scene. Measured against a hand-corrected book this accounted for more of
the disagreements than genuine misattributions.
Variants are folded together only when one label's words are a strict subset
of another's after stripping articles and titles, so "Weber" and "Alter Weber"
stay separate and unrelated names never merge. Applied to a real cast:
Sharraz -> Sharraz Garthai, Baronin -> Baronin Ira von Seewiesen,
Von -> Oberst Alrik von Blautann (40 lines, 58 -> 54 speakers).
Also moves the quotation-mark toggle into the A- / A+ toolbar row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CSS cannot target a character inside a text node, so the marks are now wrapped
in their own .ab-q element at render time and hidden with a single class on
body. Toggling is instant regardless of segment count, and the stored text is
never modified.
Also fixes the toggle looking inert on a freshly cast book: it previously only
added missing marks and never hid the ones the book already carried. Button
moved bottom-right as a larger labelled pill, clear of the search controls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
German prose never quotes narration, so displaying the guillemets makes a
mislabeled line obvious at a glance. Extraction strips them from ~80% of
segments, so they are rendered rather than stored: dialogue rows display
wrapped in » «, lines that kept their own marks are left as-is, and the saved
text is untouched so synthesis and exports are unaffected. A toolbar button
toggles visibility and the choice persists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adjacent segments merge when they share a speaker, but an unattributed line
carries the speaker "Unknown", so two consecutive Unknown lines compared as
equal and were fused into a single block joined by a blank line. Confirmed
against the source text: Lysandra's line and Perdia's interruption — two
separate paragraphs in the book — became one segment, after which no
attribution pass could separate them and one voice would read both sides of
the exchange.
"Unknown" means the speaker is not known, never that two lines share one.
Narration and lines with a real speaker name merge exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A trailing inquit names only the last speaker, so applying it to a block
holding multiple turns silently mis-credited the earlier one — a wrong
attribution is invisible where an Unknown is not. Such blocks are now skipped
by the grammar rules and left for the LLM pass or a manual split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Darrags Stimme klang tonlos." after a quote names who just spoke, but the
voice-announcement rule only looked at the narration before a line, so this
mirror image stayed Unknown. Verified on the book (-> Darrag).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Driven by unresolved lines in a real German novel:
- AB_SPEECH_VERBS was missing many common inquit verbs (grunzte, versetzte,
unterbrach, drängte, herrschte, keuchte, erklärte, widersprach, warf ein …)
and had no thought verbs at all, so inner monologue ("dachte Lysandra") was
never attributed.
- A trailing inquit naming two people ("unterbrach die blonde Perdia Lysandra")
resolved to the wrong one; the grammatical subject speaks, so articles and
adjectives are skipped and the first name after the verb wins.
- A quote whose own inquit had been mistyped as dialogue could never resolve,
because the rule required the following segment to be narration. A segment
opening with a lower-case speech verb is an inquit whatever its type; it is
now accepted and its type corrected.
Verified on the book: resolves further lines with no LLM call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening a book always restored the localStorage draft and then pushed it to the
server as a backfill, so a tab holding an older copy silently overwrote newer
work saved from anywhere else — confirmed live: a cast improved from 79 Unknown
lines to 25 was destroyed by opening the book in a stale tab. Local and server
drafts are now compared by savedAt, the newest wins and is cached locally, and
the server is only backfilled when the local copy is actually newer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps found by validating the repair chain against a real German novel:
Split quotes whose inquit uses a pronoun rather than a name (»Ja, ungewöhnlich«
+ ", antwortete er knapp.") stayed Unknown, because every existing rule
requires a literal name after the speech verb. The antecedent is unambiguous,
so it now resolves the same way the colon rule does — to the last character
named in the narration immediately before the quote. Verified on the book: 2
further lines resolved with no LLM call, including the reported case.
The repairNarration pass also only received the verification prompt when
launched from the menu button; called from auto-repair or programmatically it
fell back to the general casting prompt, which is not written for the "is this
narration actually speech?" question. It now defaults to the verification
prompt in every entry path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Identify/Cast flyouts were hard-anchored to open above their trigger,
which suited the casting panel's bottom footer but clipped them off the top of
the screen in Studio, where the same toolbar sits near the top — the first two
entries were unreachable. They now pick the side with space, prefer opening
downwards, stay clamped inside the viewport and scroll if taller than it.
Also reorders the Identify menu to the sequence the passes should run in, with
the one-click Auto-repair cast first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spoken lines that PDF extraction typed as narration were invisible to
"Identify unknown characters" (it only scans type==='dialogue'), so no number
of re-runs could fix them, while the pass that could re-sent all ~1000
narration segments to the LLM. Adds a detector that scores narration for
direct-speech signals and re-checks only those: 36 candidates instead of 1036
on a real novel. Adds "Auto-repair cast" to chain the passes in the right
order automatically after casting.
Also fixes a rollback gap where stopping a run skipped the safety net that
undoes a pass which increased Unknown speakers (confirmed live: 80 -> 159
with no warning), corrects an attribution rule that taught the model the
listener is the speaker, and stops the threshold loop from re-verifying every
narration segment each round.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fish-Speech clones from the app's own saved WAV library but was missing from
the set of backends populated with those voices, so the UI probed the Fish
server for a voice-listing endpoint it does not have, showed "Fetched 0
voices" and left the engine unusable despite being healthy. It now reports
the full library (218 voices).
Also adds a Fish-only panel listing all 49 documented emotion tags with
click-to-insert and a multi-emotion example, and corrects the backend's
advertised capabilities: it was flagged style_aware with "emotion markers are
honoured per request", but measurement shows a reference clip's in-context
prosody overwhelms inline tags (10x loudness spread across emotions without a
reference, 1.2x with one). It is now described by what it does well:
deterministic, byte-reproducible cloning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The emotion quick-pickers added in 1.20.5 guarded with
`typeof REH_EMOTIONS === 'undefined'`, but REH_EMOTIONS is a const declared
later in the bundle's single shared scope — `typeof` on a const in its
temporal dead zone throws instead of returning "undefined", which aborted
top-level initialization for every module bundled after tts-preview.js.
The pickers now read window.REH_EMOTIONS on a deferred macrotask.
Also: emotion instructions are now always built in English (spoken text and
the native-accent clause stay in the book's language), which controlled A/B
testing showed produces a far cleaner prosodic gradient from Qwen3-TTS; and
Fish-Speech now receives temperature/top_p/repetition_penalty, which it was
the only backend never to have forwarded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fish-Speech emotion tags were silently ignored on non-English books: per-line
emotions are LLM-generated in the book's own language, but Fish-Speech only
recognizes English [tag] markers, and a double-tagging bug was stacking a
broken server-derived tag on top of the client's own. Added a DE->EN
translation table and removed the double-tagging. Also wires the existing
book-profile context and race_species field into character portrait prompts
(previously only used for voice design), adds a recast-until-threshold loop
for casting, and adds backend-aware emotion quick-picks to Read Aloud, Try a
Voice, and Conversation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
For a designed voice the instruct prompt is the voice's identity — the TTS
engine reproduces the voice from that text alone. The only copy saved was the
`note` display summary, clipped to 240 characters, which left 43 of 73 voices
cut off mid-sentence. Save the complete prompt in its own field so the engine
can register a voice from the whole description.
Existing voices keep working from the clipped copy (it still carries gender,
accent and timbre) and pick up the full text when next redesigned.
Pairs with the engine-side fix in tts-dgx-spark-faster-qwen3-tts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voice consistency:
- Read back each voice's pinned seed (Seed Finder / Batch Seeds) on every
generation. The seed was saved to voice metadata but only ever read by the
Seed Finder's own benchmark path, so all per-voice seed pinning was inert.
- Stop coercing the "voice_design_playback" stability profile back to
"voice_clone". The pseudo-backend key isn't a real routing target, so the
backend-name normalizer silently rewrote it — reintroducing the hardcoded
seed:0 that profile exists to avoid, overriding every per-voice pin.
- Apply the accent clause on every line, not just at voice-creation time,
and reorder the instruct so emotion leads and accent trails (Qwen3-TTS
doesn't reliably follow multiple conflicting instructions).
- Pass an explicit language to Voice Design instead of leaving it on "Auto".
Audio effects:
- Add a limiter after compressor makeup gain. Makeup gain pushed peaks to
~1.9, and the final hard clip turned that into broadband distortion that
swamped the rest of the chain.
- Cascade highpass/lowpass 3 stages each (~18 dB/octave). Single-pole
filters were too gentle to band-limit speech audibly.
- Add a Bandpass control and wire it into the Telephone/Radio presets —
compression alone never sounded like a phone; band-limiting is the
defining trait.
Persona / Try It Out:
- Disable "Apply character persona" with an explanatory tooltip when the
voice has no persona saved, and error clearly server-side instead of
silently no-op'ing. Persona is typed manually per voice, never auto-filled.
- Stop dropping applyPersona in the chunked generation path (>200 chars).
- Populate the Voice Design dropdown from the user's own library rather than
filtering the engine's discovery list, which never contains custom voices.
Navigation and library:
- Use pushState instead of replaceState so browser Back/Forward step through
in-app navigation instead of leaving the app entirely.
- Show real dialogue line counts in the character sidebar instead of the
capped reference-quote count (which showed a misleading uniform "12").
Also fixes a crash in /api/transcribe-bytes that referenced an undefined
source_id in its cleanup path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A fresh (non-resume) cast unconditionally nulled _audiobook.rehId even
when retrying or recasting a book that already had a linked Rehearsal
Library record from a prior attempt — silently orphaning it and
creating a new one on the next save. Now only nulled for a genuinely
first-ever cast; retries/recasts overwrite the existing record.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same pattern as the Casting audiobook Prompt button: a collapsible
textarea with the full extraction prompt (fields + sources/citation
schema), named-preset save/load/delete, and live persistence so edits
apply from the next passage onward. Server-side, _charsheets_prepare()
now accepts a character_sheets_prompt override (shared by the blocking
and streaming endpoints), following the same pattern already used for
the audiobook attribution prompt override.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Was a small 420px dialog with a collapsible one-line JSON preview and a
cramped 2-column chip grid. Now a big panel matching the audiobook
casting live view's shape: passage text (left) and live JSON output
(right) side by side, with a character sidebar on the right edge whose
just-updated entries are highlighted — so the user can watch each sheet
actually being built from its source passage instead of a tiny opaque
progress bar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two gaps observed in real casting output:
1. A mid-quote " - " (em-dash pause) was being read as the end of the
quotation, handing the rest of the line to a wrong/new speaker.
2. Narration naming whose voice is about to speak (e.g. "Marcians
Stimme wirkte nicht mehr so fest") wasn't being used to resolve the
following unattributed line away from 'Unknown'.
Added as rules 11/12 to the client-side AB_DEFAULT_PROMPT (the prompt
actually sent to the LLM) and to normalizeCastingPrompt()'s migration
so already-saved custom prompts pick them up automatically, same
mechanism as the earlier Doppelpunkt-Regel upgrade. Also added
equivalent rules to the server-side fallback prompt in
routes/conversation.py for completeness, though the client-sent prompt
is what's actually used in normal operation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A document-level "click outside closes the popup" handler only exempted
clicks on the paragraph's speaker label, not clicks anywhere else in the
passage text. Since mousedown fires before click, this closed the popup
(clearing assignModeRow) before the v1.14.13 name-click logic ever ran,
making "click a name elsewhere to feed the open popup" silently do
nothing. Passage-text clicks/drags no longer auto-close the popup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The intended flow is: click the broken paragraph's speaker label to open
"Assign to", then supply the name (type, click it anywhere in the text,
or drag-select it). But clicking or drag-selecting a name with no popup
open yet used to open/target a popup for whichever paragraph that name
lived in, silently reassigning THAT paragraph instead of the one the
user meant to fix. Both paths now require an already-open popup before
they do anything — a paragraph is only ever selected via its own
speaker label (or the existing double-click fast-assign shortcut).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Characters Found sidebar, the "Assign to" popup's character list,
and the "merge with an already-found character" list now show each
character's saved portrait when one exists (same source as the profile
detail panel), falling back to the existing colour-keyed initial-letter
dot otherwise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clicking a name inside a paragraph's text always retargeted the "Assign
to" popup (and its highlight) to that paragraph, even when the popup was
already open for a different one. That made it look like the wrong
paragraph was being reassigned when the user just wanted to reuse a name
they saw elsewhere. Now, while a popup is open, clicking a name outside
its target paragraph only fills the search box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Passage/Thinking boxes in the audiobook casting live view can now be
resized vertically. Also fixed the character-list collapse button's
chevron pointing opposite to the direction the panel edge actually moves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed by directly probing a streaming response from vllm-0.23.1rc1:
its delta objects carry reasoning text under "reasoning", not the more
common "reasoning_content" key every reasoning-display code path was
checking for. Added a shared _reasoning_text() helper that checks both,
used by the Conversation Playground, audiobook casting's live view, and
character sheets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Was 6min/3min. 122B+ models can take longer than that just to produce
a first token; 10min also happens to match the server-side cap
(_request_timeout_seconds), so this is the max without raising that too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "LLM Reading…" split view showed the same passage text three times
(reader pane, Passage column, and a full-width block toggled by the
chevron that duplicated the Passage column). Removed the redundant
full-width block, repurposed the chevron to show/hide the two-column
view itself, and swapped the columns to Passage (left) / Thinking (right).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reasoning-model chain-of-thought is now separated from the spoken/displayed
reply on the server (handles both a dedicated reasoning_content field and
inline <think> blocks, including chat templates that inject the opening tag
as a prompt prefix so it never appears in the stream). The client shows it
in a panel collapsed behind a "Thinking" chevron instead of dumping raw
reasoning text into the chat or speaking it aloud via TTS.
Also moved the noise-gate slider out of its own row and next to the mic
button, grouping it with Auto-stop/Hands-free/Live agent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two unclosed <div class="card"> tags in the TTS settings markup left the
add-engine dialog nested inside the TTS panel's DOM subtree, so it
rendered at zero size whenever another Engines sub-tab (e.g. Speech
Recognition) was active. Also fixed a `let` declared after its first
use, which threw a ReferenceError on every Engines-tab page load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Streaming playback never produces an in-memory blob, but the enable
logic required one. Now enables unconditionally and lazily
re-synthesizes a buffered blob on demand if effects are applied before
one exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>