VOLTAreference

Reference / Compose and perform

Continuous mixes with Auto-DJ

Compose and perform

Open in the workbench →
On this page · 4 sections

Sequence tracks into a continuous listening session using the available automatic mixing controls.

What it is#

Volta Radio's resident DJ. Instead of playing pastes back-to-back with dead air, the radio orders its queue harmonically on the Camelot wheel, tempo-aligns adjacent songs, and drops the incoming downbeat on an outgoing bar — all from compiled metadata (Module.tempo, Module.key, the arrangement schedule), no beat-detection or key-estimation. Two SongPlayer decks ping-pong: BPMs within ±8% get a beatmatched 4-bar overlap (the outgoing deck's tempo steps to the incoming BPM over 2 bars, locked one full bar before the seam); anything wider gets a plain 2-bar equal-power fade. The seam prefers an arrangement section boundary within ±2 bars of the aimed moment (~45 s in).

Surface#

No DSL surface — a song participates by declaring tempo, key, and (for seam placement) an arrange. On radio.html:

  • an auto-dj toggle (default on, persisted to localStorage["volta.radio.autodj"]; off = the original hard-cut sequencer);
  • key/BPM chips (e.g. F min · 4A) on the now-playing line and every queue row;
  • a "next: … — beatmatched mix / crossfade" line while a seam is armed.

The queue reorders once metadata hydrates (bounded-concurrency paste fetches, cached for playback). A compile failure at warm-up drops that queue entry and tries the next — the radio never wedges.

How it's implemented#

  • Pure math — runtime/src/dj.ts. DjMeta { bpm, camelot, keyLabel, sectionBarStarts } via two paths: metaFromSource (textual regex read of tempo = …bpm / key = "…", for ordering before anything compiles) and metaFromModule (exact values off the compiled ir::Module for the two songs meeting at a seam). camelotOf/parseKeyName (Camelot wheel; unknown or exotic keys degrade to null), transitionCost + orderQueue (greedy deterministic nearest-neighbor walk; unknown keys cost a flat 3), planSeam (±8% BEATMATCH_WINDOW → beatmatch, else fade), seamSchedule (bar-quantized seam beat, section-boundary preference, never earlier than now + rampBars + 1 bars, stepped ramp plan in beats), equalPowerGains.
  • The song's own form — the engine. A deck plays through SongPlayer, whose play applies the opening scene and then arm_arrangement (Unified Timeline Stage 7), so each track's scenes, transitions and songGraph are walked by engine/src/arrangement/runner.rs on the deck's audio clock — the same plan the workbench plays. The radio reads the walk back off AR01 rather than scheduling it, so a busy page cannot make a drop land late, and the seam maths below composes with it: sectionBarStarts still prefers a section boundary for the handoff, and the engine is what puts the section there.
  • Conductor — runtime/src/radio.ts. Two decks, each its own engine instance and AudioContext; ~15 s before the handoff (PREPARE_LEAD_SECONDS, aimed at TRACK_SECONDS = 45) the idle deck compiles + warms the next song. On the default native decks (SongPlayer.engineBeatmatch) the ramp is ONE sample-accurate engine tempo ramp over the ramp window, run on the audio clock so postMessage jitter cannot distort it; the fallback path instead fires the ramp steps sequentially as TempoClock nudges on the outgoing deck, each beat resolved against the live clock only after the previous step fired (every nudge bends the beat→time map). At the seam the incoming deck starts muted, then alignBeatOrigin re-origins its beat 0 onto the outgoing bar; the fade rides the outgoing clock's beat axis.
  • Player seams — runtime/src/song-player.ts. SongPlayer.setDjGain (a separate factor on the master gain, so a song's own level strip op and the DJ fade compose) and alignBeatOrigin. The next load() re-pins the module's own tempo, so a beatmatch nudge never outlives its song.
  • Tests: runtime/src/dj.test.ts (26 tests — Camelot laws, cost ordering, greedy-walk determinism, seam placement against a real TempoClock, the equal-power law). Manual run: cd web && npx wrangler dev + cd runtime && npm run dev, publish two songs a few BPM apart, open /radio.html.

Notes & limits#

  • A song with scenes but no arrange does not cycle its scenes. It has no form to walk, so the engine declines to arm one and the deck starts every declared track — the same thing the workbench does with it. Until Unified Timeline Stage 7 the page walked such a song itself, rotating its scenes on a ~14-second wall-clock timer that nothing in the song asked for and that the workbench never played; the radio and the app therefore disagreed about what the same paste sounded like. Scenes without an arrange are a palette to launch by hand, not a form. Give a song an arrange (or a songGraph) for it to move on its own.
  • A finite song now ends by itself. AR01.ended — the engine's statement that an |> once form has run past its last bar — reaches the page as SongPlayer.onEnded. The transport keeps running past the end while tails ring out, so before this flag no page could tell "finished" from "still playing"; a deck can now retire a short song on the music rather than only on TRACK_SECONDS. Wiring the seam scheduler to prefer a real end over the aimed 45 s moment is still open.
  • Ordering metadata is textual (regex over paste source); only the two songs at a seam get exact compiled values. Storing bpm/key columns at publish is the noted follow-up.
  • A song still inside an authored tempo-map ramp at its seam beatmatches against its current tempo — no map-aware curve matching yet.
  • No EQ-swap seams (kill the outgoing lows as the incoming bass enters — needs a master EQ insert, Mixing tracks and buses) and no Playwright crossfade e2e yet.
  • All of the above are deferred in Continuous mixes with Auto-DJ §6 / ideas.md.