VOLTAreference

Reference / Connect and share

Send MIDI to instruments

Connect and share

Open in the workbench →
On this page · 7 sections

Use Volta patterns to send notes and controls to MIDI instruments, with timing and device setup made explicit.

As-built. The inbound side (controllers, CC→macro learn, MIDI clock in) lives in Connect controllers and other software; this doc covers sending a song's notes out to an external synth — hardware like a Korg minilogue, or another program.

What it is#

Two builtins route a pattern's notes to a MIDI device instead of (or alongside) a Volta voice:

import "devices" (minilogue)
import "keys" (pad)

chords = !"<[c4,eb4,g4] [ab3,c4,eb4]>"
lead   = midi minilogue !"c4 eb4 g4 c5"          -- MIDI-only: the synth is the sound
mirror = play pad chords |> midiOut minilogue    -- a Volta voice AND MIDI
  • midi <device> <pattern> — a MIDI-only track. It renders no Volta voice; each event is sent as note-on/note-off to the device. Parallels instrument (an external sound source driven by a pattern).
  • <track> |> midiOut <device> — mirror an existing play track: you hear the Volta voice and the device plays the same notes. Parallels the plugin effect wrapper. Useful for layering a software patch with the hardware, or for silent practice (the Volta voice as a guide when the hardware is unplugged).

Pitch flows as MIDI note numbers end-to-end (c4 = 60), so note output is exact. Per-event velocity (c4=0.7, |> velocity) scales the MIDI velocity (1–127; a velocity-0 note-on would read as a note-off, so the floor is 1).

One engine stamps the note stream on both targets; only the port write differs. The desktop host sends via midir (CoreMIDI / WinMM / ALSA); the browser product pages send via the Web MIDI API. The browser workbench page does not send MIDI out yet — see Notes & limits.

Devices#

A device is an in-language preset — typed, importable, and resolved at compile time, so the runtime only ever sees the port match + channel + CC numbers. Write one for a synth the standard library does not ship yet:

device myPoly = {
  match   = "my poly",     -- case-insensitive substring of the MIDI port name
  channel = 1,             -- 1–16 (the device's global MIDI channel)
  poly    = 4,             -- documentation / future voice-stealing hint
  cc      = { cutoff = 74, resonance = 71 },  -- name → controller number
}

device Name = { … } is sugar for Name = midiDevice { … }. Presets ship in the standard library devices.volta (import "devices" (minilogue)); add a synth by appending another device. The cc map is carried into the IR for a future cc "cutoff" … automation builtin; note output doesn't need it.

The match is a substring test against the OS port name, so "minilogue" matches "Korg minilogue:port 0". First match wins; pick a more specific substring if several devices share a word.

MPE (MIDI Polyphonic Expression)#

Plain MIDI has one pitch-bend wheel per channel, so a chord can't bend its notes independently — every pitch quantizes to an integer 12-TET note number, and a tuning system, per-note cents, or glide are lost at the port. Three optional device fields switch on MPE output, which closes that gap:

device seaboard = {
  match     = "seaboard",   -- (or a LinnStrument, an Osmose, a DAW MPE track…)
  mpe       = true,         -- per-note member channels + pitch bend
  bendRange = 48,           -- semitones per member channel (default 48)
  zone      = "lower",      -- "lower" (default) | "upper"
}

tb303 = patch { glide = 60ms, source = saw (freq pitch),
                ampEnv = adsr 3ms 300ms 0.2 150ms, gain = vel * 0.6 }

tuning = just ~C2
lead = midi seaboard !"c4:maj"          -- a JI triad, exact on the hardware
acid = play tb303 !"c2 c3" |> midiOut seaboard   -- glide rides continuous bend
  • mpe = true turns on MPE. Each note is allocated its own member channel and sent as a note-on at the nearest integer note number plus a per-note pitch bend carrying the retuned fractional remainder. Volta's tuning systems already remap nominal pitch to fractional MIDI at compile time (Tuning systems) — MPE just emits that fraction as bend instead of rounding it away. mpe = false (the default) is the historical single-channel, nearest-semitone behavior, unchanged.
  • bendRange (semitones, default 48) is the per-member pitch-bend range Volta configures on the device (via RPN 0) and encodes bends against. 48 is the MPE spec's recommended default; a narrower range gives finer resolution but caps how far a glide can bend.
  • zone is "lower" (master = MIDI ch 1, members 2–16) or "upper" (master = ch 16, members 1–15).

Channel allocation. Member channels are handed out round-robin, reusing any freed by a note-off; on overflow (more simultaneous notes than the zone's 15 members) the oldest note is stolen. On device init Volta sends the MPE zone configuration (RPN 6 on the master) and the per-channel bend range (RPN 0).

Continuous bend. A glide (mono or poly) and any per-note pitch motion emit a stream of bend updates while the note holds — ~5 ms cadence, deduped — so a slide bends smoothly on the hardware rather than stepping. Each note's origin is the nearest pitch from the previous step, which unifies the mono slide (one prior pitch) and a poly-glide chord (nearest of the previous chord).

How it's implemented#

Compiler. midiDevice { … } is a marker builtin collected in lower.rs pass 1 into a device table (it produces no synth/track). midi / midiOut are a descriptor family, builtins/midi.rs — it owns their ports and arities and declares that device takes a bare binding and nothing else. They resolve a device reference against that table and bake an ir::MidiOutSpec (match, channel (wire 0–15), mirror, cc, plus MPE's mpe, bendRange, zone) onto the Track. A midi track uses the $midi sentinel synth (no Volta voice); midiOut keeps the real synth and sets mirror = true. The MPE fields ride the same record-parse path as channel/cc (records-don't-lower; parse_device_def), and serialize only when set, so a non-MPE device's JSON is byte-identical to before. (compiler/src/{builtins,lower,lower/track,ir}.rs, parse.rs for the device keyword.)

Engine. The engine pushes MidiFrame { at_sample, track, status, data1, data2 } into an audio→host ring (symmetric to the garbage ring) from schedule_track, at the same call site that drives a hosted plugin instrument. A $midi track triggers no voice; a midiOut track sends and triggers.

Desktop host (engine-host/src/audio.rs). MidiControl runs a lazy volta-midi-out worker that drains the ring and sends each frame via midir, mapping the frame's track index to an open output port. Routes (track → port-name substring) are rebuilt from the IR on every module load.

Browser host (runtime/src/native-runtime.ts + runtime/src/midi.ts). NativeRuntime.startMidiDrain polls WasmEngine::drain_midi and forwards each 15-byte record to MidiRegistry.sendBytes, preserving relative timing within a batch; MidiRegistry matches an output port by name substring. That is the product-page stack; runtime/public/studio-host.js (the workbench page) wires Web MIDI input only.

MPE frame-building lives in engine/src/mpe.rs — bend math, member-channel allocation, glide interpolation, RPN init. The engine stamps the note-on/bend/note-off MidiFrames in push order into the ring FIFO and keeps a per-track MpeState (allocator + previous-onset pitches), reset with the glide state. Pitch bend and RPN config reuse the 3-byte frame path, so neither the ring nor the host drains changed. (The byte-for-byte TypeScript twin was deleted with the Web Audio path.) Ten unit tests in engine/src/mpe.rs pin the bend math, allocation and RPN init.

Audio back in#

To process the hardware through Volta, patch its audio out into your interface and use the live-input path — input, follower "input", tap "input", vocode — exactly as in Live audio input. The native engine opens the default input device; route the synth there (or make its interface your default input). Combined with midi, you get a hybrid instrument: Volta sequences the synth, the analog output comes back in for effects/recording.

Notes & limits#

  • Channel is 1–16 in the DSL (wire value 0–15 in the IR/JSON).
  • Polyphony is the hardware's: a chord wider than the synth's voice count (4 on the minilogue) steals voices on the device — Volta sends every note.
  • Microtones / glide. On a plain (non-MPE) device, output rounds to the nearest semitone — tuning systems, per-note cents, and glide quantize to equal temperament at the port. On an mpe = true device they survive: the retuned fractional pitch is emitted as a per-note pitch bend, and glide rides a continuous bend stream (see the MPE section above).
  • Timing. Native: the host sends frames as the engine emits them (~one block of output-latency lead, the margin hardware wants); sample-accurate intra-block scheduling is a refinement. Browser: the drain is a setInterval poll, so the batch's send time is jitter-bounded by the poll cadence (relative timing inside a batch is preserved).
  • No MIDI out on the browser workbench. studio-host.js never drains the engine's MIDI ring, so midi / midiOut tracks are silent at the port there. Use volta-studio for hardware output. Open work, not a design decision.
  • CC automation / MIDI clock out (cc "cutoff" lfo, 24-ppq clock to sync the device's arp/delay) are designed-for but not yet shipped — the cc map and the ring are in place for them.
  • Input device selection is default-only on native today; selecting a specific input (when the synth isn't your default) is a follow-on.

See also#