MIDI API
AOT-compatible, reflection-free MIDI I/O, file handling, and hardware clock. Available as a separate package: OwnAudioSharp.Midi
ThreadPriority.Highest.Namespaces
| Namespace | Contents |
|---|---|
OwnAudio.Midi.IO | MidiMessage, IMidiInputPort, IMidiOutputPort, MidiPortFactory |
OwnAudio.Midi.File | MidiFile, MidiTrack, MidiEvent, MidiFileReader, MidiFileWriter |
OwnAudio.Midi.Clock | MidiClock |
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. "/dev/midi1").
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. |
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 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);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) |
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 (only valid on tempo-change events). |
MIDI Clock
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 |
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
| Platform | I/O Backend | File R/W | Clock |
|---|---|---|---|
| Windows | WinMM (winmm.dll) | â | â |
| macOS | CoreMIDI framework | â | â |
| Linux | ALSA rawmidi (libasound) | â | â |
All platform code is selected at compile time via #if WINDOWS / MACOS / LINUX â no runtime reflection. The library is fully compatible with .NET Native AOT (IsAotCompatible=true, IsTrimmable=true). All P/Invoke uses [LibraryImport] (source-generated, AOT-safe).