Browser-based real-time motion-to-music demo. A Python backend runs the GPU pipeline behind an HTTP + WebSocket server; a Next.js front-end renders the live UI:
- Upload a source audio file, get a live ACE-Step stream back
- Live-editable prompt
- Every knob visible at once in stacked Core / Groups / Keystones sections (no bank tab switching)
- Optional hardware MIDI input via the Web MIDI API with per-knob
MIDI learn: click
CC ?next to any knob, wiggle a physical control, and it rebinds live. Mappings persist per option-profile in localStorage; clickReset MIDI mapto restore the auto-map. - Optional webcam motion input (frame-diff driving
denoise) - HUD canvas with waveform background, history trails, playhead, SDE curve, and live stats
- Zstd-compressed delta slices decoded in the browser
- Server: the full ACE-Step install (CUDA GPU,
uv sync, prebuilt TensorRT engines). No extra dependencies beyond the main project. - Client: any modern Chromium or Firefox. Web MIDI and webcam support are optional.
A single launcher starts the Python backend on :1318 and a Next.js
dev server on :6660 with combined output:
uv run python -u -m demos.realtime_motion_graph_web.run
# forward backend args after `--`:
uv run python -u -m demos.realtime_motion_graph_web.run -- --accel eagerFirst run installs web/node_modules automatically (Node.js 20+ required).
Open http://localhost:6660. Next.js rewrites /api/*, /fixtures/*,
/loras/*, and /videos/* to the backend at :1318; the WebSocket
URL comes from NEXT_PUBLIC_POD_BASE_URL (set by the launcher).
The full UI lives under web/ (React + zustand, mirrored from the
internal daydreamlive/demon-react package). See web/components/,
web/engine/, web/hooks/, and web/store/ for the source.
Anything after -- on the launcher is forwarded to the backend.
--accel {tensorrt,compile,eager} sets BOTH decoder_backend and
vae_backend on the underlying Session. Default is tensorrt.
--decoder-accel and --vae-accel override --accel for one
component at a time. Useful when, for example, only one of the two
TRT engines exists for a given checkpoint, or when you want to debug
one component in eager while the other stays on TRT:
# Mix-and-match: TRT decoder, eager VAE.
uv run python -u -m demos.realtime_motion_graph_web.run -- \
--accel tensorrt --vae-accel eagerThe text encoder stays resident in VRAM by default so live prompt edits do not
pay CPU/GPU transfer cost. Add --offload-text-encoder on lower-VRAM GPUs to
restore the previous lower-memory behavior.
--checkpoint <name> selects which DiT checkpoint to load. The name
must match a directory under <checkpoints_dir>/. Full TensorRT mode is
registered for acestep-v15-turbo (default, 2B) and
acestep-v15-xl-turbo (XL). XL TRT uses dynamic-batch b8 decoder
profiles; build one first, then launch with:
uv run python -u -m demos.realtime_motion_graph_web \
--accel tensorrt --checkpoint acestep-v15-xl-turboThe browser HUD receives tick_ms and dec_ms over WebSocket, but the
server does not print structured telemetry. To compare accelerators,
checkpoints, TRT profiles, or VAE settings without browser/audio-device
overhead, run the headless benchmark:
uv run python -u -m demos.realtime_motion_graph_web.benchmark \
--accel tensorrt --checkpoint acestep-v15-xl-turboThe benchmark mirrors the backend inference path: fixture/config defaults,
TRT engine selection, Session(...), prepare_source, encode_text,
session.stream(...), repeated stream.tick(...), and optional VAE decode.
It reports setup timings, per-generation tick, decode, tick+decode
mean/P50/P90/P95/min/max, skip counts, and peak CUDA memory.
Useful variants:
# Compare compile mode against the same workload.
uv run python -u -m demos.realtime_motion_graph_web.benchmark --accel compile
# Mixed backend, same style as the server flags.
uv run python -u -m demos.realtime_motion_graph_web.benchmark \
--decoder-accel tensorrt --vae-accel eager
# Persist raw samples and summary stats.
uv run python -u -m demos.realtime_motion_graph_web.benchmark \
--accel tensorrt --checkpoint acestep-v15-xl-turbo \
--json runs/xl-trt-bench.json
# Mirror PipelineRunner's decode-skip behavior.
uv run python -u -m demos.realtime_motion_graph_web.benchmark \
--accel tensorrt --skip-threshold 1e-3By default, --skip-threshold -1 disables decode skipping so VAE decode
latency is measured on every completed generation. Set --no-decode for
decoder-only throughput.
Once it's running:
- Open
http://localhost:6660/ - Click Play — the demo loads the default fixture
(
inside_confusion_loop_60s_gsm.wav). Fixtures stream from thedaydreamlive/demon-fixtures-v2Hugging Face dataset on first request and are cached locally. - Switch fixtures any time using the selector at the top of the Advanced drawer; switching tears down the session and restarts with the new audio.
- Cold start takes ~15 s while the server loads the model + TRT
engines (or longer on
--accel compile); once it's ready the UI switches to the live HUD view.
Audio is the primary source: the demo always loads from the
canonical fixture set (daydreamlive/demon-fixtures-v2 on Hugging Face,
listed in acestep.fixtures.KNOWN_FIXTURES), served by the backend
at /fixtures/<name> via lazy HF download.
Video is optional and secondary — drop any .mp4/.webm/.mov
into videos/ (sibling of web/) to attach the audio-reactive shader
pipeline. With no videos present the demo runs audio-only (graph mode
is the default and looks the same).
demos/realtime_motion_graph_web/
├── README.md
├── __init__.py
├── __main__.py # `python -m demos.realtime_motion_graph_web`
├── run.py # launcher: backend + Next.js dev server
├── server.py # HTTP API + WebSocket multiplex on one port
├── ws_adapter.py # per-WebSocket coroutine; wraps StreamingSession
├── audio_codec.py # per-subscriber slice codec + stem payload
├── protocol.py # wire format (Python source of truth)
├── videos/ # user-supplied .mp4/.webm/.mov drop-in (optional)
└── web/ # Next.js front-end (React + zustand)
The WebSocket protocol is defined in protocol.py (the Python source
of truth that web/engine/protocol.ts mirrors):
- Init: JSON config -> binary audio upload
(
<uint32 channels><uint32 samples>+ float32 PCM) - Server init: JSON ready + binary float16 initial buffer
- Streaming: JSON params/prompt out, binary slice (raw float16 or
zstd-compressed float16 delta) +
params_update/prompt_appliedJSON messages in
server.py multiplexes the JSON HTTP API, fixture/video file serving,
and the WebSocket upgrade onto one TCP port; the WS handshake hands
off to ws_adapter.handle_client, which wraps a
acestep.streaming.session.StreamingSession.
The video is rendered through a small WebGL2 shader pipeline so it visually responds to the music in real time. Two effects:
- Color parallax — saturated regions drift horizontally with a slow sway plus a punch on every kick.
- Bloom on kick — luminance-thresholded bloom that brightens with the bass envelope.
The same kick amplitude is exposed to CSS as --bloom-amount, so the
perimeter HUD bars and the cursor halo glow in lockstep with the
shader bloom on the video.
Curator setup: nothing. Color parallax is saturation-driven, not
depth-driven, so there is no preprocessing step and no depth map
sidecars to generate. Drop the source video into videos/ and run
the launcher as usual. If WebGL2 is unavailable the canvas is hidden
and the plain video plays as fallback.
The eight files in acestep.fixtures.KNOWN_FIXTURES ship with sidecar
files in the daydreamlive/demon-fixtures-v2 HF dataset:
<track-id>/
source.wav
track.json # editable BPM/key/time-signature + asset manifest
stems/
vocals.wav # optional user-facing vocal stem
instruments.wav # optional user-facing instrumental stem
sidecars/
full.json
full.safetensors # full-track source latent + context_latent
vocals.json
vocals.safetensors # optional pre-encoded vocal source
instruments.json
instruments.safetensors # optional pre-encoded instrumental source
When the client sends fixture_name for a known fixture, the server
loads the cached source latent + context latent and reads BPM / key
from the JSON, skipping librosa beat tracking, the CNN key
classifier, and Session.prepare_source. Session.encode_text still
runs live every connect (it depends on the prompt and the demo's
blended-prompt UI typically diverges from any baked tags within
seconds of connecting; the ~60ms warm cost isn't worth the cache
complication). For ad-hoc uploads (no fixture_name), the full live
path runs as before.
The runtime checks MODELS_DIR/fixtures/ first (so local edits are
tested without an upload round-trip) and falls through to the dataset.
User uploads use the same layout under MODELS_DIR/user_uploads/;
fixtures are effectively system-provided tracks.
If you want to override the BPM or key for a fixture, edit the
<track-id>/track.json and re-run the precompute script. Editing the
JSON's bpm / key / time_signature fields and re-running
preserves them (the script only re-derives values that aren't already
pinned). To
forcibly re-derive everything from scratch, pass --force:
uv run python -m scripts.calibration.precompute_fixture_sidecars
uv run python -m scripts.calibration.precompute_fixture_sidecars --with-stems
uv run python -m scripts.calibration.precompute_fixture_sidecars --force
uv run python -m scripts.calibration.precompute_fixture_sidecars --only \
inside_confusion_loop_60s_gsm.wavAfter editing, upload the regenerated track JSON, WAV stem assets, and
all sidecar JSON/safetensors pairs back to daydreamlive/demon-fixtures-v2.
The runtime still falls back to the legacy daydreamlive/demon-fixtures
dataset for old audio and full-track sidecar files when a v2 asset is
not present.
Fixture dataset v2 plan:
- Create
daydreamlive/demon-fixtures-v2as a Hugging Face dataset repo. - Run
uv run python -m scripts.calibration.precompute_fixture_sidecars --with-stems. - Upload each fixture directory with
source.wav,track.json,stems/, andsidecars/. - Keep
daydreamlive/demon-fixturesavailable until deployed pods have warmed or downloaded the v2 layout.
mcp_server.py is a stdio MCP server that exposes every user-facing
demo action — prompt, knobs, LoRA enable/disable, timbre/structure
refs, source swap — as an MCP tool. Useful for letting Claude Code (or
any MCP client) drive the demo for automated testing.
How it works:
- The user opens the demo in their browser as usual. The backend
registers that session in a process-global registry and starts the
control bus, a small HTTP server on
127.0.0.1:1319. - The MCP server speaks HTTP to the control bus. It does not open its own WebSocket and does not start a separate GPU session, so running it alongside a browser tab is free.
- Every command the MCP sends is dispatched through the same handler
as the browser's own WebSocket frames. Acks (
prompt_applied,lora_catalog,timbre_set,swap_ready, etc.) flow back to the browser's WS, so the front-end UI mirrors MCP-driven changes automatically. The newparams_echomessage handles knob changes (seeuseMcpMirror).
Run the backend the usual way:
uv run python -u -m demos.realtime_motion_graph_web.runthen point Claude Code at the MCP server:
Env-var overrides:
DEMON_HOST/DEMON_PORT— backend's main HTTP+WS port (default127.0.0.1:1318).DEMON_CONTROL_HOST/DEMON_CONTROL_PORT— control bus (default127.0.0.1:1319).
Server-side flags:
--control-host <host>— bind the control bus to a non-localhost interface (use sparingly: this lets remote clients write into a live session).--control-port <port>— override the control port (default 1319).--no-control— disable the control bus entirely.
Available tools: list_sessions, session_state, list_fixtures,
list_loras, list_knobs, set_prompt, set_prompt_blend,
set_knob, set_knobs, get_knob, enable_lora, disable_lora,
set_timbre_strength, set_timbre_fixture, set_timbre_audio,
clear_timbre, set_structure_fixture, set_structure_audio,
clear_structure, swap_to_fixture, swap_to_audio. Most tools take
an optional session_id parameter; they default to the
most-recently-started session if you omit it.
Open the browser tab first — the MCP will refuse to act when there's no live session to attach to.
- Web Audio: an
AudioWorkletNodedrives a shared PCM buffer that the main thread patches in place on each slice. Same crossfade logic as the nativeAudioEngine(50 ms on swap, in-place delta add otherwise). - Web MIDI: auto-attaches the first input. Values use the endless-encoder two's-complement CC semantics from the native client so existing controllers just work.
- Webcam:
getUserMediawith a low-res capture canvas and a simple abs-diff detector, smoothed like the OpenCV version.
- "WebSocket connection failed": verify the backend is reachable on
:1318(firewall, reverse proxy). The launcher logs[backend]output if the Python side crashed. - Audio plays silent on first connect: browsers gate audio on a
user gesture;
Connect & startcounts as one, so this should "just work" but if it doesn't, click anywhere in the HUD view. - No MIDI devices listed: Web MIDI requires
localhostor HTTPS in Chromium. Use the localhttp://localhost:6660URL, or run behind a reverse proxy with TLS for remote access. - Webcam permission denied: same-origin constraint as MIDI. Switch to on-screen knobs mode if the browser blocks the camera.
- Cold start long: the GPU server rebuilds the pipeline on every new connection, same as the native server. Reuse connections when possible.
{ "mcpServers": { "demon": { "command": "uv", "args": [ "run", "python", "-u", "-m", "demos.realtime_motion_graph_web.mcp_server" ], "cwd": "C:/_dev/projects/DEMON" } } }