loading
Warning:
Chromium only, over HTTPS Web MIDI ships in Chromium-based browsers. Firefox implements it behind a preference, and Safari not at all. Everything below returns nothing useful until RequestAccess has resolved - the port list is part of the grant.
Note:
Sysex is a separate, stricter permission Asking for system-exclusive access shows a different prompt, because sysex can rewrite a device's firmware. Leave it off unless the app really does send or read sysex.

Support check

IsSupported

True when the runtime exposes navigator.requestMIDIAccess. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.

C#
@inject Bit.Butil.Midi midi

var supported = await midi.IsSupported();
Live sample
support output
Results will appear here when you interact with the samples.

Request access

RequestAccess / GetPorts

RequestAccess prompts the user and returns the ports the grant covers. The resolved access is cached for the page, so calling it again is cheap and does not re-prompt; GetPorts re-reads the same list.

C#
var access = await midi.RequestAccess(sysex: false);
foreach (var input in access!.Inputs)  { /* input.Id, input.Name */ }
foreach (var output in access.Outputs) { /* output.Id, output.Name */ }

var current = await midi.GetPorts();
Live sample
access output
Results will appear here when you interact with the samples.

Listen to a controller

SubscribeMessages

Subscribes to one input, or - with no id - to every input at once, which is usually what an app wants since the user's controller is whichever one they touch.

C#
await using var messages = await midi.SubscribeMessages(message =>
{
    // message.Data[0] is the status byte: 0x90|channel is note-on
    // message.TimeStamp is on the performance.now() clock
});
Live sample
message output
Results will appear here when you interact with the samples.

Send notes

Send / SendNoteOn / SendNoteOff / Clear

SendNoteOn and SendNoteOff are the two messages every synth understands; Send takes raw bytes for everything else. Clear drops whatever is still queued - the way out of a note left hanging.

C#
await midi.SendNoteOn(outputId, note: 60, velocity: 100);
await midi.SendNoteOff(outputId, note: 60);

// raw: program change to patch 5 on channel 1
await midi.Send(outputId, [0xC0, 0x05]);

await midi.Clear(outputId);
Live sample
Note (60 = middle C)
Velocity
Channel
send output
Results will appear here when you interact with the samples.

Ports coming and going

SubscribeStateChange

Fires when a port is connected or disconnected. A port that has been unplugged stays in the list as disconnected, so a subscription survives the cable being re-seated.

C#
await using var watch = await midi.SubscribeStateChange(port =>
{
    // port?.Name, port?.State, port?.Connection
});
Live sample
state change output
Results will appear here when you interact with the samples.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes navigator.requestMIDIAccess. Returns default (false) during prerender/SSR instead of throwing.
RequestAccess
ValueTask<MidiAccessInfo?> RequestAccess(bool sysex = false, bool software = false)
Prompts for MIDI access and returns the ports it covers, or null when refused. The access is cached for the page.
GetPorts
ValueTask<MidiAccessInfo?> GetPorts()
The current port list. Null until RequestAccess has resolved.
Send
ValueTask<bool> Send(string outputId, byte[] data, double? timestamp = null)
Sends raw MIDI bytes, opening the port if needed. The timestamp is on the performance.now() clock.
SendNoteOn / SendNoteOff
ValueTask<bool> SendNoteOn(string outputId, byte note, byte velocity = 100, byte channel = 0) / SendNoteOff(string outputId, byte note, byte velocity = 0, byte channel = 0)
The two messages every synth understands.
Clear
ValueTask<bool> Clear(string outputId)
Drops every message queued for the port that has not been sent yet.
SubscribeMessages
ValueTask<ButilSubscription> SubscribeMessages(Action<MidiMessage> handler, string? inputId = null)
Listens to one input, or to every input when inputId is null.
SubscribeStateChange
ValueTask<ButilSubscription> SubscribeStateChange(Action<MidiPortInfo?> handler)
Watches ports being connected and disconnected.
DisposeAsync
ValueTask DisposeAsync()
Detaches every listener this instance attached and releases the JS callback reference.
InvokeMidiMessage
void InvokeMidiMessage(Guid id, MidiMessage message)
JSInvokable interop plumbing for incoming messages - not intended for app code.
InvokeMidiStateChange
void InvokeMidiStateChange(Guid id, MidiPortInfo? port)
JSInvokable interop plumbing for port state changes - not intended for app code.
MidiAccessInfo
class MidiAccessInfo { bool SysexEnabled; MidiPortInfo[] Inputs; MidiPortInfo[] Outputs; }
The granted ports, and whether sysex is included.
MidiPortInfo
class MidiPortInfo { string Id; string? Name; string? Manufacturer; string? Version; string Type; string State; string Connection; }
One input or output port.
MidiMessage
class MidiMessage { string PortId; byte[] Data; double TimeStamp; }
One received message: the port, the raw bytes and the arrival time.
An unhandled error has occurred. Reload 🗙