07/MIDI
MIDI Over USB
The front USB port carries class-compliant USB-MIDI alongside HostLink. This section covers how the composite device works, how mono and polyphonic note input reaches a firmware, and what the service does with channels, expression, clock, and SysEx.
Not in the SDK release yet
One port, two functions
A USB host binds drivers to interfaces, not to devices. The module takes advantage of this: it enumerates as one composite device with two functions, the CDC serial function that HostLink has always used, and a USB-MIDI function beside it. The browser claims the serial interfaces through WebSerial; the operating system's MIDI driver claims the MIDI interfaces.
USB-MIDI a class specification, so there is nothing to install. The device name a DAW shows is the product string, which the SDK sets from your module name.
Receiving notes
midi::UsbMidi is the service. It receives in the USB interrupt, queues, and dispatches on the control loop's 1 ms poll, so notes arrive on the same thread as panel gestures. Attach it with loop.Use(usb_midi) like any other surface. What it dispatches to is your choice of consumer.
For a monophonic voice, the consumer is a MonoNoteTracker. Held notes can stack, overlap, and release in any order; the tracker resolves that history into one gate, one note, and one velocity, and flags the moments your envelope should retrigger.
static midi::UsbMidi usb_midi;
static midi::MonoNoteTracker notes;
/* in main(): attach it like any other surface */
usb_midi.UseTracker(notes);
loop.Use(usb_midi);
/* wherever you read controls, once per frame: */
if (notes.TakeRetrigger()) env.Trigger();
float hz = midi::NoteToHz(notes.Note() + usb_midi.BendSemis());
bool gate = notes.GateHigh();
notes.GateHigh() / notes.Note() / notes.Velocity()
Note() keeps its final value so a release tail holds its pitch.notes.TakeRetrigger()
notes.SetPriority(Last | Low | High)
A firmware that does not use hostlink::Host also calls usb_midi.Start("Product Name") once, to bring the USB device up itself.
Polyphony
The polyphonic consumer is a PolyNoteAllocator. It manages up to 16 voices, with the count set at runtime, and answers the questions a poly firmware would otherwise answer itself: which voice takes a new note, what happens when the voices run out, and how sustain and re-pressed notes behave. Freed voices are reused least-recently-released first, so release tails are disturbed as little as possible.
static midi::PolyNoteAllocator voices;
voices.SetVoiceCount(8)
.SetSteal(midi::PolyNoteAllocator::Steal::Oldest);
usb_midi.UseAllocator(voices);
/* per voice, in the poll: */
for (uint8_t i = 0; i < voices.VoiceCount(); i++) {
const auto& v = voices.VoiceAt(i);
if (voices.TakeRetrigger(i)) envs[i].Trigger();
osc[i].SetNote(v.note);
amp[i] = v.gate ? v.velocity * (1.f / 127.f) : 0.f;
}
| Steal policy | When the voices run out |
|---|---|
Oldest | the longest-held note yields; the usual default |
Lowest / Highest | protects the top or bottom of a chord |
None | new notes are dropped; nothing already sounding is cut |
Voices are keyed by channel and note together. An allocator listening omni therefore handles MPE member channels correctly without any special mode.
Channel, bend, and expression
The service listens omni by default, or filters to a single channel. Independently of the filter, it caches pitch bend, channel pressure, and CC 74 per channel. These are the three per-note dimensions an MPE controller spreads across its member channels. A mono firmware reads BendSemis() and is done; a poly firmware reads them per voice through Voice::channel.
usb_midi.BendSemis() / BendSemis(ch)
usb_midi.ChannelPressure(ch) / Cc74(ch)
usb_midi.OnRpn(fn, ctx)
(channel, param, value14, is_nrpn). Bend range, tuning, and MPE zone configuration all arrive this way.MIDI settings in the editor
MIDI channel, bend range, and note priority are ordinary Settings selectors. Declare each one wherever it fits on your settings pages, then hand the handle to the matching recipe. The recipe names the field, attaches its zone labels, and binds the value. From there it behaves like any other setting: it shows up in the web editor, applies on the next poll, and persists with presets.
auto ch = settings.Page(3).Pot(0)
.Selector(midi::UsbMidi::kChannelZones).Default(0);
auto bend = settings.Page(3).Pot(1)
.Selector(midi::UsbMidi::kBendRangeZones).Default(2);
auto prio = settings.Page(3).Pot(2)
.Selector(midi::UsbMidi::kPriorityZones).Default(0);
usb_midi.UseChannelSetting(ch); /* "MIDI Channel" Omni, 1 to 16 */
usb_midi.UseBendRangeSetting(bend); /* "Bend Range" Off to 12 st */
usb_midi.UsePrioritySetting(prio); /* "Note Priority" Last/Low/High */
Clock
Incoming MIDI clock is timestamped in the USB interrupt rather than at the poll, so the stamps carry microsecond precision instead of millisecond. The hook hands each pulse to your clock follower directly:
usb_midi.OnClockPulse([](uint32_t stamp_us, void* f) {
static_cast<ClockFollower*>(f)->OnPulse(stamp_us);
}, &follower);
ClockFollower and the rest of the timing stack are covered in Clocks and Timing. Start, Stop, and Continue arrive as ordinary events through OnEvent.
The full stream and SysEx
The trackers model notes; everything else is available raw. OnEvent(fn, ctx) receives every event that passes the channel filter. SysEx streams to OnSysEx in chunks of up to three bytes with a flag on the final chunk.
Transmit mirrors receive:
| Sender | Emits |
|---|---|
SendNoteOn / SendNoteOff | notes |
SendControlChange / SendProgramChange | controllers and program selects |
SendPitchBend / SendChannelPressure | 14-bit bend and aftertouch |
SendRealtime(Clock | Start | Stop | Continue) | the module as clock source |
SendSysEx(payload, len) | framed and packetized, all-or-nothing |
Guarantees
The service holds a few invariants regardless of what the host sends:
| Input | Behavior |
|---|---|
| NoteOn with velocity 0 | dispatched as NoteOff, per convention |
| CC 64 | sustain on the tracker and the allocator: releases defer, pedal-up flushes |
| CC 120 / 123 | all sound off and all notes off clear every attached consumer |
| Cable pull, host sleep, bus reset | note state clears and gates drop |
| Event order | preserved end to end, so chords allocate deterministically |
| Overload | queues are bounded; they drop and count rather than block |
MIDI runs beside HostLink, so the editor, preset transfer, and reboot into the updater keep working while notes play. The headers under alchemy/midi are the full reference once the interface lands.