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
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
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
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
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…
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…
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
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…
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>
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>
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>
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>
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>
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>
Add optional read-along scripts to the clone recorder
Recorders staring at a live timer with nothing to say produce hesitant,
flat takes that clone badly. A 'Not sure what to say?' toggle reveals a
read-along card with five curated passages — natural first-person
voice, a question and an exclamation in each for pitch range, varied
sounds, ~25-30s at a relaxed pace — plus a Try another shuffle and a
read-it-like-you'd-say-it nudge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Checked by default. While recording it enables the browser's mic
pipeline (noise suppression, echo cancellation, auto gain); after
capture or upload it high-passes at 80Hz to kill rumble and handling
noise, trims leading/trailing silence, and normalizes the peak so
quiet laptop-mic takes arrive at a healthy level. The raw capture is
kept so toggling the box re-applies or undoes processing on the spot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make cloned voices a persistent, named personal voice library
Clones were per-slot files: reassigning a slot destroyed them, loading
an example reset the cast, and nothing survived a reload. Now clones
are first-class voices — up to 4, user-named at save time — stored in
IndexedDB on the user's device (nothing lands on the shared Space
server) and listed in a My voices section of the library with preview,
inline rename, delete, and the same one-click slot assignment as
presets. Assignments no longer destroy anything: switching a slot to a
preset keeps the clone in the library, and loading an example preserves
slots holding cloned voices. When all 4 slots are taken, saving a new
clone requires explicitly choosing which voice to replace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rework voice picking and cloning around the slot, not the grid
Change on a slot now opens the library scoped to that speaker: every
voice gets a one-click Use voice button (current voice disabled), pick
and the dialog closes. The Assign to 1-4 grid survives only in browse
mode from the header link.
Cloning gets a first-class flow: a permanent Clone a voice button in
the sidebar opens a single dialog holding everything the old flow
scattered — record from the microphone (live timer, 60s auto-stop) or
upload a file, playback preview with duration feedback (block <5s,
warn <10s, recommend 15-30s), speaker pills naming the voice being
replaced, and the consent checkbox — one Use this voice confirm.
Recordings and decodable uploads are converted client-side to 24kHz
mono WAV so the backend never sees exotic codecs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Icon affordances for the player: pop-out on the dock, x to close the stage
Replace the labeled Open player block button with an expand icon on the
dock's player row, and Back to editor with a corner close icon on the
stage (Esc and backdrop click still work). View full script now scrolls
the revealed transcript to the active line so the toggle visibly does
something.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The strip's display:flex overrode the hidden attribute's UA display:none
(author styles beat UA styles), so the Edit strip stayed visible above
the expanded composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collapse the composer after commit; add a Now Playing stage
Two progressive-disclosure passes so each phase of the flow owns the
screen. The composer hero collapses to a one-line strip (source summary
+ Edit) once a script exists — the editor becomes the star — and
expands on click or when the script empties. Generation completion
opens a full Now Playing stage: large seekable waveform, karaoke
caption that advances sentence-by-sentence within long turns, synced
full-script toggle, spacebar play/pause, and Back to editor. The dock
keeps its compact player with an Open player button.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Redesign the studio UI from the voice-studio prototype
Adopt the Claude Design prototype's direction while keeping all real
functionality (SSE progress, voice cloning + consent, script import,
editable turns):
- Instrument Serif / Plus Jakarta Sans, warm cream palette, floating
cards instead of full-height panes
- Cast panel: labeled speaker-count pills, voice slot cards with
preview + Change, auto-fill of empty slots with unused voices
- Voice library modal: search, gender/tag filter chips, per-voice
slot assignment, clone-a-voice entry
- Model select becomes a Fast/Best quality toggle; CFG scale becomes
an Expressiveness slider (same backend range)
- Output dock becomes a player: custom play button, seekable waveform
with progress, and a synced transcript that highlights the current
line using word-count-proportional timing
- /api/voices now returns per-voice tags and color
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Redesign the composer as a single input shell, not loose siblings
The duration select and import link were separate elements next to the
textarea — the select's focus ring read as a validation error (thick
accent-colored border, mismatched height vs. the button), and "or paste
/ upload a script" was a barely-visible dotted-underline link.
Replaces it with the chat-composer pattern (ChatGPT/Claude.ai): one
bordered card containing the textarea, with a footer toolbar strip for
secondary controls — a borderless pill-style duration select, an actual
"Import script" button with an icon, and the primary CTA. Reads as a
single obvious input at a glance instead of three competing controls.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add dropdown chevrons, grow the scenario field, harden voice preview
- All <select> elements had appearance:none with nothing replacing the
native arrow, so they didn't read as dropdowns at all. Adds a chevron
background-image consistently across the sidebar selects, duration
select, and the turn-speaker pill.
- Scenario textarea was rows=1 / min-height 44px — grows to ~4 lines
(min-height 104px) so a real description fits without scrolling.
- playVoicePreview() flipped the play button to "playing" state before
play() resolved, so a failed/silent playback still looked like it
worked. Now reverts the button state on a play() rejection instead of
swallowing it silently — this is what made the corrupted-audio bug
(see previous commit) invisible instead of an obvious error.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a target-length control for AI script generation
Users had no way to influence how long the AI-written script should
be — the system prompt's word cap was fixed at 1000 words regardless
of scenario, and the LLM's actual output length was whatever it felt
like producing within that ceiling.
Adds a Length select (1/2/5/10/15/20/30/45/60 min) next to the prompt
box. Selected minutes convert to a word target at the same 150 wpm
pace already used for the duration estimate elsewhere in the app, which
drives: the system prompt's length instruction (now phrased as a target
to reach, not just a ceiling not to exceed), the completion's max_tokens
(scaled to the target, capped at 8192), and the turn-count budget/trim
ceiling (previously a flat 50-turn cap that would have silently
truncated anything past ~20 minutes).
Long targets (30-60 min) are a best effort, not a guarantee — a single
LLM completion may not reliably produce that much coherent dialogue in
one pass, and the underlying provider's real max_tokens ceiling isn't
documented, so th…
Give input fields real contrast, not a subtle tint
Previous fix added a border but kept the fill at paper-soft (#fbf9f4)
inside a paper (#ffffff) card — a ~4-unit delta per channel, invisible
at normal screen brightness. Introduces a dedicated --field token
(#ece4d2) with a visible border-strong outline and inset shadow so the
composer and script-import textareas read unambiguously as inputs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The composer textarea had border:none + background:transparent, so it
blended completely into the white composer card with no visible edge
or fill — reported as "no text box there" / unclickable, since nothing
signaled it was an input. Gives it a real bordered field treatment
(paper-soft fill, border, focus ring) like every other input in the app.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Redesign as an app shell: Chorus voice studio, cream/ink palette
The first FastAPI rewrite kept the old Gradio wizard's shape (stacked
numbered steps, dark indigo/fuchsia) almost verbatim, so it read as the
same app under a new stack. This replaces it with an actual product
layout: fixed top bar, a settings sidebar (model, speaker count, per-
voice pick + preview), a script-composer canvas with a chat-style
transcript, and a persistent output dock for generation status/result.
Drops the "Conference Generator" framing in favor of a generic voice-
studio identity (Chorus, easily renamed) while keeping every existing
feature: AI script writing, examples, script import, per-turn speaker
editing, and SSE-streamed generation progress. Backend API is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebuild frontend as FastAPI + hand-built UI, replacing Gradio
Gradio's default styling no longer matched the bar for HF Space UIs, so
the frontend is now a FastAPI app serving a custom static UI (index.html/
app.js/styles.css) instead of gr.Blocks. Feature-for-feature parity with
the old app: AI script generation, example scripts, paste/upload script,
per-turn speaker/voice editing, voice preview, and CFG/model controls.
Generation progress now streams from Modal via real Server-Sent Events
instead of a client-side guessed timer, using the same stage/status/log
payloads modal_runner.py already emitted. Switches the Space to a Docker
SDK (Dockerfile added, requirements swapped from gradio to fastapi/
uvicorn/scipy) since sdk:gradio no longer applies.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>