Replaces the processed reference (declip + dynaudnorm + loudnorm), which
audibly degraded the voice. The original recording is untouched except for
a smooth -8 dB dip over its hot first two seconds (0.35-2.35 s); every other
sample is bit-identical. Preview clip regenerated at 64 kbps. Backend v24.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
Level the finished take: even section loudness before it is served
Chunks render independently and the model's delivery level drifts between
them, so one global normalization gain still left louder and quieter
stretches. When the full take lands, ride a slow gain envelope over it:
3 s windows every second, gated to speech, each nudged toward the take's
median loudness (capped at ±8 dB), smoothed, then soft-limited. Overall
loudness is unchanged; Studio mode still sets the delivery target. Applied
once at storage time, so playback in the result stage and every export
inherit it. Fails open: any error serves the take as generated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
Josh preset: declipped and level-normalized reference
The exported clip opened ~10 dB hotter than the rest with clipped peaks,
which carried into renders. Reference is now adeclip + dynaudnorm +
loudnorm (-23 LUFS, -2 dBTP peak), preview clip regenerated. Backend
redeployed (v23) with the new WAV.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
APP_VERSION/APP_VERSION_DATE live in app.py, ride along on /api/status, and
the header renders them under the wordmark with the release date as a tooltip.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
/api/last-take used to return the newest take from anyone, so any visitor
could recover someone else's render. Takes are now remembered per owner: a
random id the client keeps in localStorage and sends as X-Chorus-Visitor,
falling back to the client IP. Each owner's latest take is still protected
from the 15-minute audio expiry; owner entries age out after 6 h.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
Add Josh, the author's narration voice, as the default first preset
Reference WAV lives in backend_modal/voices (gitignored, shipped to Modal
v22); the preview clip is public/voices/Josh.m4a. Listed first in
VOICE_INFO so it is Speaker 1 by default in the cast and API fallbacks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
Consent block and Generate pill share one sticky dock
Generate is disabled until the visitor ticks a responsible-use statement that
covers every voice — presets, clones, their own: no impersonation without
consent, no deception/harassment/defamation, no sexual/hateful/illegal
content, no passing audio off as genuine, rights to uploaded voices, and
synthetic disclosure. Acceptance lasts for the tab session only.
The server refuses /api/generate without use_consent and records each
acceptance as a JSON line (time, IP, user agent, voices, word count, script
SHA-256 — never the script) to data/consent_log.jsonl and to stdout as
"CONSENT {...}" so the Space logs carry it too.
The old clone-only sidebar checkbox is folded into this one; the per-clone
confirmation in the clone dialog stays. Clone cards also get a download
action so a browser-only voice can be exported (e.g. to become a preset).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
Cut Modal cold start from ~3 min to ~30 s: bake weights, memory snapshot
Every cold container was re-downloading both VibeVoice checkpoints (~17 GB)
from the Hub inside container start. The image now bakes the checkpoints plus
the Qwen2.5-1.5B/7B tokenizer files the processor loads, and the class runs
with enable_memory_snapshot: load_models (snap=True) fills CPU RAM once per
deploy, move_to_gpu (snap=False) copies to the A100 on every boot.
Measured on the deployed app (v21, 2026-09-12): 29 s from request to a ready
GPU, versus 110-180 s before. Standing cost unchanged (tail profile).
Also: boot_check local entrypoint (--deployed pokes the live app), UI/README
cold-start copy updated to ~30 s, VRAM baseline comment corrected to the
measured 24.1 GB. Not HF_HUB_OFFLINE: v20 crash-looped when the un-baked
tokenizer repo could not be fetched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75
Launch guardrails, honest live-preview states, drop the dead script toggle
Cost caps for the public demo: scripts up to 7,000 words (duration menu
tops out at 45 min to match), plus a global budget of 60 generations per
rolling day on top of the per-IP limit, with a friendly quota message.
/api/status reports the remaining daily budget and the word cap.
The generation stage's preview row used to appear only once audio was
already scheduled and always said "playing", even when muted. It now shows
from the start with a note explaining that audio streams in once the
first stretch of dialogue renders and passes the gate, then switches to
playing/muted with the chunk count. The preview AudioContext is created
inside the Generate click so browsers don't start it suspended.
"View full script" on the player stage is removed along with its hidden
transcript pane; the dock transcript is the one that works.
The Modal runner (tracked) gains deploy-time scaling profiles
(VIBEVOICE_PROFILE=launch|tail) and cold/warm markers in container logs.
Co-Authored-By: Claude Fab…
The backend now deploys under a scaling profile (VIBEVOICE_PROFILE=launch
keeps one A100 hot plus a buffer container; tail scales to zero; both cap
at 4 containers). This side of the split:
- /api/status reports live Modal runner counts (cached 10 s) so the UI
can tell a cold GPU from a warm one. The generate bar shows "First
generation after idle can take ~3 minutes" only while no container is
hot, or when the count is unavailable.
- Generation concurrency raised from 2 to 4 to match max_containers.
- README rewritten for the current app: 16 voices, voice cloning,
streaming render, sound modes, the chunked pipeline and its measured
records, scaling profiles, and rate limits. The Gradio-era screenshots
are removed since they no longer match the UI.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MARbUWrg2FB73kxtqenL2x
Second-generation takes could open with the uploaded voice sample spoken
word for word — prompt leakage: the reference's acoustic tokens sit in
the same prompt as the script, and a clip cut mid-utterance invites the
model to "finish" it before reading the script. Three counters:
- _trim_reference now snaps both window edges to the quietest moment
within 8 s, so a trimmed clip starts at an utterance onset and ends on
a real pause (plus ~150 ms of it, protecting the final word's decay).
- Uploaded clones cap at 30 s instead of 60: half the leakage material,
and ~225 fewer prompt tokens per speaker in every batch row. Presets
keep their curated 60 s and are byte-identical to before.
- The quality gate gains a replay detector: a chunk whose duration
overshoots words/2.0 wps by more than 10 s of pause slack is flagged
and rerolled — replayed speech is healthy speech, so the existing
wps/silence/flatness checks never fired on large chunks (200 words +
a 30 s replay still mea…
Add Clean-noise export option and denoise uploaded clone references
Post-processing: a "Clean noise" checkbox in the Sound panel bakes STFT
spectral gating into downloads (WAV/MP3) — noise print from the per-bin
10th-percentile magnitude, attenuation capped at -14 dB, gains smoothed
over time and frequency, plus a 70 Hz rumble cut. Conservative by design:
clean takes pass through with a residual ~70 dB below signal (verified),
speech is untouched (corr 0.9995, -0.12 dB), and the hiss/static bed
drops ~11 dB. No live Web Audio equivalent exists, so the hint says the
cleanup applies to downloads only.
At the source: uploaded clone references now get the same mild denoise
before RMS normalization, since VibeVoice imprints the reference's noise
bed onto every generated word. Presets are left as-is (curated and
ear-validated), so cached renders are unaffected; the step fails open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B4yyaAf3qnp28r9Lk1MN59
Stop voice previews on library close; cap reference clips at 60s
The library dialog's close event (X, backdrop, Esc, and the programmatic
close after choosing a voice) now silences a playing preview.
Reference audio costs 7.5 prompt tokens per second in every batch row,
so a minutes-long custom clone inflates VRAM and prefill for the whole
render. Clips are now trimmed to their best 60s speech window at load,
matching the preset format.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Fix streamed-chunk voice mapping, stall margin, and progress jitter
- Voice bug: the processor pairs voices with speakers positionally and
only declares voice_samples[:n_speakers_in_chunk], so any chunk whose
speaker set isn't {1..k} got wrong or undeclared voices — a mini
containing only Speaker 2 rendered in Speaker 1's voice (and 3-4
speaker scripts had the same latent bug on subset chunks). Each chunk
now remaps its speaker ids to a dense 1..k and passes exactly its own
voices in matching order.
- Stall at ~57%: the fast-start region only ever covered chunk 0
(~80 s of audio) against a ~2 min first full wave. It now consumes
the opening ~350 words across chunk boundaries (always leaving at
least one full-size chunk), buying ~140-160 s of bridge audio.
- Progress jitter: the bar is now monotonic (peak-hold across the
estimate-to-chunk-truth switch) and creeps between wave deliveries on
the calibrated wave estimate instead of freezing at e.g. 57%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://cl…
Stream audio while it renders: fast-start wave and live preview
Delivery change only — chunking rules, context handling, references,
crossfade, batching, and the quality gate are untouched; streamed audio
is emitted only after each wave's gate verdict, so the preview plays
exactly what the final take will contain.
- Runner: the opening ~250 words split into turn-aligned mini-chunks
rendered as their own first batched wave (wall time of one mini, ~100s
of audio almost immediately), then each wave's chunks are yielded as
audio events with index/total. Short scripts keep the single-pass path.
- Server: streamed chunks stored per generation and served at
/api/stream/{id}/{index}.wav; SSE events carry only the URL.
- Client: Web Audio preview scheduled with the same 0.25s linear seam
crossfade, Studio-consistent loudness, mute control, stall-tolerant
butt-join, and chunk-count-driven determinate progress. On completion
playback hands off to the real player at the same position.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claud…
Solo readings with verified public-domain licensing on archive.org:
Ishmael (Moby Dick), Traveller (The Time Machine), Dublin (David
Copperfield), Badger (The Wind in the Willows), Meadow (The Secret
Garden), Abbey (Faces and Places), Avonlea (Anne of Green Gables),
Sunshine (Pollyanna). Same 60 s 24 kHz mono reference prep as the
last batch; library is now 16 voices, 8F/8M.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Put the public-domain voices first and make them the speaker defaults
Cylinder — the pre-1923 Edison Gold Moulded cylinder recording (UCSB
cylinder archive aesthetic); Statesman — the 1920 Nation's Forum
'Americanism' address; Novella and Eyre — LibriVox public-domain
readings (Pride and Prejudice, Jane Eyre). Each prepared as a 60 s
24 kHz mono reference picked for maximum speech activity, with AAC
previews (no MP3 encoder in the prep pipeline; the voices endpoint now
serves whichever preview format exists). Novella replaces Mantis in
the default speaker slots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Measure every turn's start from the rendered audio itself
Fast one-phrase exchanges exposed the limits of word-count estimation:
short turns make the per-turn error proportionally huge, and the
browser's coarse waveform peaks can't see the brief dips between rapid
speaker switches. The runner now segments each rendered chunk's 20 ms
energy envelope, matching turn boundaries to real inter-turn gaps with
a small DP (word-count priors, gap-length bonuses, ordering enforced),
and ships per-turn start times with the take. The frontend applies
them directly — no estimating, no snapping. 20 ms worst-case error on
synthetic fast dialogue, uneven speech rates, and paragraph turns;
fail-open everywhere (old takes fall back to the chunk-anchor path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Report chunk start times and turn counts with the finished take
The frontend anchors caption timing to these instead of estimating the
whole timeline from word counts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Anchor captions to real chunk timings; simplify polish to Sound modes
- The Modal runner now reports where each rendered chunk starts and how
many turns it covers; captions retime piecewise between those hard
anchors so timing error can never accumulate past one chunk, with the
silence-snap refining interior boundaries only. Falls back to the old
estimate when a take has no timing map.
- Active-turn scrolling now moves only the transcript's own scrollbox;
scrollIntoView was yanking the whole dialog on every turn change.
- Speaker orbs: modest swell that can never cover the name labels
(energy shows in a soft translucent halo instead), richer sphere
shading, labels layered above neighboring glows.
- Polish panel replaced by a single Sound choice: Original, Studio,
Warm, Bright. Studio is the default — normalized to -16 LUFS; Warm
and Bright add a tone shelf on top. Speed and lift are gone.
Downloads always match what you hear.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rouzi…
Add loudness normalization as an independent polish option
A 'Normalize loudness to podcast standard (-16 LUFS)' switch that
changes level only — up or down to target, gated so pauses don't skew
the measurement, peaks caught by the existing soft-knee limiter — and
composes with (or without) any other polish. Live in playback via the
Web Audio gain using the loudness the peaks endpoint now reports, and
baked into WAV/MP3 exports server-side. The blind 1.6x lift is
disabled while normalize is on, since normalize sets level outright.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Sync captions to real pauses, add speaker orbs, clarify polish
- Captions ran ahead of the audio: word-count apportioning ignores the
pauses speech actually has. Snap each turn boundary to the nearest
silence-to-speech onset in the RMS peaks envelope, carrying estimate
drift forward, and rescale sentence timings into the corrected span.
- Stage gets one glowing orb per speaker; the talking one swells with
the live signal level via a Web Audio analyser, the rest drift idle.
- Polish panel now says its settings are live during playback and also
rendered into downloads; the toggle reads 'Polish · on' when active.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Show estimated progress for single-wave renders and cold starts
Progress was driven only by completed waves, so a one-wave render (any
short script) swept indeterminately with no percentage or ETA for the
whole run, and cold starts showed a bare sweep for minutes. Estimate
the wave time from script words and a per-model speed factor measured
on past runs (persisted in localStorage), drive the bar off that until
real wave timings exist, ramp warm-up toward the last observed cold
start, and let the Warming up headline breathe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Don't let the turn cap truncate scripts below the word target
The per-length turns budget assumed 55-word paragraph turns, so when
the model wrote short rapid-fire turns the cap fired long before the
word target (2 min requests came back ~28s). Keep only the MAX_TURNS
safety valve; the word-based overshoot trim already bounds length.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Hit the requested script length with continuation rounds
A single LLM call chronically undershoots large word targets (10 min
requests came back ~2 min of dialogue). Generate in rounds instead:
count the words actually produced, then ask the model to continue the
same conversation until the script reaches 90% of the target, the model
runs dry, or a length-scaled round budget runs out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RouzihqN7d1LSubZ3dE6ym
Say when a take has been cleared instead of failing the download
Takes live in memory, so a Space redeploy or restart drops them and the
download links 404 — surfacing as the browser's opaque 'File wasn't
available on site'. Download clicks now probe the stored take with a
one-byte Range request first and, if it is gone, explain what happened
and why in the app rather than handing the user a dead link. Playback
load failures report the same thing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make polish real post-processing; fix distortion, underline and dead MP3 click
Audit of the reported problems, all confirmed and fixed:
Polish was preview-only, which is not what post-processing means. Speed,
tone and level are now rendered into the exported file by a new
/api/audio/{id}/export endpoint — WSOLA time-stretch (pitch preserved,
duration accurate to 0.2%), RBJ shelving EQ (measured within 0.1dB of
target) and a boost-only leveller, block-processed so a 5.5-hour take
costs ~1.7 minutes and bounded memory. Download links switch to the
rendered export as soon as a setting is changed, and the panel says
which one you will get.
The distortion was real: preview applied +4.6dB of makeup gain after
compression with nothing to catch peaks, so anything above -4.6dBFS
clipped. Replaced with a limiter-terminated graph. The exported
leveller only ever boosts — it previously normalised every take to the
same loudness, making a healthy take quieter.
Stage 'Download WAV' was underlined because .btn-ink lacked
text-decoration:none while .btn-pill-outline had it; m…
Stream takes from the server instead of buffering them in the page
Finishing a render kicked off a fetch that buffered the entire WAV into
a JS blob before anything could play — 889MB on a long take, with the
transfer stalling behind the Space proxy and the UI sitting on a frozen
byte counter. The player now points straight at /api/audio/{id}, which
gained Range support, so playback starts immediately and seeking issues
a Range request instead of pulling the file again. Download buttons are
plain links, handing hundreds of megabytes to the browser's own
download manager. The waveform comes from a new /api/audio/{id}/peaks
endpoint that computes a strided RMS envelope server-side: 13KB of JSON
in 0.02s where the client previously downloaded everything to draw it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix cursor flicker from a resizing modal; retire the bundled examples
The stage was a vertically centred dialog whose content changes every
second, so each height delta moved ALL of its content by half — which
reads as the pointer flashing between hand and arrow as buttons slide
under it. The dialog is now anchored near the top so growth only
extends downward, the log and text rows have reserved heights, and the
progress bar is always present (sweeping during warm-up) instead of
toggling hidden and shifting everything by 22px. Verified: no
cursor-flapping points across a 680x760 grid over a full lifecycle.
Examples: the bundled scripts predate the current app, so they no
longer load; the chip row hides itself and the empty-state copy points
at Write with AI and import instead. Loader and files are left intact
behind a flag for a future curated set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Polish: a panel on the player stage with speed (0.8-1.25x, pitch
preserved so no chipmunk), tone (neutral/warm/bright via shelf filters)
and a leveller for quiet speech. It runs live on the playing element,
so it costs nothing and works on a five-hour take — and it is labelled
as affecting playback only, since the stored WAV is untouched.
Warm-up: GPU cold start was folded into 'elapsed' and into the reported
Generation time, which also included the download — a 59-minute render
reported as 72 minutes. The clock now rebases when synthesis actually
starts, warm-up is reported as its own metric, and generation time is
captured before the transfer. Added a realtime-factor metric; both now
agree with the backend's own log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Counting only completed waves meant the percentage sat at 0% for the
whole of wave 1 — minutes on a long render — then jumped. Now, once a
wave has finished we know roughly how long one takes, so progress
interpolates within the wave in flight and the ETA uses the same
fraction. Before that first data point there is no honest number, so
the bar sweeps indeterminate and reads 'Rendering…' rather than faking
a zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A deploy could leave a browser running a cached app.js against a newer
index.html — which is exactly what happened after the last push. The
index route now stamps every /static reference with a hash of that
file's contents, serves the HTML as no-cache, and marks hashed asset
URLs immutable so they cache for a year and change the instant their
content does. Unhashed URLs deliberately keep normal revalidation so a
bare request can never be poisoned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Put generation progress front and centre, and lock the workspace
The stage now doubles as mission control: starting a render opens it on
a generating pane — big percentage, wave progress bar, 'Wave 8 of 21 ·
~40 min left · 20 min elapsed', current status and the live generation
log — then hands off to the player pane when the take lands. Since the
payload is already submitted, editing during a render can't affect it,
so workspace controls are disabled and dimmed until it finishes or is
stopped, rather than silently no-opping. Also guards showModal() against
being called on an already-open dialog during the handoff.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real generation progress: wave bar, ETA, elapsed, tab title; taller log
A 72-minute render showed only a spinner and a rotating joke — the
backend's real signal ('Rendering wave 3/21…') was being overwritten by
parody lines, and its own pct is a time-based hint that pins at 88%
after 90 seconds. Now the status card carries a progress bar driven by
completed waves, plus 'Wave 8 of 21 · ~40 min left · 20 min elapsed'
with a client-side clock ticking between events, and the tab title
shows percent so a backgrounded long render is readable at a glance.
Parody lines keep their spot as flavour text. The generation log is now
shown by default (it is worth seeing), taller, and auto-scrolls while
it is already at the bottom.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stride the waveform peak scan so hours-long takes stay instant
Visiting every sample meant ~475M iterations for a 5.5-hour take,
locking the UI for seconds. Sample ~1k points per bucket instead —
statistically identical for an RMS envelope — with the stride grid kept
aligned across slice boundaries. Measured: 82MB in 70ms, so a ~950MB
record take lands under a second, with the loudness envelope and silent
gaps still clearly resolved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 5.5-hour take is ~950MB of WAV; as 96kbps mono MP3 it's ~6x smaller
with no audible loss for 24kHz speech. The server encodes lazily on
first request (lameenc in a worker thread, cached per take, pruned with
the audio store) at /api/audio/{id}.mp3; the dock and stage grow a
Download MP3 button pointing there, shown only while the server still
holds the take.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the canvas waveform with wavesurfer.js + streaming RMS peaks
The 48-bucket peak-max visualizer saturated on long takes — at 2 hours
every bucket spans minutes and always contains a loud moment, so the
waveform rendered as one flat bar. Now: peaks come from a streaming RMS
scan over the WAV's raw PCM in 8MB slices (flat memory at any length,
no browser audio decode; decoder fallback for non-WAV), at 4096 buckets
that wavesurfer.js downsamples to fit. Wavesurfer (vendored, MIT) is
skinned to the app palette and driven by the existing audio element, so
play/seek/karaoke sync are unchanged; the stage adds its auto-scaling
timeline ruler. Stage instance is built on open and destroyed on close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw native file input was the last unstyled control — hide it
behind a themed Upload .txt button with a truncating filename readout,
matching the clone dialog's pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stalled transfer previously hung await read() forever with no
feedback (the failure mode that ate a record attempt). Now 60s without
bytes fails the download with a message pointing at Recover last take,
and any generation error re-surfaces the recovery offer if a finished
take survived server-side.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Never orphan a finished take: server keeps it, UI offers recovery
Losing the page (accidental navigation, tab close, HF wrapper clicks)
orphaned a finished render permanently — the audio_id lived only in
page state, and a 60-minute take died that way today. Now the server
remembers the newest take (exempt from TTL pruning until a newer one
replaces it) and exposes it at /api/last-take; on load, if the server
holds a take the page doesn't know about, the output dock offers a
one-click 'Recover last take' that streams it into the player.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stream the finished-audio download with MB progress
After a long render, fetching the WAV to the browser can take minutes
(hundreds of MB), during which the UI sat on Complete with no player —
indistinguishable from a hang. The fetch is now streamed with a live
'Downloading your take… X / Y MB' status; Complete only shows once the
audio is actually in hand. Stop is hidden during the transfer since the
render is already done.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 120-min+ record attempt hit the 3600s ceiling and lost the entire
render. The generator streams progress throughout, so a long timeout is
insurance against losing an hour of GPU work, not a runaway risk. Clone
references also prefill slower than the preset-voice pace the old
ceiling was sized against.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancelling previously required knowing that a page refresh drops the
SSE stream and triggers the backend's zombie defense. The status card
now shows a Stop button while a generation is running; it aborts the
fetch, which closes the stream and cancels the render server-side the
same way. The status card shows a Stopped state with a cold-start note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Never fail a completed take on browser-side waveform decode
Decoding a multi-hour WAV for the peaks display can exceed browser
memory; fall back to the placeholder waveform instead of flipping the
UI to an error state after a successful render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Give the Chorus wordmark a logomark and more presence
A waveform glyph of five bars in the cast's voice colors (many voices,
one chorus), larger serif wordmark, matching SVG favicon, and a hover
that sweeps the bars to the accent color.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lower batching gate to 250 words; split monologues into even atomic pieces
Ear-check on a 292-word (~98s) solo render found audible N8 drift —
pace speeds up and volume fades toward the end (visible in the output
waveform) — so the 350-word 'monolithic is fine' gate was too high:
drift onset beats seam risk. Gate lowered to 250. The oversized-turn
splitter now cuts monologues into even sentence-boundary pieces
(292w -> ~146+146 rather than greedy 200 + ragged tail) and emits them
as atomic chunks, fixing a latent bug where the grouping pass could
re-merge sub-target pieces back into one chunk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clone dialog: multi-select Use as, longer read scripts, live recording pulse
Use as returns as a multi-select — none up to all four speakers — with
the save button narrating the choice (Save voice / Save & use as
Speakers 2 & 3). It defaults to the slot the dialog was opened for, or
Speaker 1 from the sidebar so a fresh clone always has a visible home.
Read-along passages roughly doubled to ~130-145 words (~50-60s at a
relaxed pace). Recording state is unmistakable now: the Stop button
pulses a red glow ring and the timer goes red with a blinking dot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simplify the clone dialog: name first, no Use as pills
Reorder to name -> record/upload (+ read-along script) -> preview ->
checkboxes -> one contextual save button. The Use as speaker pills are
gone: the save button itself says where the voice lands — 'Save & use
as Speaker N' when the dialog was opened from that slot, plain 'Save
voice' (library only, assign later via Change) from the sidebar button.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>