VOLTAreference

Reference / Connect and share

Connect controllers and other software

Connect and share

Open in the workbench →
On this page · 6 sections

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 = 128bpm source declaration sets the song's BPM; everything downstream converts through a runtime TempoClock that 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 IN section 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 background volta-midi-in worker, 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 midi pill. Click it, wiggle a knob, and it reads cc74·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/0xFB start and 0xFC stops 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/X octave, C/V velocity; letter hints are painted on the covered keys and the covered span is underlined. If the editor has the caret, the strip says typing → editor instead 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 same MidiNoteOn/MidiNoteOff commands, 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; a hold latch 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 reads key d dorian. Nothing is dimmed — the white/black identity stays primary.
  • Capture with real lengths — armed capture records the MidiNoteOn/MidiNoteOff pair, so a typed or hardware-played take lands in the capture review with its held durations as mini-notation @weight stretches (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).

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):

ineffect
/volta/macro/<name> fset 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 fset the track fader (linear, clamped ≥ 0)
/volta/scene sapply a scene (bar-quantized, like a perform chip)
/volta/transport "start"|"stop"start/stop everything (a float works too)
/volta/tempo framp the tempo (20–400 BPM; anything else is ignored)
/volta/seek fseek 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 (the bpm suffix).
    • ir/src/ir.rs — Module.tempo: f64 (default 120 via serde(default = "default_tempo")) and Track.cycle_beats alongside the derived cycle_seconds.
    • compiler/src/lower.rs — extract_tempo reads the top-level tempo = Nbpm; cycle_seconds_at(tempo) = CYCLE_QUANTUM_BEATS · 60 / tempo. Tests default_tempo_is_120 / tempo_declaration_scales_cycle_seconds.
  • Workbench (ui/, native and wasm alike): ui/src/midimap.rs is 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-ppq BpmEstimator (1-pole IIR, α = 0.15) that turns a clock train into a ramped tempo. ui/src/app/io.rs draws the MIDI IN / SYNC panel, ui/src/app/macrostrip.rs::pump_midi_in pumps it each frame, and ui/src/app/prefs.rs persists bindings by macro name.
  • Hosts: engine-host/src/midiin.rs opens desktop ports (midir, one connection per open port) and engine-host/src/sync.rs folds every external time source into set_tempo + reorigin_beat_at_sample. In the browser runtime/public/studio-host.js::startMidi does the same job with Web MIDI, publishing through push_midi_ports / push_midi_in (ui-wasm/src/web.rs) — driver timestamps, not frame times. Web MIDI is requested lazily, by ensureMidi(), the first time something wants it (the MIDI IN device 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 — TempoClock maps beats↔seconds by integrating the compiled Module.tempoMap, with setTempo appending a new anchor rather than rewriting history. CYCLE_QUANTUM_BEATS = 4 mirrors the compiler. Tests in runtime/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 from runtime/src/rng.ts so bytes are reproducible) plus appendMarkerCues, the browser twin of the engine's append_marker_cues. The render itself is engine/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.rs runs volta_engine::offline::render_export (or render_export_arrangement) on a plain worker thread, reports progress back over an mpsc the frame loop polls, and stamps every WAV with a vlta provenance chunk. The old Tauri engine_export_wav_to_file command went with app-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 from Module.seed. Two exports of the same module are bit-identical. A module flagged nondeterministic (live input source) 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; Start resets 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 74 compiler 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.