Hermetic Modular

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

This interface has not been merged into the main codebase. It is documented here ahead of that, and lands in September at the latest. Please provide comments on the implementation if you have opinions.

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.

Hostno drivers to installBrowserWebSerial → HostLink editorDAW · etcCoreMIDI · WinMM · ALSAone cablefront USB-CModuleclass compliantone composite USB deviceCDC functionHostLink · interfaces 0/1USB-MIDI functioninterfaces 2/3

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.

a mono voice
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()

The resolved voice state. After the last release, Note() keeps its final value so a release tail holds its pitch.

notes.TakeRetrigger()

One-shot, true once per trigger. Consume it where your envelope starts.

notes.SetPriority(Last | Low | High)

Which held note sounds when several are down.

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.

an eight-voice allocation
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 policyWhen the voices run out
Oldestthe longest-held note yields; the usual default
Lowest / Highestprotects the top or bottom of a chord
Nonenew 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)

Pitch bend in semitones at the effective bend range. A host that sends RPN 0 (pitch-bend sensitivity) changes the range remotely; a bound bend-range setting takes over again the next time it is moved.

usb_midi.ChannelPressure(ch) / Cc74(ch)

Aftertouch and timbre, cached per channel from the accepted stream.

usb_midi.OnRpn(fn, ctx)

Decoded RPN and NRPN data entry, per channel, delivered as (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.

settings-backed configuration
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:

clock into the follower
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:

SenderEmits
SendNoteOn / SendNoteOffnotes
SendControlChange / SendProgramChangecontrollers and program selects
SendPitchBend / SendChannelPressure14-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:

InputBehavior
NoteOn with velocity 0dispatched as NoteOff, per convention
CC 64sustain on the tracker and the allocator: releases defer, pedal-up flushes
CC 120 / 123all sound off and all notes off clear every attached consumer
Cable pull, host sleep, bus resetnote state clears and gates drop
Event orderpreserved end to end, so chords allocate deterministically
Overloadqueues 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.