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

๐ŸŽน
MIDI I/O
Real-time input/output via WinMM, CoreMIDI, or ALSA rawmidi. Full SysEx receive with zero-allocation state-machine parser.
๐Ÿ”Œ
Virtual Ports
Create software MIDI endpoints on macOS (CoreMIDI) and Linux (ALSA Sequencer) for inter-app routing.
๐Ÿ“„
MIDI File
Read, edit, and write Standard MIDI Files (SMF format 0 and 1).
๐Ÿ•
MIDI Clock
Thread-based 24 PPQN clock and audio-engine-driven sample-accurate AudioEngineMidiClock.

Namespaces

NamespaceContents
OwnAudio.Midi.IOMidiMessage, IMidiInputPort, IMidiOutputPort, SysExReceivedHandler, MidiPortFactory
OwnAudio.Midi.FileMidiFile, MidiTrack, MidiEvent, MidiFileReader, MidiFileWriter
OwnAudio.Midi.ClockMidiClock, AudioEngineMidiClock

Port Management

Use MidiPortFactory to enumerate devices and open ports. Ports are IDisposable โ€” always wrap in using.

C# โ€” List available ports
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}");
C# โ€” Open a port
// 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.

C#
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

PropertyTypeDescription
StatusbyteRaw status byte.
Data1byteFirst data byte (note number, CC number, โ€ฆ).
Data2byteSecond data byte (velocity, CC value, โ€ฆ).
TimestamplongNanoseconds since port open (platform-dependent precision).
TypeMidiMessageTypeEnum: NoteOn, NoteOff, ControlChange, โ€ฆ
ChannelintMIDI channel 0โ€“15.
IsNoteOnbooltrue if NoteOn with velocity > 0.
IsNoteOffbooltrue if NoteOff, or NoteOn with velocity = 0.
IsControlChangeboolCC message.
IsPitchBendboolPitch bend message.
IsProgramChangeboolProgram 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.

C#
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();
}
PlatformImplementationMax SysEx size
Windows (WinMM)Four 4 KB unmanaged MIDIHDR buffers, rotated automatically4 KB per buffer (multiple buffers chain)
macOS (CoreMIDI)64 KB per-port accumulation buffer; handles cross-packet fragmentation64 KB
Linux (ALSA rawmidi)Byte-level state machine with running-status support; 64 KB accumulation buffer64 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

C#
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.

C# โ€” macOS timestamped send
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

MessageStatus byteData1Data2
Note Off0x80 | chnote (0โ€“127)velocity
Note On0x90 | chnote (0โ€“127)velocity
Aftertouch0xA0 | chnotepressure
Control Change0xB0 | chCC numbervalue
Program Change0xC0 | chprogramโ€”
Channel Pressure0xD0 | chpressureโ€”
Pitch Bend0xE0 | chLSB (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.

C# โ€” Create virtual ports
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
PlatformBackendVisibility
macOSMIDIDestinationCreate / MIDISourceCreate (CoreMIDI)System-wide; appears in all CoreMIDI clients and DAWs
LinuxALSA 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.

C#
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}");
}
PlatformMechanismOverhead
macOSCoreMIDI client notification callbackZero polling โ€” event-driven
LinuxFileSystemWatcher on /dev/snd watching midi* nodesMinimal โ€” kernel inotify
WindowsNot 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

C# โ€” Reading a 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);
C# โ€” Editing (transpose all notes up 2 semitones)
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");
C# โ€” Writing from scratch (C major scale)
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

PropertyTypeDescription
DeltaTimeintTicks since previous event.
TypeMidiEventTypeMidi, Meta, or SysEx.
StatusbyteStatus byte (Midi events) or meta type (Meta events).
Data1byteFirst data byte.
Data2byteSecond data byte.
MetaDatabyte[]?Raw payload for Meta and SysEx events.
IsTempoChangebooltrue for Meta type 0x51 (Set Tempo).
IsEndOfTrackbooltrue for Meta type 0x2F (End of Track).
GetTempoMicroseconds()intMicroseconds 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

C# โ€” Internal clock (sequencer use)
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();
C# โ€” Clock with hardware output port
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 resumes

Automatic Clock Messages

EventMIDI MessageByte
Start()MIDI Start0xFA
Stop()MIDI Stop0xFC
Continue()MIDI Continue0xFB
Every pulse (24/beat)MIDI Timing Clock0xF8

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.

C# โ€” AudioEngineMidiClock
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

MidiClockAudioEngineMidiClock
Driving mechanismDedicated OS thread (Highest priority)Audio render callback
JitterOS thread scheduling (~100 ยตs typical)Sample-accurate (< 1 sample)
CPU overheadSpin-wait loopFractional counter per block
Start / Stop messagesโœ… Automatic via output portManual โ€” send manually if needed
Best forStandalone clock, general purposeDAW integration, audio engine sync

Full Example โ€” MIDI Keyboard Recorder

Records incoming MIDI events with timestamps and saves to a Standard MIDI File.

C#
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

FeatureWindowsmacOSLinux
Physical I/OWinMM (winmm.dll)CoreMIDIALSA 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.