midi-harbor/crates/core/src/midi.rs
2026-09-28 13:59:10 -05:00

651 lines
22 KiB
Rust

//! MIDI 1.0 message semantics.
//!
//! Shared by routing, note silencing, the recovery journal and the message monitor, because all
//! four need to know what a run of bytes means rather than just how long it is.
/// Highest value a data byte may carry. Anything above has the status bit set.
pub const MAX_DATA: u8 = 0x7F;
/// Channels in MIDI 1.0.
pub const CHANNELS: u8 = 16;
/// Controller number for all-sound-off.
pub const CC_ALL_SOUND_OFF: u8 = 120;
/// Controller number for reset-all-controllers.
pub const CC_RESET_ALL_CONTROLLERS: u8 = 121;
/// Controller number for all-notes-off.
pub const CC_ALL_NOTES_OFF: u8 = 123;
/// Controller number for the sustain pedal.
pub const CC_SUSTAIN: u8 = 64;
/// A MIDI channel, zero-based internally and one-based when shown to people.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Channel(u8);
impl Channel {
/// Creates a channel from a zero-based number, returning `None` when out of range.
pub fn new(zero_based: u8) -> Option<Self> {
(zero_based < CHANNELS).then_some(Self(zero_based))
}
/// Creates a channel from the low nibble of a status byte, which is always in range.
pub fn from_status(status: u8) -> Self {
Self(status & 0x0F)
}
/// Returns the zero-based number, as it appears on the wire.
pub fn index(&self) -> u8 {
self.0
}
/// Returns the one-based number, as people refer to it.
pub fn number(&self) -> u8 {
self.0.saturating_add(1)
}
/// Returns every channel, for operations that sweep all of them.
pub fn all() -> impl Iterator<Item = Self> {
(0..CHANNELS).map(Self)
}
}
/// A parsed MIDI 1.0 message.
///
/// System-exclusive payloads are deliberately absent: they are unbounded and must not be copied
/// around on the data path, so they travel as handles into a pre-allocated pool instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MidiMessage {
/// A key was released.
NoteOff {
/// Which channel.
channel: Channel,
/// Which key.
note: u8,
/// How fast it was released.
velocity: u8,
},
/// A key was pressed. A velocity of zero means the same as note off.
NoteOn {
/// Which channel.
channel: Channel,
/// Which key.
note: u8,
/// How hard it was pressed.
velocity: u8,
},
/// Pressure applied to one held key.
PolyAftertouch {
/// Which channel.
channel: Channel,
/// Which key.
note: u8,
/// How much pressure.
pressure: u8,
},
/// A controller moved.
ControlChange {
/// Which channel.
channel: Channel,
/// Which controller.
controller: u8,
/// Its new value.
value: u8,
},
/// The sound selection changed.
ProgramChange {
/// Which channel.
channel: Channel,
/// Which program.
program: u8,
},
/// Pressure applied to the whole channel.
ChannelAftertouch {
/// Which channel.
channel: Channel,
/// How much pressure.
pressure: u8,
},
/// The pitch wheel moved, centred at 8192.
PitchBend {
/// Which channel.
channel: Channel,
/// The fourteen-bit position.
value: u16,
},
/// A system common message that carries data: a time code quarter frame, a song position
/// or a song select.
SystemCommon {
/// The status byte, `0xF1` to `0xF3`.
status: u8,
/// The data bytes, of which a quarter frame and a song select use only the first.
data: [u8; 2],
},
/// A system message of one byte, which is every real-time message and a tune request.
System {
/// The status byte.
status: u8,
},
}
impl MidiMessage {
/// Returns the channel this message applies to, or `None` for system messages.
pub fn channel(&self) -> Option<Channel> {
match self {
Self::NoteOff { channel, .. }
| Self::NoteOn { channel, .. }
| Self::PolyAftertouch { channel, .. }
| Self::ControlChange { channel, .. }
| Self::ProgramChange { channel, .. }
| Self::ChannelAftertouch { channel, .. }
| Self::PitchBend { channel, .. } => Some(*channel),
Self::SystemCommon { .. } | Self::System { .. } => None,
}
}
/// Reports whether this message starts a note sounding.
///
/// A note on with zero velocity is a note off by convention, and treating it as a start is
/// the classic way to leave a note hanging forever.
pub fn starts_note(&self) -> bool {
matches!(self, Self::NoteOn { velocity, .. } if *velocity > 0)
}
/// Reports whether this message stops a note sounding.
pub fn stops_note(&self) -> Option<(Channel, u8)> {
match self {
Self::NoteOff { channel, note, .. } => Some((*channel, *note)),
Self::NoteOn {
channel,
note,
velocity,
} if *velocity == 0 => Some((*channel, *note)),
_ => None,
}
}
/// Returns the status byte for this message.
pub fn status(&self) -> u8 {
match self {
Self::NoteOff { channel, .. } => 0x80 | channel.index(),
Self::NoteOn { channel, .. } => 0x90 | channel.index(),
Self::PolyAftertouch { channel, .. } => 0xA0 | channel.index(),
Self::ControlChange { channel, .. } => 0xB0 | channel.index(),
Self::ProgramChange { channel, .. } => 0xC0 | channel.index(),
Self::ChannelAftertouch { channel, .. } => 0xD0 | channel.index(),
Self::PitchBend { channel, .. } => 0xE0 | channel.index(),
Self::SystemCommon { status, .. } | Self::System { status } => *status,
}
}
/// Returns how many bytes this message occupies, including its status byte.
pub fn len(&self) -> usize {
match self {
Self::ProgramChange { .. } | Self::ChannelAftertouch { .. } => 2,
Self::SystemCommon { status, .. } | Self::System { status } => {
system_message_len(*status)
}
_ => 3,
}
}
/// Reports whether this message carries no bytes, which cannot happen.
pub fn is_empty(&self) -> bool {
false
}
/// Writes this message into `out`, returning how many bytes were written.
///
/// Writes nothing and returns zero when `out` is too small, so a caller on the data path
/// never has to handle a panic or a reallocation.
pub fn encode(&self, out: &mut [u8]) -> usize {
let needed = self.len();
if out.len() < needed {
return 0;
}
let bytes: [u8; 3] = match self {
Self::NoteOff { note, velocity, .. } => [self.status(), *note, *velocity],
Self::NoteOn { note, velocity, .. } => [self.status(), *note, *velocity],
Self::PolyAftertouch { note, pressure, .. } => [self.status(), *note, *pressure],
Self::ControlChange {
controller, value, ..
} => [self.status(), *controller, *value],
Self::ProgramChange { program, .. } => [self.status(), *program, 0],
Self::ChannelAftertouch { pressure, .. } => [self.status(), *pressure, 0],
Self::PitchBend { value, .. } => {
// Fourteen bits split across two seven-bit bytes, least significant first.
[
self.status(),
(*value & 0x7F) as u8,
((*value >> 7) & 0x7F) as u8,
]
}
Self::SystemCommon {
status,
data: [first, second],
} => [*status, *first, *second],
Self::System { status } => [*status, 0, 0],
};
for (slot, byte) in out.iter_mut().zip(bytes.iter()).take(needed) {
*slot = *byte;
}
needed
}
/// Parses one message from `bytes`, returning it and how many bytes it consumed.
///
/// `running_status` supplies the status byte when the message omits it, which is legal inside
/// an RTP-MIDI list and common from hardware.
pub fn parse(bytes: &[u8], running_status: Option<u8>) -> Option<(Self, usize)> {
let first = bytes.first().copied()?;
// Resolve the status byte, which may be carried over from the previous message.
let (status, payload_offset) = if first >= 0x80 {
(first, 1)
} else {
(running_status?, 0)
};
// A byte with the high bit set is never data. Finding one here means a status arrived
// before this message was complete, and forwarding it as data would put a status byte in
// the middle of the next device's stream.
let len = if status >= 0xF0 {
system_message_len(status)
} else {
channel_message_len(status)
};
let data = bytes.get(payload_offset..)?;
let needed = data.get(..len.saturating_sub(1))?;
if needed.iter().any(|byte| *byte >= 0x80) {
return None;
}
// System messages carry no channel.
if status >= 0xF0 {
let message = if len > 1 {
Self::SystemCommon {
status,
data: [
needed.first().copied().unwrap_or(0),
needed.get(1).copied().unwrap_or(0),
],
}
} else {
Self::System { status }
};
return Some((message, payload_offset + len.saturating_sub(1)));
}
let channel = Channel::from_status(status);
let first_data = data.first().copied()?;
let message = match status & 0xF0 {
0x80 => {
let velocity = data.get(1).copied()?;
Self::NoteOff {
channel,
note: first_data,
velocity,
}
}
0x90 => {
let velocity = data.get(1).copied()?;
Self::NoteOn {
channel,
note: first_data,
velocity,
}
}
0xA0 => {
let pressure = data.get(1).copied()?;
Self::PolyAftertouch {
channel,
note: first_data,
pressure,
}
}
0xB0 => {
let value = data.get(1).copied()?;
Self::ControlChange {
channel,
controller: first_data,
value,
}
}
0xC0 => Self::ProgramChange {
channel,
program: first_data,
},
0xD0 => Self::ChannelAftertouch {
channel,
pressure: first_data,
},
0xE0 => {
let msb = data.get(1).copied()?;
Self::PitchBend {
channel,
value: u16::from(first_data) | (u16::from(msb) << 7),
}
}
_ => return None,
};
let consumed = payload_offset + message.len().saturating_sub(1);
Some((message, consumed))
}
}
/// Returns how many bytes a channel message occupies, including its status byte.
fn channel_message_len(status: u8) -> usize {
match status & 0xF0 {
0xC0 | 0xD0 => 2,
_ => 3,
}
}
/// Returns how many bytes a system message occupies.
///
/// System-exclusive is unbounded, so it reports its status byte alone and the caller scans for
/// the terminator itself.
fn system_message_len(status: u8) -> usize {
match status {
// Time code quarter frame and song select carry one data byte.
0xF1 | 0xF3 => 2,
// Song position pointer carries two.
0xF2 => 3,
_ => 1,
}
}
/// Returns a note number's key and octave, as Apple's MIDI tools write it: middle C, note 60, is C3.
///
/// Every place a note is shown uses this, so the monitor and the window's note picker name a note
/// alike.
pub fn note_name(note: u8) -> String {
/// Key names, indexed by semitone within an octave.
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let key = NAMES.get(usize::from(note % 12)).copied().unwrap_or("?");
// Twelve semitones per octave, and note 0 sits two octaves below octave 0.
#[allow(clippy::integer_division)]
let octave = i16::from(note / 12) - 2;
format!("{key}{octave}")
}
/// Builds the messages that silence a channel completely.
///
/// Sent on every link teardown and recovery. All-notes-off alone is not enough: a held sustain
/// pedal keeps notes sounding through it. This sends the pedal release first, then the two broad
/// resets. A synth that ignores all-notes-off needs a note off for each note, which only a caller
/// that knows the notes can send; `Sounding::silence` does.
pub fn silence_channel(channel: Channel) -> [MidiMessage; 3] {
[
MidiMessage::ControlChange {
channel,
controller: CC_SUSTAIN,
value: 0,
},
MidiMessage::ControlChange {
channel,
controller: CC_ALL_NOTES_OFF,
value: 0,
},
MidiMessage::ControlChange {
channel,
controller: CC_ALL_SOUND_OFF,
value: 0,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
/// Locks Apple's octave numbering, which the monitor and the note picker both show.
///
/// Audio MIDI Setup and Logic write middle C, note 60, as C3; other tools write it C4, so the
/// convention is a choice that has to stay put. Note 0 is C-2 and note 127 is G8, the ends of
/// MIDI's range: 127 is ten octaves and seven semitones above 0.
#[test]
fn a_note_is_named_with_middle_c_as_c3() {
for (note, want) in [(0, "C-2"), (60, "C3"), (61, "C#3"), (69, "A3"), (127, "G8")] {
assert_eq!(note_name(note), want, "note {note} must read {want}");
}
}
fn channel(index: u8) -> Channel {
Channel::new(index).expect("channel in range")
}
/// Every kind of message encodes to the bytes the MIDI 1.0 specification gives it and parses
/// back from them.
///
/// The status byte is the kind in the high nibble and the zero-based channel in the low one.
/// Program change and channel pressure carry one data byte, quarter frame and song select one,
/// song position two, and real-time messages none. Pitch bend is fourteen bits sent least
/// significant seven first, so the centre, 8192 = 0x40 << 7 | 0x00, is `E1 00 40`.
#[test]
fn messages_encode_to_the_bytes_the_specification_gives_and_parse_back() {
let cases = [
(
"note off",
MidiMessage::NoteOff {
channel: channel(0),
note: 60,
velocity: 64,
},
&[0x80, 60, 64][..],
),
(
"note on, channel 16",
MidiMessage::NoteOn {
channel: channel(15),
note: 127,
velocity: 100,
},
&[0x9F, 127, 100][..],
),
(
"poly pressure",
MidiMessage::PolyAftertouch {
channel: channel(3),
note: 40,
pressure: 90,
},
&[0xA3, 40, 90][..],
),
(
"control change",
MidiMessage::ControlChange {
channel: channel(7),
controller: 74,
value: 12,
},
&[0xB7, 74, 12][..],
),
(
"program change",
MidiMessage::ProgramChange {
channel: channel(2),
program: 5,
},
&[0xC2, 5][..],
),
(
"channel pressure",
MidiMessage::ChannelAftertouch {
channel: channel(9),
pressure: 77,
},
&[0xD9, 77][..],
),
(
"pitch bend centred",
MidiMessage::PitchBend {
channel: channel(1),
value: 8192,
},
&[0xE1, 0x00, 0x40][..],
),
(
"time code quarter frame",
MidiMessage::SystemCommon {
status: 0xF1,
data: [0x35, 0],
},
&[0xF1, 0x35][..],
),
(
"song position",
MidiMessage::SystemCommon {
status: 0xF2,
data: [0x10, 0x20],
},
&[0xF2, 0x10, 0x20][..],
),
(
"song select",
MidiMessage::SystemCommon {
status: 0xF3,
data: [0x07, 0],
},
&[0xF3, 0x07][..],
),
(
"timing clock",
MidiMessage::System { status: 0xF8 },
&[0xF8][..],
),
];
for (case, message, bytes) in cases {
let mut buffer = [0u8; 3];
let written = message.encode(&mut buffer);
assert_eq!(
&buffer[..written],
bytes,
"{case}: the encoding must be the specification's bytes"
);
assert_eq!(
MidiMessage::parse(bytes, None),
Some((message, bytes.len())),
"{case}: the specification's bytes must parse back to the message, consumed whole"
);
}
}
proptest::proptest! {
/// Whatever parses from a run of bytes encodes back to exactly the bytes it consumed.
///
/// A message the daemon would read one way and send another is a peer misreading us.
#[test]
fn what_is_parsed_encodes_back_to_the_bytes_it_came_from(
bytes in proptest::collection::vec(proptest::num::u8::ANY, 1..4),
) {
proptest::prop_assume!(bytes.first().is_some_and(|status| *status >= 0x80));
if let Some((message, consumed)) = MidiMessage::parse(&bytes, None) {
let mut buffer = [0u8; 3];
let written = message.encode(&mut buffer);
proptest::prop_assert_eq!(buffer.get(..written), bytes.get(..consumed));
}
}
}
/// Running status supplies a missing status byte, and bytes that do not make a whole message
/// are refused rather than completed by guessing.
///
/// Running status is legal inside an RTP-MIDI list and common from hardware, so data bytes
/// with a status carried over are a message. With nothing carried over, or with the message
/// cut short, nothing establishes what the bytes mean.
#[test]
fn running_status_completes_a_message_and_nothing_else_does() {
let note_on = MidiMessage::NoteOn {
channel: channel(0),
note: 62,
velocity: 100,
};
let cases = [
(
"data bytes under running status",
&[62, 100][..],
Some(0x90),
Some((note_on, 2)),
),
(
"data bytes with no running status",
&[62, 100][..],
None,
None,
),
(
"a note on missing its velocity",
&[0x90, 60][..],
None,
None,
),
("a status byte alone", &[0x90][..], None, None),
("nothing at all", &[][..], Some(0x90), None),
];
for (case, bytes, running, want) in cases {
assert_eq!(
MidiMessage::parse(bytes, running),
want,
"{case}: only a whole message may be parsed"
);
}
}
/// A byte with the high bit set is never read as data, wherever it falls in a message.
///
/// MIDI 1.0 reserves the high bit for status bytes. One arriving before a message is complete
/// abandons it; forwarding it as data would put a status byte in the middle of the next
/// device's stream, which that device reads as the start of something else.
#[test]
fn a_status_byte_is_never_read_as_data() {
for status in (0x80..=0xE0).step_by(0x10).chain([0xF1, 0xF2, 0xF3]) {
let len = if status >= 0xF0 {
system_message_len(status)
} else {
channel_message_len(status)
};
for intruder in 0x80..=0xFF {
for position in 1..len {
let mut bytes = [status, 0x00, 0x00];
bytes[position] = intruder;
assert_eq!(
MidiMessage::parse(&bytes, None),
None,
"{bytes:02X?}: a status byte inside a message must refuse it"
);
}
}
}
}
/// A note on with zero velocity stops a note rather than starting one.
///
/// The MIDI 1.0 specification defines it as a note off, and senders use it to keep running
/// status across a run of notes. Treating it as a start is the classic way to leave a note
/// hanging forever.
#[test]
fn a_note_on_with_zero_velocity_stops_a_note() {
let cases = [
("velocity zero", 0, false, Some((channel(0), 60))),
("velocity one", 1, true, None),
];
for (case, velocity, starts, stops) in cases {
let message = MidiMessage::NoteOn {
channel: channel(0),
note: 60,
velocity,
};
assert_eq!(
(message.starts_note(), message.stops_note()),
(starts, stops),
"{case}: only a note on with velocity starts a note"
);
}
}
}