Bring MIDI controls, synchronized clocks and messages from other tools into your music-making workflow.
What it is#
The ways Volta reaches past the browser tab. Four shipped surfaces, all hung off one spine — a tempo model that divorces musical time (beats) from wall-clock time (seconds):
- Tempo (stage 0) — Volta schedules in beats, not seconds. A
tempo = 128bpmsource declaration sets the song's BPM; everything downstream converts through a runtimeTempoClockthat any external clock can drive. - MIDI input (stage 1) — a hardware controller's notes play the active synth, and its CC knobs bind to named macros via a DAW-style "learn" affordance. (MIDI output — sending a song's notes out to an external synth — is the mirror of this, documented separately in Send MIDI to instruments.)
- MIDI clock sync (stage 3) — an external 24-ppq MIDI clock drives Volta's tempo; a header picker chooses which input is the clock source.
- Audio export (stage 4) — render the active scene (or whole arrangement) to a WAV file offline, faster than realtime, deterministically.
Surface#
Set a song tempo (absent, the default is 120 BPM = the old CYCLE_SECONDS = 2.0):
tempo = 128bpm
MIDI notes and CC are wired entirely in the UI — no syntax. Click a macro tile's midi pill, wiggle a controller knob, and that (channel, cc) is bound (shown as e.g. cc74·1); shift-click the pill to clear. A connected controller's note-ons play the target synth at the controller's own velocity. Pick the MIDI clock source in the io panel's MIDI IN section; (none) returns to the internal tempo. Export is the workbench's Export Audio dialog (ui/src/app/dialogs.rs::export_window): length in bars, bit depth, sample rate, container, stems, loudness target. On the desktop a background worker (app-native/src/export.rs) renders and writes the files; in the browser the same engine renders offline and runtime/public/studio-host.js hands the bytes to the page as a download (runtime/src/export.ts does the WAV encode and marker cues). When a song with a fixed arrange schedule is active, export renders the whole arrangement — every section in order with its scene changes and motion — pre-filling the length with the arrangement's own bar count until you dial it (ui/src/app/lifecycle.rs, see Arrange a song § Offline export). Otherwise it's a flat bounce of the active scene's tracks.
Native MIDI input (desktop)#
Specced in Native Midi and shipped 2026-07-25 for the desktop shell. The routing policy is now shared: the browser page reads Web MIDI (studio-host.js::startMidi) and pushes ports and raw messages into the same Rust workbench, so both hosts behave identically below.
- Ports are opt-in. The io panel's
MIDI INsection lists every input with an open/close checkbox and a live activity dot; nothing is opened until you ask, so a render, a CI run and the plugin target never touch CoreMIDI. The list re-reads on a ~1 s tick while the panel is open (hotplug rescan), and ports you had enabled are reopened automatically when they reappear. Opening happens on a backgroundvolta-midi-inworker, never the UI thread; a port that keeps failing to open backs off (1, 2, 4, 8, 16 s) and gives up after six tries until you untick and re-tick it or it disappears and comes back. - Notes play a held preview voice on the resolved target synth — shown, and overridable, in the same panel (
notes →). Note-off releases exactly its note-on (id = (channel << 8) | note), and a velocity-0 note-on counts as a note-off. - CC → macro learn: every macro tile in the strip has a
midipill. Click it, wiggle a knob, and it readscc74·1; shift-click clears. One CC may drive several macros, and a CC maps through the macro's declared range. Bindings persist by macro name in the shell-state blob, so they survive a recompile and a restart. - Clock in: pick a source port in the panel (
(none)= the internal tempo). A 24-ppq train is smoothed (1-pole, α = 0.15) and applied as a tempo ramp, never a jump, so a jittery clock bends instead of stuttering;0xFA/0xFBstart and0xFCstops the transport. Implausible intervals (a pulled cable, a dropped USB frame) are ignored rather than lurching the transport.
Pulses carry the driver's timestamp, not the frame's: 24 ppq at 120 BPM is one pulse every 20.8 ms, finer than a UI frame, so frame-stamping would fold several pulses onto one instant and read a fraction of the real tempo.
Where it lives: engine-host/src/midiin.rs (the device hub — one midir connection per open port, callback → queue), ui/src/midimap.rs (all routing policy: bindings, learn, the BPM estimator, held-note bookkeeping), ui/src/app/macrostrip.rs (pump_midi_in), ui/src/app/io.rs (the io-panel section) and ui/src/app/prefs.rs (persistence) and ui/src/perform.rs (the learn pill). Tests: engine-host/src/midiin.rs (7), volta_ui::midimap (17), ui/tests/midi_in.rs (6, driven headlessly through the real workbench).
Not shipped: song-position-pointer seeking, MPE/aftertouch in, soft-pickup CC, bindings serialized into the song, per-track note routing — see Native Midi §4.
On-screen keys (musical typing)#
Shipped 2026-08-30 as Onscreen Keys Stages 1–2 — playing the workbench with no controller at all. A bottom keys strip (header keys chip, View ▸ Keys, ⌘K; persisted in the shell blob):
- The keybed — click to play (velocity from the press height: top soft, bottom hard), drag for a glissando, as many whole octaves as the width affords, centered on the typing octave. Keys light for every held note — typed, clicked, or arriving from a hardware controller — so the strip doubles as a note monitor.
- Musical typing — the two-row DAW layout while the strip is open and no text surface owns the keyboard:
A W S E D F T G Y H U J K O L P ; 'from the base C,Z/Xoctave,C/Vvelocity; letter hints are painted on the covered keys and the covered span is underlined. If the editor has the caret, the strip saystyping → editorinstead of dying silently; click the bed to claim typing. - One routing model — notes go where hardware notes go: the shared
notes →target (the io panel's own combo, mirrored in the strip), held as preview voices via the sameMidiNoteOn/MidiNoteOffcommands, on pseudo-channel 16 so an on-screen C4 and a hardware C4 hold distinct voices. A module swap releases everything; closing the strip releases only its own channel; aholdlatch defers releases (sustain pedal). - Scale marks — when the song declares a
key = "…"(or the bench's roll/chord wheel picked one — the same finder, so the surfaces can't disagree), in-scale keys wear a dot under the felt strip, the root wears the bright one, and the header readskey d dorian. Nothing is dimmed — the white/black identity stays primary. - Capture with real lengths — armed capture records the
MidiNoteOn/MidiNoteOffpair, so a typed or hardware-played take lands in the capture review with its held durations as mini-notation@weightstretches (c4@3 ~ ~), chords comma-folded ([e4,g4]), holds clamped at the next onset and their own cycle group. One-shot auditions stay one step.
Where it lives: ui/src/keys.rs (typing map, latch/glissando model, keybed geometry — pure), ui/src/app/keysstrip.rs (the panel), ui/src/midimap.rs (note_on/note_off/release_channel, KEYS_CHANNEL). Tests: volta_ui::keys (10), volta_ui::midimap's keys cases, ui/tests/keys.rs (8). Shot: VOLTA_KEYS=1 → keys-strip.png. In the browser both pages hear it through studio-host.js, which routes midiNoteOn/midiNoteOff onto the engine worklet's preview_trigger/preview_release (Onscreen Keys §3).
Ableton Link & OSC (desktop)#
Shipped 2026-07-25 as Link Osc, superseding this document's §4 bridge design — the desktop shell runs the engine in-process, so Link is a direct dependency rather than a WebSocket helper.
One seam. Every external time source produces the same three facts (tempo, a beat at a host instant, playing) and drives engine-host/src/sync.rs, which turns them into the two engine calls that already existed for the rooms clock: a ramped set_tempo and a sample-accurate reorigin_beat_at_sample anchored through the audio thread's (sample, wall µs) seqlock. Corrections under 1 ms are left alone (a dead-band; re-seating the scheduler every packet would chatter for no audible gain), and the measured phase error is reported in the panel either way — it is the number that says whether you are actually locked.
Link (engine-host/src/link.rs, behind the link cargo feature — make build-studio / make run-studio turn it on; the plain workspace build stays free of a C++ toolchain). Follow mode takes tempo + phase from the session at the song's bar quantum; Lead mode publishes local tempo and transport. Zero peers changes nothing.
OSC (engine-host/src/osc.rs, rosc, both directions off by default):
| in | effect |
|---|---|
/volta/macro/<name> f | set the macro — 0..1 maps through its declared range, anything else is taken at face value (/volta/macro/cutoff 8000 = 8 kHz) |
/volta/track/<name>/level f | set the track fader (linear, clamped ≥ 0) |
/volta/scene s | apply a scene (bar-quantized, like a perform chip) |
/volta/transport "start"|"stop" | start/stop everything (a float works too) |
/volta/tempo f | ramp the tempo (20–400 BPM; anything else is ignored) |
/volta/seek f | seek to an absolute beat |
OSC has no authentication, so the listener binds loopback only (127.0.0.1) unless the io panel's LAN box is ticked (a phone controller); the status reads listening :<port> · this computer or · network so the reach is never a guess (OscIn::bind(port, lan)).
Out (throttled to 30 Hz, only when a destination is set): /volta/beat, /volta/tempo, /volta/playing. Unknown addresses and malformed packets are counted and shown as N dropped, never an error — a controller sending garbage must not disturb audio.
The io panel's SYNC section carries mode, the Link toggle + peer count + session tempo, the phase error, and the two OSC fields.
How it's implemented#
- Compiler (stage 0 only):
compiler/src/types.rs—UnitKind::BeatsPerMinute(thebpmsuffix).ir/src/ir.rs—Module.tempo: f64(default 120 viaserde(default = "default_tempo")) andTrack.cycle_beatsalongside the derivedcycle_seconds.compiler/src/lower.rs—extract_temporeads the top-leveltempo = Nbpm;cycle_seconds_at(tempo) = CYCLE_QUANTUM_BEATS · 60 / tempo. Testsdefault_tempo_is_120/tempo_declaration_scales_cycle_seconds.
- Workbench (
ui/, native and wasm alike):ui/src/midimap.rsis the whole routing policy — port bookkeeping, note-on/off pairing (id = (channel << 8) | note), CC learn and binding lookup through each macro's declared range, and the 24-ppqBpmEstimator(1-pole IIR, α = 0.15) that turns a clock train into a ramped tempo.ui/src/app/io.rsdraws theMIDI IN/SYNCpanel,ui/src/app/macrostrip.rs::pump_midi_inpumps it each frame, andui/src/app/prefs.rspersists bindings by macro name. - Hosts:
engine-host/src/midiin.rsopens desktop ports (midir, one connection per open port) andengine-host/src/sync.rsfolds every external time source intoset_tempo+reorigin_beat_at_sample. In the browserruntime/public/studio-host.js::startMididoes the same job with Web MIDI, publishing throughpush_midi_ports/push_midi_in(ui-wasm/src/web.rs) — driver timestamps, not frame times. Web MIDI is requested lazily, byensureMidi(), the first time something wants it (theMIDI INdevice pick, a held note from the keys strip or a controller): asking at boot met a first-time visitor with a device-permission prompt nothing on screen had mentioned (2026-09-21 browser walkthrough, C14). - Page side (browser only):
runtime/src/tempo.ts—TempoClockmaps beats↔seconds by integrating the compiledModule.tempoMap, withsetTempoappending a new anchor rather than rewriting history.CYCLE_QUANTUM_BEATS = 4mirrors the compiler. Tests inruntime/src/tempo.test.ts.runtime/src/midi.ts—MidiRegistry: Web MIDI access, note callback, CC learn and the clock consumer. It owns the browser's MIDI output ports today (Send MIDI to instruments); MIDI in routing is the Rust path above.runtime/src/export.ts—encodeWav(16-bit PCM, TPDF dither seeded fromruntime/src/rng.tsso bytes are reproducible) plusappendMarkerCues, the browser twin of the engine'sappend_marker_cues. The render itself isengine/src/offline.rs.- The TypeScript scheduler, macro strip and page wiring (
scheduler.ts,macros.ts,main.ts) were deleted with the browser workbench (2026-08-09); see git history.
- Desktop export:
app-native/src/export.rsrunsvolta_engine::offline::render_export(orrender_export_arrangement) on a plain worker thread, reports progress back over an mpsc the frame loop polls, and stamps every WAV with avltaprovenance chunk. The old Tauriengine_export_wav_to_filecommand went withapp-tauri(removed 2026-07-20).
Notes & limits#
- Determinism of export is real. The seeded RNG (
engine/src/rng.rs) is a pure function of (seed, coordinate) and every stochastic source derives fromModule.seed. Two exports of the same module are bit-identical. A module flaggednondeterministic(liveinputsource) cannot be captured offline — those sources fall silent and the dialog warns. - MIDI clock drives tempo well, phase poorly. It carries no absolute beat number and no bar concept, and its pulses jitter, so the estimator low-passes them;
Startresets the estimate. Many sources never send transport at all, so the first pulse is treated as "running". - Note-off is not routed — voices are time-bounded by their pattern event / release tail. Held-note support is reserved.
- The optional
cc 74compiler sugar from Connect controllers and other software §3 is not shipped; the learn UI covers the need. - Everything degrades gracefully: no Web MIDI, or a declined permission → no ports listed, nothing opened, Volta runs on its internal clock unchanged.
- Outstanding: see the "Not shipped" list above and Native Midi §4. Stage 2 (Link/OSC) and stage 5 (the native host) both shipped.