Skip to content

MIDI

IMIDIManager provides basic MIDI input and output through AppContext. It is available when SNOWPULSE_HAS_MIDI=1; the web PLAYABLE_AD profile compiles the service out.

Supported v1 messages are channel voice messages:

  • note off / note on
  • polyphonic pressure
  • control change
  • program change
  • channel pressure
  • pitch bend

SysEx and MIDI system messages are intentionally rejected.

Access

Web builds must request MIDI access from game code, ideally from a user gesture such as a button click. App::init() does not request access automatically.

auto* midi = context()->midiManager();
if (midi) {
    midi->requestAccess([](snowpulse::MIDIAccessStatus status) {
        if (status == snowpulse::MIDIAccessStatus::Granted) {
            // MIDI is ready.
        }
    });
}

On web, MIDI requires browser WebMIDI support and a secure context (https:// or localhost). The engine requests navigator.requestMIDIAccess({ sysex: false }).

On macOS desktop, Snowpulse uses CoreMIDI. On Windows desktop, iOS, and Android, the default manager reports MIDIAccessStatus::Unavailable.

The full platform matrix is:

Platform Backend
macOS desktop CoreMIDI input/output and device notifications
Windows desktop Unavailable manager
Web (FULL) Web MIDI API with sysex: false
Web (PLAYABLE_AD) Compiled out
iOS Unavailable manager
Android Unavailable manager

Input

Register onMessage() before or after access. Incoming messages are queued by the platform backend and dispatched during IMIDIManager::update(), which App calls once per frame.

auto* midi = context()->midiManager();
midi->onMessage([](const snowpulse::MIDIMessage& message) {
    if (message.type() == snowpulse::MIDIMessageType::NoteOn) {
        const int channel = message.channel(); // zero-based, 0..15
        const int note = message.data1();
        const int velocity = message.data2();
        (void)channel;
        (void)note;
        (void)velocity;
    }
});

MIDIMessage::bytes stores the raw MIDI bytes. Invalid, incomplete, system, and SysEx messages are not dispatched. deviceId identifies the source input and timestampSeconds carries the platform timestamp when one is available.

Immediate Input

onMessageImmediate() bypasses the per-frame queue for latency-sensitive work such as triggering a sound from a drum controller:

midi->onMessageImmediate([audio](const snowpulse::MIDIMessage& message) {
    if (message.type() == snowpulse::MIDIMessageType::NoteOn) {
        // Keep this callback thread-safe. Do not touch Scene, Node, or UI state.
        audio->play("audio/hit.wav");
    }
});

On CoreMIDI this callback runs on the platform MIDI callback thread. The handler must be thread-safe and must not access the scene graph, UI, or other main-thread-only state. Use normal onMessage() for gameplay changes. The web backend invokes the immediate path from its browser callback before queueing the same message for normal per-frame delivery.

Devices

Use inputs() and outputs() after access to inspect connected devices. Register onDevicesChanged() to handle browser statechange events or CoreMIDI device changes.

midi->onDevicesChanged([](const std::vector<snowpulse::MIDIDeviceInfo>& inputs,
                          const std::vector<snowpulse::MIDIDeviceInfo>& outputs) {
    printf("MIDI inputs: %zu, outputs: %zu\n", inputs.size(), outputs.size());
});

Each MIDIDeviceInfo includes an id, name, manufacturer, and connected flag.

Output

Send a valid basic MIDI message to a connected output id.

snowpulse::MIDIMessage noteOn;
noteOn.bytes = { 0x90, 60, 100 }; // note on, channel 0, middle C
midi->send(midi->outputs().front().id, noteOn);

send() returns false if access is not granted, the output id is missing, or the message is not a valid supported channel voice message.