MIDI API
AOT-compatible, reflection-free MIDI I/O, SysEx, virtual ports, hot-plug monitoring, file handling, and sample-accurate clocks. Available as a separate package: OwnAudioSharp.Midi
AudioEngineMidiClock.Namespaces
| Namespace | Contents |
|---|---|
OwnAudio.Midi.IO | MidiMessage, IMidiInputPort, IMidiOutputPort, SysExReceivedHandler, MidiPortFactory |
OwnAudio.Midi.File | MidiFile, MidiTrack, MidiEvent, MidiFileReader, MidiFileWriter |
OwnAudio.Midi.Clock | MidiClock, AudioEngineMidiClock |
Port Management
Use MidiPortFactory to enumerate devices and open ports. Ports are IDisposable โ always wrap in using.
using OwnAudio.Midi.IO;
IReadOnlyList<string> inputs = MidiPortFactory.GetInputPortNames();
IReadOnlyList<string> outputs = MidiPortFactory.GetOutputPortNames();
foreach (var name in inputs)
Console.WriteLine($"[IN] {name}");
foreach (var name in outputs)
Console.WriteLine($"[OUT] {name}");// Open by index
using IMidiInputPort input = MidiPortFactory.OpenInput(inputs[0]);
// Open by name
using IMidiOutputPort output = MidiPortFactory.OpenOutput("IAC Driver Bus 1");Platform port names: Windows uses WinMM names (e.g. "Microsoft GS Wavetable Synth"), macOS uses CoreMIDI (e.g. "IAC Driver Bus 1"), Linux uses ALSA rawmidi paths (e.g. "hw:1,0").
Receiving MIDI Data
Subscribe to MessageReceived before calling Start(). The callback fires on a background thread โ marshal to your UI thread if needed.
using IMidiInputPort input = MidiPortFactory.OpenInput("USB MIDI Interface");
input.MessageReceived += OnMidiMessage;
input.Start();
Console.WriteLine("Listening... press Enter to stop.");
Console.ReadLine();
input.Stop();
input.MessageReceived -= OnMidiMessage;
void OnMidiMessage(MidiMessage msg)
{
if (msg.IsNoteOn)
Console.WriteLine($"Note ON โ pitch={msg.Data1} vel={msg.Data2} ch={msg.Channel}");
else if (msg.IsNoteOff)
Console.WriteLine($"Note OFF โ pitch={msg.Data1} ch={msg.Channel}");
else if (msg.IsControlChange)
Console.WriteLine($"CC #{msg.Data1} = {msg.Data2} ch={msg.Channel}");
else if (msg.IsPitchBend)
{
int bend = (msg.Data2 << 7) | msg.Data1; // 0..16383, centre = 8192
Console.WriteLine($"Pitch Bend = {bend} ch={msg.Channel}");
}
}MidiMessage Properties
| Property | Type | Description |
|---|---|---|
Status | byte | Raw status byte. |
Data1 | byte | First data byte (note number, CC number, โฆ). |
Data2 | byte | Second data byte (velocity, CC value, โฆ). |
Timestamp | long | Nanoseconds since port open (platform-dependent precision). |
Type | MidiMessageType | Enum: NoteOn, NoteOff, ControlChange, โฆ |
Channel | int | MIDI channel 0โ15. |
IsNoteOn | bool | true if NoteOn with velocity > 0. |
IsNoteOff | bool | true if NoteOff, or NoteOn with velocity = 0. |
IsControlChange | bool | CC message. |
IsPitchBend | bool | Pitch bend message. |
IsProgramChange | bool | Program change message. |
Receiving SysEx Data
IMidiInputPort exposes a dedicated SysExReceived event that delivers a complete 0xF0 โฆ 0xF7 frame as a ReadOnlySpan<byte>. The span is only valid during the callback โ copy it if you need to retain the data. The parser handles SysEx frames that arrive fragmented across multiple driver read calls or CoreMIDI packets.
using OwnAudio.Midi.IO;
using IMidiInputPort input = MidiPortFactory.OpenInput("USB MIDI Interface");
input.SysExReceived += OnSysEx;
input.Start();
Console.ReadLine();
input.Stop();
void OnSysEx(ReadOnlySpan<byte> data)
{
// data[0] == 0xF0, data[^1] == 0xF7
Console.Write($"SysEx ({data.Length} bytes):");
foreach (byte b in data)
Console.Write($" {b:X2}");
Console.WriteLine();
// Copy if retention beyond this callback is needed
byte[] copy = data.ToArray();
}| Platform | Implementation | Max SysEx size |
|---|---|---|
| Windows (WinMM) | Four 4 KB unmanaged MIDIHDR buffers, rotated automatically | 4 KB per buffer (multiple buffers chain) |
| macOS (CoreMIDI) | 64 KB per-port accumulation buffer; handles cross-packet fragmentation | 64 KB |
| Linux (ALSA rawmidi) | Byte-level state machine with running-status support; 64 KB accumulation buffer | 64 KB |
SysExReceived uses the custom SysExReceivedHandler delegate type (delegate void SysExReceivedHandler(ReadOnlySpan<byte> data)) because Action<ReadOnlySpan<byte>> is not directly usable as an event type. The callback is allocation-free in the hot path.
Sending MIDI Data
using IMidiOutputPort output = MidiPortFactory.OpenOutput("USB MIDI Interface");
// Note On / Note Off (status = 0x90 | channel)
output.Send(new MidiMessage(0x90, 60, 100)); // Middle C, velocity 100
await Task.Delay(500);
output.Send(new MidiMessage(0x80, 60, 0)); // Note Off
// Control Change (status = 0xB0 | channel)
output.Send(new MidiMessage(0xB0, 7, 100)); // Volume (CC #7) = 100
// Program Change (status = 0xC0 | channel)
output.Send(new MidiMessage(0xC0, 0, 0)); // Grand Piano
// Pitch Bend โ 14-bit value split across LSB/MSB
int bend = 10000; // 0..16383, centre = 8192
output.Send(new MidiMessage(0xE0, (byte)(bend & 0x7F), (byte)(bend >> 7)));
// SysEx
ReadOnlySpan<byte> sysex = [0xF0, 0x41, 0x10, 0x42, 0x12, 0xF7];
output.SendSysEx(sysex);Timestamped Send (macOS only)
On macOS, MacOsMidiOutputPort exposes an additional overload that accepts an absolute nanosecond timestamp. CoreMIDI schedules the message at exactly that Mach time, eliminating OS scheduling jitter for sample-accurate playback. Pass 0 for immediate delivery.
using OwnAudio.Midi.IO.Platform;
if (output is MacOsMidiOutputPort macOutput)
{
long nowNs = /* your nanosecond clock source */;
macOutput.Send(new MidiMessage(0x90, 60, 100), nowNs + 10_000_000); // 10 ms ahead
}Common Status Bytes
| Message | Status byte | Data1 | Data2 |
|---|---|---|---|
| Note Off | 0x80 | ch | note (0โ127) | velocity |
| Note On | 0x90 | ch | note (0โ127) | velocity |
| Aftertouch | 0xA0 | ch | note | pressure |
| Control Change | 0xB0 | ch | CC number | value |
| Program Change | 0xC0 | ch | program | โ |
| Channel Pressure | 0xD0 | ch | pressure | โ |
| Pitch Bend | 0xE0 | ch | LSB (7-bit) | MSB (7-bit) |
Virtual MIDI Ports
Virtual ports create software MIDI endpoints that other applications can connect to โ useful for inter-app MIDI routing, DAW integration, and loopback testing. Use MidiPortFactory.CreateVirtualInput and CreateVirtualOutput.
using OwnAudio.Midi.IO;
// Virtual input โ external apps send to "MyApp In", we receive
using IMidiInputPort virtualIn = MidiPortFactory.CreateVirtualInput("MyApp In");
virtualIn.MessageReceived += msg => Console.WriteLine($"Virtual IN: {msg}");
virtualIn.SysExReceived += data => Console.WriteLine($"SysEx {data.Length} bytes");
virtualIn.Start();
// Virtual output โ we send, external apps receive
using IMidiOutputPort virtualOut = MidiPortFactory.CreateVirtualOutput("MyApp Out");
virtualOut.Send(new MidiMessage(0x90, 60, 100));
virtualOut.SendSysEx([0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7]); // MIDI Identity Request| Platform | Backend | Visibility |
|---|---|---|
| macOS | MIDIDestinationCreate / MIDISourceCreate (CoreMIDI) | System-wide; appears in all CoreMIDI clients and DAWs |
| Linux | ALSA Sequencer (snd_seq_t) | Visible as sequencer clients; connect with aconnect -l |
| Windows | โ | Not supported (WinMM has no virtual port API) |
Hot-Plug Device Monitoring
Subscribe to MidiPortFactory.PortsChanged to be notified when MIDI devices are connected or disconnected. Call StartMonitoring() once before subscribing.
using OwnAudio.Midi.IO;
MidiPortFactory.PortsChanged += OnPortsChanged;
MidiPortFactory.StartMonitoring();
Console.WriteLine("Monitoring device changes. Press Enter to stop.");
Console.ReadLine();
MidiPortFactory.StopMonitoring();
MidiPortFactory.PortsChanged -= OnPortsChanged;
void OnPortsChanged()
{
IReadOnlyList<string> current = MidiPortFactory.GetInputPortNames();
Console.WriteLine($"Devices changed โ {current.Count} input(s) available:");
foreach (var name in current)
Console.WriteLine($" {name}");
}| Platform | Mechanism | Overhead |
|---|---|---|
| macOS | CoreMIDI client notification callback | Zero polling โ event-driven |
| Linux | FileSystemWatcher on /dev/snd watching midi* nodes | Minimal โ kernel inotify |
| Windows | Not yet implemented | โ |
MIDI File
MidiFileReader parses Standard MIDI Files (SMF format 0 & 1). MidiFile, MidiTrack, and MidiEvent are immutable โ edit by rebuilding the event list. Namespace: OwnAudio.Midi.File
using OwnAudio.Midi.File;
MidiFile file = MidiFileReader.Read("song.mid");
Console.WriteLine($"Format: {file.Format} Ticks/beat: {file.TicksPerBeat}");
Console.WriteLine($"Tracks: {file.Tracks.Count}");
foreach (MidiTrack track in file.Tracks)
{
long absoluteTick = 0;
foreach (MidiEvent evt in track.Events)
{
absoluteTick += evt.DeltaTime;
if (evt.Type == MidiEventType.Midi)
{
byte msgType = (byte)(evt.Status & 0xF0);
int channel = evt.Status & 0x0F;
if (msgType == 0x90 && evt.Data2 > 0)
Console.WriteLine($"t={absoluteTick,8} NoteOn ch={channel} note={evt.Data1} vel={evt.Data2}");
}
else if (evt.Type == MidiEventType.Meta && evt.IsTempoChange)
{
double bpm = 60_000_000.0 / evt.GetTempoMicroseconds();
Console.WriteLine($"t={absoluteTick,8} Tempo {bpm:F1} BPM");
}
}
}
// Convert ticks โ seconds (tempo-dependent)
int tempoUs = 500_000; // default 120 BPM
double TicksToSeconds(long ticks) =>
ticks * tempoUs / (1_000_000.0 * file.TicksPerBeat);MidiFile original = MidiFileReader.Read("song.mid");
var editedEvents = new List<MidiEvent>();
foreach (MidiEvent evt in original.Tracks[0].Events)
{
if (evt.Type == MidiEventType.Midi)
{
byte msgType = (byte)(evt.Status & 0xF0);
if (msgType == 0x90 || msgType == 0x80)
{
byte newNote = (byte)Math.Clamp(evt.Data1 + 2, 0, 127);
editedEvents.Add(new MidiEvent(evt.DeltaTime, evt.Status, newNote, evt.Data2));
continue;
}
}
editedEvents.Add(evt);
}
var editedFile = new MidiFile(
original.Format,
original.TicksPerBeat,
[new MidiTrack(editedEvents)]);
MidiFileWriter.Write(editedFile, "song_transposed.mid");const ushort TicksPerBeat = 480;
const int Bpm = 120;
int tempoUs = 60_000_000 / Bpm;
var events = new List<MidiEvent>();
byte[] tempoBytes = [(byte)(tempoUs >> 16), (byte)(tempoUs >> 8), (byte)tempoUs];
events.Add(new MidiEvent(0, 0x51, tempoBytes)); // Tempo meta event
events.Add(new MidiEvent(0, 0xC0, 0, 0)); // Program Change โ piano
int[] scale = [60, 62, 64, 65, 67, 69, 71, 72]; // C D E F G A B C
long cursor = 0;
long T(double beats) => (long)(beats * TicksPerBeat);
for (int i = 0; i < scale.Length; i++)
{
int deltaOn = (int)(T(i) - cursor); cursor = T(i);
int deltaOff = (int)(T(i + 0.9) - cursor); cursor = T(i + 0.9);
events.Add(new MidiEvent(deltaOn, 0x90, (byte)scale[i], 80));
events.Add(new MidiEvent(deltaOff, 0x80, (byte)scale[i], 0));
}
// End of Track is appended automatically by MidiFileWriter
var midiFile = new MidiFile(0, TicksPerBeat, [new MidiTrack(events)]);
MidiFileWriter.Write(midiFile, "c_major_scale.mid");MidiEvent Properties
| Property | Type | Description |
|---|---|---|
DeltaTime | int | Ticks since previous event. |
Type | MidiEventType | Midi, Meta, or SysEx. |
Status | byte | Status byte (Midi events) or meta type (Meta events). |
Data1 | byte | First data byte. |
Data2 | byte | Second data byte. |
MetaData | byte[]? | Raw payload for Meta and SysEx events. |
IsTempoChange | bool | true for Meta type 0x51 (Set Tempo). |
IsEndOfTrack | bool | true for Meta type 0x2F (End of Track). |
GetTempoMicroseconds() | int | Microseconds per beat (valid on tempo-change events only). |
MIDI Clock โ Thread-Based
MidiClock sends MIDI Timing Clock pulses (0xF8) at 24 PPQN. The clock thread runs at ThreadPriority.Highest using a spin-wait loop for microsecond accuracy. Namespace: OwnAudio.Midi.Clock
using OwnAudio.Midi.Clock;
using var clock = new MidiClock(bpm: 120.0);
clock.Start();
await Task.Delay(4000);
clock.Bpm = 140.0; // change tempo while running
await Task.Delay(4000);
clock.Stop();using OwnAudio.Midi.IO;
using OwnAudio.Midi.Clock;
using IMidiOutputPort output = MidiPortFactory.OpenOutput("USB MIDI Interface");
using var clock = new MidiClock(bpm: 120.0, outputPort: output);
clock.Start(); // sends 0xFA (MIDI Start) then 0xF8 clock pulses
// ...
clock.Stop(); // sends 0xFC (MIDI Stop)
clock.Continue(); // sends 0xFB (MIDI Continue) and resumesAutomatic Clock Messages
| Event | MIDI Message | Byte |
|---|---|---|
Start() | MIDI Start | 0xFA |
Stop() | MIDI Stop | 0xFC |
Continue() | MIDI Continue | 0xFB |
| Every pulse (24/beat) | MIDI Timing Clock | 0xF8 |
MIDI Clock โ Audio-Engine-Driven
AudioEngineMidiClock derives 24 PPQN timing pulses directly from the audio render loop instead of a dedicated OS thread. This eliminates OS scheduling jitter entirely โ each 0xF8 pulse is emitted at the exact sample boundary where it belongs. Call UpdateTempo() whenever the BPM or sample rate changes, then call ProcessAudioBlock() from your audio render callback on every block.
using OwnAudio.Midi.Clock;
using OwnAudio.Midi.IO;
using IMidiOutputPort output = MidiPortFactory.OpenOutput("USB MIDI Interface");
var audioClock = new AudioEngineMidiClock();
audioClock.UpdateTempo(bpm: 120.0, sampleRate: 48000);
// Called by your audio engine for every render block:
void OnAudioBlock(int blockSize)
{
// Sends 0xF8 pulses at sample-accurate positions; zero allocation
audioClock.ProcessAudioBlock(blockSize, output);
}
// Change tempo at any time:
audioClock.UpdateTempo(bpm: 140.0, sampleRate: 48000);How it works: At 120 BPM / 48 000 Hz the clock fires every 1 000 samples (48 000 รท (120 ร 24 รท 60)). A fractional accumulator tracks sub-sample remainder across blocks so tempo accuracy is maintained regardless of block size.
MidiClock vs AudioEngineMidiClock
MidiClock | AudioEngineMidiClock | |
|---|---|---|
| Driving mechanism | Dedicated OS thread (Highest priority) | Audio render callback |
| Jitter | OS thread scheduling (~100 ยตs typical) | Sample-accurate (< 1 sample) |
| CPU overhead | Spin-wait loop | Fractional counter per block |
| Start / Stop messages | โ Automatic via output port | Manual โ send manually if needed |
| Best for | Standalone clock, general purpose | DAW integration, audio engine sync |
Full Example โ MIDI Keyboard Recorder
Records incoming MIDI events with timestamps and saves to a Standard MIDI File.
using OwnAudio.Midi.IO;
using OwnAudio.Midi.File;
var recordedEvents = new List<(long absoluteTick, MidiEvent evt)>();
var startTime = DateTimeOffset.UtcNow;
const ushort TicksPerBeat = 480;
const int Bpm = 120;
int tempoUs = 60_000_000 / Bpm;
double ticksPerMs = TicksPerBeat * Bpm / 60_000.0;
using IMidiInputPort input = MidiPortFactory.OpenInput(
MidiPortFactory.GetInputPortNames()[0]);
input.MessageReceived += msg =>
{
long ms = (long)(DateTimeOffset.UtcNow - startTime).TotalMilliseconds;
long tick = (long)(ms * ticksPerMs);
recordedEvents.Add((tick, new MidiEvent(0, msg.Status, msg.Data1, msg.Data2)));
};
input.Start();
Console.WriteLine("Recording... press Enter to stop.");
Console.ReadLine();
input.Stop();
// Convert absolute ticks to delta times
recordedEvents.Sort((a, b) => a.absoluteTick.CompareTo(b.absoluteTick));
var midiEvents = new List<MidiEvent>();
byte[] tempoBytes = [(byte)(tempoUs >> 16), (byte)(tempoUs >> 8), (byte)tempoUs];
midiEvents.Add(new MidiEvent(0, 0x51, tempoBytes));
long prev = 0;
foreach (var (tick, evt) in recordedEvents)
{
int delta = (int)(tick - prev);
prev = tick;
midiEvents.Add(new MidiEvent(delta, evt.Status, evt.Data1, evt.Data2));
}
var midiFile = new MidiFile(0, TicksPerBeat, [new MidiTrack(midiEvents)]);
MidiFileWriter.Write(midiFile, "recording.mid");
Console.WriteLine("Saved: recording.mid");Platform Support
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| Physical I/O | WinMM (winmm.dll) | CoreMIDI | ALSA rawmidi (libasound) |
| SysEx receive | โ MIDIHDR buffers | โ CoreMIDI packets | โ State-machine parser |
| Virtual ports | โ | โ CoreMIDI | โ ALSA Sequencer |
| Hot-plug detection | โ | โ CoreMIDI notify | โ FileSystemWatcher |
| Timestamped send | โ | โ Mach Absolute Time | โ |
| MIDI File R/W | โ | โ | โ |
MidiClock | โ | โ | โ |
AudioEngineMidiClock | โ | โ | โ |
Platform selection happens at runtime via RuntimeInformation.IsOSPlatform โ no compile-time defines are required in application code. The library is fully compatible with .NET Native AOT (IsAotCompatible=true, IsTrimmable=true). All P/Invoke uses [LibraryImport] (source-generated, AOT-safe); struct sizes in native calls use sizeof(T) rather than Marshal.SizeOf<T>() for zero-reflection hot paths.