Hermetic Modular

07/MIDI

MIDI

To receive USB-MIDI, poll the port in the main loop and process its queued events at the start of each audio block.

USB configuration

The front USB-C port exposes CDC serial for HostLink and USB-MIDI. The browser opens CDC through WebSerial; a DAW or other MIDI application uses the operating system's MIDI driver.

HostWebSerial and MIDI driversBrowserWebSerial → HostLink editorMIDI applicationCoreMIDI · WinMM · ALSAUSB connectionfront USB-CModuleclass compliantComposite USB deviceCDC functionHostLink · interfaces 0/1USB-MIDI functioninterfaces 2/3

hostlink::Host and DaisyUsbPort use usb::Device::Shared(). Either service can initialize first. To set a USB product name or VID/PID, call Configure before either service initializes. Conflicting explicit identities are rejected. Do not initialize Daisy's standalone USB device service or USB logger on this port.

Connect the module to a computer or another USB host. The front port operates as a USB peripheral and cannot host a USB keyboard. The template's Makefile compiles the MIDI transport sources. CMake projects link alchemy::midi_daisy for transport and alchemy::midi_clock for clock input.

USB input

DaisyUsbPort::Poll decodes received USB packets in the main loop. Its callback pushes each Event into an Ingress queue. The audio callback drains that queue and passes events to NotePart::Process. Return the queue's Push result so the port detects a full queue.

USB note input
#include "alchemy/midi.h"
#include "alchemy/midi/usb_transport.h"

using namespace alchemy;
using namespace alchemy::midi;

static DaisyUsbPort<> midi_port;
static Ingress<128, 1> audio_input;
static NotePart<1, 32> notes;
static TickTimebase foreground_time, audio_time;

bool InitMidi() {
    const auto frequency = daisy::System::GetTickFreq();
    if (!frequency) return false;
    const auto epoch = daisy::System::GetTick();
    foreground_time.Init(frequency, epoch);
    audio_time.Init(frequency, epoch);
    if (!usb::Device::Shared().Configure(
            daisy::UsbHandle::FS_EXTERNAL, "My Module")) return false;
    PartConfig config;
    config.listen = Listen::Omni();
    config.articulation = ArticulationPolicy::Legato();
    config.slide.trigger = SlideTrigger::KeyOverlap;
    return notes.Init(config) && midi_port.Init({}, {0, 1});
}

void PollMidi() {
    foreground_time.Update(daisy::System::GetTick());
    midi_port.Poll(foreground_time.GetSample(), [](const Event& e) {
        return audio_input.Push(e);
    });
}

template<class ActionSink>
void DrainMidi(ActionSink&& action_sink) {
    const auto now = audio_time.Update(daisy::System::GetTick());
    audio_input.DrainDue(now, [&](const Event& e) {
        notes.Process(e, action_sink);
    });
}

Call InitMidi() after hardware initialization and before starting audio; check its return value. Call PollMidi() from the main loop, including while settings are open. At the start of each audio block, call DrainMidi(action_sink). The action_sink callback receives a const NoteAction&; apply its fields as described below.

Update foreground_time only in the main loop and audio_time only in the audio callback. Initialize both from the same hardware tick and frequency so their timestamps share an epoch. Update each before the raw counter wraps. DrainDue consumes due events in queue order at the start of the block; it does not schedule events within the block.

SourceId{0, 1} identifies logical port 0, session 1. Give each input a port number. A replacement source uses a newer nonzero session so stale events can be rejected. Event channels are 0–15; Listen::Channel(1) selects channel 1. A default PartConfig listens to channel 1; Listen::Omni() accepts all channels.

Voice actions

notes.Process(e, action_sink) passes a NoteAction to the callback. For voice actions, use action.voice.slot to index the DSP voice. Apply Group actions to the shared paraphonic envelopes. Process the part and apply its actions in the audio callback.

Action or fieldDSP responsibility
Assign / RetargetAssign a voice or change its note. Apply pitch_transition on both actions.
pitch_semitonesAdd expression.TotalBend() before converting MIDI pitch to frequency.
attack_amp / attack_filterTrigger the two envelopes independently. A note selection does not necessarily retrigger either.
ReleaseLower the gate and render the release tail. Keep the voice's pitch history.
ExpressionUpdate bend, pressure, and other expression without restarting the voice.
KillStop the voice. Honor retain_pitch when its next assignment must continue a pitch trajectory.
GroupApply shared paraphonic gate, velocity, and envelope actions.

Retain the action's VoiceHandle, which contains a slot and generation. When that voice's release envelope ends, call notes.NotifyIdle(handle). The generation prevents an old release from freeing a newly assigned voice in the same slot. In paraphonic mode, also call NotifyGroupIdle(generation) when the shared release ends.

Voice allocation

The template arguments in NotePart<Voices, Notes, Channels, MpeSources> set its storage capacities. Voices is the number of DSP voice slots. Notes limits tracked note occurrences, including repeated presses of the same key; use more note slots than voices to track held keys after stealing. Channels limits simultaneous source/channel pairs. MpeSources limits MPE sources and defaults to zero.

Six polyphonic voices
static NotePart<6, 32> poly_notes;

bool InitPoly() {
    PartConfig config;
    config.mode = NoteMode::Poly;
    config.listen = Listen::Channel(1);
    config.allocation = Allocation::RoundRobinFree;
    config.steal = StealPolicy::ReleasedThenSustainedThenHeld;
    return poly_notes.Init(config);
}

NoteMode::Mono / Poly / Paraphonic / Off

Select one voice, independent voices, voices with a shared envelope group, or no note input. Applying Off kills sounding voices.

PartConfig::priority

Last, Low, or High note selection for mono operation. Falling back to a held note after Note Off does not count as a new attack.

PartConfig::duplicates

Fifo, Lifo, or RetriggerSingle determines which repeated Note On occurrence is released by a Note Off.

PartConfig::steal

ReleasedThenSustainedThenHeld, Oldest, Quietest, or None. Report voice level with ReportLevel when using Quietest.

PartConfig::articulation / slide

Choose amp and filter attack policies, onset, and pitch-slide behavior separately. Poly policies apply per voice; paraphonic shared policies apply to the group.

TrackedCount() / SoundingCount() / CapacityFailures()

Tracked notes, occupied voices (including release tails), and failed capacity requests. NotifyIdle frees a voice after its release ends.

Expression and MPE

NotePart tracks sustain, sostenuto, bend and pressure by source and channel. Note On with zero velocity is decoded as Note Off, with its origin retained. Set the initial bend range in PartConfig::bend_range; MIDI pitch-bend-sensitivity RPN messages can update it. Read each voice's expression from NoteAction::expression.

For MPE, use a nonzero MPE source capacity, such as NotePart<6, 32, 32, 1>, and set config.enable_mpe = true. The sender configures zones through manager-channel RPN 6. The part combines member and manager expression for each note. Configured zones bypass the channel mask but still respect the port filter. Enabling MPE with zero MPE source capacity fails validation.

To combine MIDI and CV, pass their pitch and gate readings to PitchGateArbiter::Process. Use the returned pitch, gate, attack_amp and attack_filter values in the DSP. PitchGateConfig selects the pitch source and gate policy.

Controller routing

ControllerDecoder decodes CC7, CC14, RPN, NRPN, bend and pressure. Routes maps these messages to parameter IDs. ParameterMixer combines a parameter's base value with MIDI modulation. The following route maps CC20 to an offset from -0.5 to +0.5.

Additive CC20 mapping
static ControllerDecoder<16> controllers;
static Routes<1> routes;
static ParameterMixer<1, 16> parameters;

bool InitControls() {
    Route route;
    route.id = route.target = 1;
    route.number = 20;
    route.listen = Listen::Omni();
    route.operation = RouteOp::AddOffset;
    route.minimum = -0.5f;
    route.maximum = 0.5f;
    route.bipolar = true;
    return parameters.Register({1, ParameterType::Continuous, 0, 1, 0.5f})
        && routes.Add(route) && routes.ValidateTargets(parameters);
}

void ProcessControls(const Event& e) {
    if (e.type == EventType::SystemReset) {
        controllers.Clear();
        parameters.ClearModulation();
    }
    controllers.Process(e, [](const ControllerMessage& message) {
        if (message.kind == ControllerKind::Disconnect ||
            message.kind == ControllerKind::Reset) {
            parameters.ResetSource(message.source, message.channel);
        } else {
            routes.Process(message, [](const ParameterCommand& command) {
                parameters.Apply(command);
            });
        }
    });
}

Call InitControls() before audio starts. For each drained event, call both ProcessControls(e) and notes.Process(e, action_sink). In the audio callback, set the panel's base value with parameters.SetBase(1, value) and read the combined value with parameters.Value(1).

For routes that edit the base, set Route::takeover to Immediate, Pickup or Scale. Resolve edits through TakeoverTracker using the current base and its revision. Clear takeover state and mixer modulation together on a source or controller reset. Use ControlEdits to return accepted base edits to the main loop; its configuration and base revisions reject edits superseded by a panel change.

To learn a mapping, arm LearnSession with a prototype route and LearnOptions. Pass decoded controller messages to Process and call Poll to check the timeout. Validate the captured Result() against registered parameter IDs before replacing the active routes. Reserved controllers require LearnOptions::advanced.

MIDI settings

Call MonoSettings::Attach during setup to add two consecutive pages to Settings. They provide panel controls, HostLink fields and stored values. Choose unused pages within indices 0–7.

Declare settings during startup
#include "alchemy/midi/mono_settings.h"

static midi::MonoSettings midi_settings;

bool AttachMidiSettings() {
    return midi_settings.Attach(settings, 1); // pages 1 and 2
}
PageControls
MIDI NotesChannel (Off, Omni, 1–16), note priority, VCA retrigger, filter retrigger, slide mode, slide time.
MIDI ControlPitch/gate source, bend range, velocity to level, velocity to accent, clock source, CC/panel merge.

Call midi_settings.Read() in the main loop to obtain MonoOptions. Transfer the options to the audio callback using SnapshotExchange, then call notes.ApplyConfig(options.Part(), action_sink). options.Merge() supplies the pitch/gate configuration. Apply the velocity-to-level and velocity-to-accent amounts in the DSP; use the clock-source setting to select external pulses, MIDI transport or MIDI tempo.

Configuration and presets

Use SnapshotExchange to transfer settings, learned routes and restored configuration from the main loop to audio. Publish a validated, trivially copyable value in the main loop. In the audio callback, acquire a lease and apply the value before processing MIDI events. Release the lease before returning. If Publish fails for a valid value, retry on a later main-loop iteration.

Publish a part configuration
static SnapshotExchange<PartConfig> pending_part;

// Foreground: retry later if this returns false and the config is valid.
bool PublishPart(const PartConfig& config) {
    return pending_part.Publish(config, [](const PartConfig& candidate) {
        return decltype(notes)::Validate(candidate);
    });
}

// Audio: call before draining events and rendering the block.
template<class ActionSink>
void ApplyPendingPart(ActionSink&& action_sink) {
    SnapshotExchange<PartConfig>::Lease lease;
    if (pending_part.Acquire(lease))
        notes.ApplyConfig(*lease, action_sink);
}

Register PartSerializable or RoutesSerializable<Capacity> with Presets::Manage to store part or route configuration. After a preset load, validate restored routes with ValidateTargets and transfer the restored configuration to audio as above. MonoSettings values are serialized by the managed Settings object.

MIDI clock

ClockAdapter applies MIDI clock and transport messages to MusicalClock through a ClockFollower. The clock's internal PPQN must be divisible by 24. Select the input with SelectSource and use the audio queue's microsecond epoch for Process and Poll.

MIDI clock input
#include "alchemy/midi/clock.h"

static MusicalClock master_clock;
static ClockFollower clock_follower(master_clock);
static ClockAdapter midi_clock(clock_follower, master_clock);

// In setup, before audio starts:
// midi_clock.SelectSource(midi_port.Source());

void DrainMidiWithClock() {
    const auto now = audio_time.Update(daisy::System::GetTick());
    audio_input.DrainDue(now, [&](const Event& e) {
        if (e.source.port == 0 && e.source != midi_clock.Source())
            midi_clock.SelectSource(e.source);
        midi_clock.Process(e, now);
        notes.Process(e, [](const NoteAction& action) {
            // Apply action to your DSP here.
        });
    });
    midi_clock.Poll(now);
}

Use this drain function in place of DrainMidi. Call midi_clock.Poll(now) every audio block, including blocks without MIDI events. It calls MusicalClock::Tick; remove any other tick of master_clock. Keep all access to that clock on the audio thread.

TransportPolicy::Follow handles Start, Continue, Stop and Song Position. Start and Continue take effect on the next timing pulse; Start resets position. Use SetTransportPolicy(TransportPolicy::ClockOnly) to follow tempo while ignoring transport commands. SetLossPolicy(ClockLossPolicy::Freeze) holds position when clock is lost; Freewheel continues at the last tempo. A source reset stops transport.

If ParamLock reads the clock in the main loop, process MIDI clock events and call ClockAdapter::Poll in that loop instead of the audio callback. Continue queuing note and controller events to audio.

MIDI output and SysEx

Handle program changes, realtime and system messages directly as Event values. For incoming USB SysEx, register SetSysExSink. Its callbacks run in the main loop with Begin, Data, End or Abort phases. Copy received data into a fixed-capacity application buffer if parsing requires further work.

Output queues messages from a single producer, which may be the audio callback. Use Send, Schedule or SendBundle to queue messages, then call Output::Poll(now, budget) in the main loop to transmit them. Call direct transport sends and DaisyUsbPort::WriteSysEx only from the main loop.

TxResult::Accepted confirms queue admission. Busy and Error retain the queued group for a later poll; Invalid or Unsupported cancels it. WriteSysEx returns an accepted byte count: advance by that count and retry the remainder on a later poll. After cancellation, start a new SysEx stream with F0. USB sends must not block the audio callback.

Input queues and disconnects

For multiple ports or event subscribers, feed each port into Dispatcher::Accept and register the audio queue as a critical sink. Call Dispatcher::Poll after servicing the ports. Before polling a port, check dispatcher.Failed(port.Source()) and call port.MarkLost() if it is true. The dispatcher delivers the old source's reset before its replacement's events. Return each queue admission result to the caller so a full queue triggers source cleanup.

DaisyUartPort receives UART MIDI at 31,250 baud. Supply UART pins, a noncached DMA buffer and a UartMidiResources token. The stock UART DMA receiver supports one owner at a time. A DIN input requires external interface circuitry; see the expansion header for pins and electrical limits.

Process source resets in NotePart, and clear controller modulation and takeover state for that source. On a part-wide System Reset, also clear application modulation for the part.

The USB transport checks bus state during polling and USB I/O. Observed suspension retires pending transfers and source state; an already-started transfer may complete. The HAL does not latch a suspend/resume cycle completed between those checks, so a brief suspension may not generate a source reset.

API reference

MIDI integration examples · MIDI API headers