midi-harbor/crates/blemidi/tests/codec.rs
2026-09-28 13:59:10 -05:00

381 lines
14 KiB
Rust

//! The three parts of BLE MIDI framing that are known to break in the field.
//!
//! A thirteen-bit millisecond counter turns over every eight seconds, running status may be sent
//! by a device but must not be assumed to survive a packet boundary, and a dump larger than the
//! MTU is spread over packets that carry no timestamps of their own. Each is a place where a
//! plausible-looking parser produces plausible-looking nonsense, so each gets a test that would
//! fail rather than merely producing different notes.
//!
//! Byte layouts follow the BLE-MIDI 1.0 specification (MMA/AMEI, 2015): a header byte
//! `10hhhhhh` carrying the high six timestamp bits, then a timestamp byte `1lllllll` carrying the
//! low seven before each message, so a timestamp in milliseconds is `high * 128 + low`.
#![allow(
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)]
use midi_harbor_blemidi::codec::{
DecodeError, Decoder, EncodeError, Encoder, Event, MAX_PACKET, MIN_PACKET, TIMESTAMP_PERIOD,
};
use midi_harbor_core::midi::{Channel, MidiMessage};
use midi_harbor_core::stream::SysExEnd;
use proptest::prelude::*;
/// What a decode produced, copied so system-exclusive runs outlive the packet they came from.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Read {
Message(u64, MidiMessage),
SysEx(u64, Vec<u8>, SysExEnd),
}
/// Reads one packet, returning everything it carried and any complaint about how it ended.
fn decode(decoder: &mut Decoder, packet: &[u8]) -> (Vec<Read>, Result<(), DecodeError>) {
let mut out = Vec::new();
let result = decoder.decode(packet, &mut |event| match event {
Event::Message { at, message } => out.push(Read::Message(at, message)),
Event::SysEx { at, bytes, end } => out.push(Read::SysEx(at, bytes.to_vec(), end)),
});
(out, result)
}
/// Encodes a run of timestamped messages into whole packets.
fn encode(capacity: usize, messages: &[(u64, MidiMessage)]) -> Vec<Vec<u8>> {
let mut encoder = Encoder::new(capacity);
let mut packets = Vec::new();
for (at, message) in messages {
encoder.push(*at, message, &mut |packet| packets.push(packet.to_vec()));
}
encoder.flush(&mut |packet| packets.push(packet.to_vec()));
packets
}
/// Reads a whole run of packets through one decoder, as a link would.
fn decode_all(packets: &[Vec<u8>]) -> Vec<Read> {
let mut decoder = Decoder::new();
let mut out = Vec::new();
for packet in packets {
let (read, result) = decode(&mut decoder, packet);
assert_eq!(
result,
Ok(()),
"packet {packet:02x?} came from the encoder, so it must decode"
);
out.extend(read);
}
out
}
fn channel(index: u8) -> Channel {
Channel::new(index % 16).expect("index is masked into range")
}
fn note_on(note: u8) -> MidiMessage {
MidiMessage::NoteOn {
channel: channel(0),
note,
velocity: 100,
}
}
prop_compose! {
/// Any message the encoder can carry, which is every channel message plus real time.
fn any_message()(
kind in 0_u8..8,
channel_index in 0_u8..16,
first in 0_u8..128,
second in 0_u8..128,
realtime in 0xF8_u8..=0xFF,
) -> MidiMessage {
let channel = channel(channel_index);
match kind {
0 => MidiMessage::NoteOff { channel, note: first, velocity: second },
1 => MidiMessage::NoteOn { channel, note: first, velocity: second },
2 => MidiMessage::PolyAftertouch { channel, note: first, pressure: second },
3 => MidiMessage::ControlChange { channel, controller: first, value: second },
4 => MidiMessage::ProgramChange { channel, program: first },
5 => MidiMessage::ChannelAftertouch { channel, pressure: first },
6 => MidiMessage::PitchBend {
channel,
value: (u16::from(second) << 7) | u16::from(first),
},
_ => MidiMessage::System { status: realtime },
}
}
}
proptest! {
/// Whatever goes in comes out, in order, however the packets happen to divide, and every
/// packet stands alone.
///
/// Packets are separate attribute writes and any one can be lost, so the encoder never uses
/// running status: byte 2 of every packet, after the header and the first timestamp, must be
/// a status byte. A receiver that saw only that packet can then still read it.
#[test]
fn every_message_survives_the_round_trip(
messages in prop::collection::vec(any_message(), 1..40),
capacity in MIN_PACKET..=64_usize,
) {
// Messages are spaced a millisecond apart so the run crosses packet boundaries on
// timestamp changes as well as on capacity.
let timed: Vec<_> = messages
.iter()
.enumerate()
.map(|(step, message)| (step as u64, *message))
.collect();
let packets = encode(capacity, &timed);
for packet in &packets {
prop_assert!(
packet.len() <= capacity,
"packet {:02x?} is longer than the {} bytes the link can write", packet, capacity
);
prop_assert!(
packet[2] & 0x80 == 0x80,
"packet {:02x?} opens on a data byte, relying on status from an earlier packet",
packet
);
}
let read = decode_all(&packets);
let recovered: Vec<_> = read
.iter()
.map(|entry| match entry {
Read::Message(at, message) => (*at, *message),
Read::SysEx(..) => panic!("no dump was sent, so none can be read"),
})
.collect();
prop_assert_eq!(recovered, timed, "the decoder must return exactly what was encoded");
}
/// Timing survives the thirteen-bit counter returning to zero, however far into the cycle a
/// run starts.
///
/// The counter holds `2^13 = 8192` milliseconds, so it turns over every 8.192 seconds. Four
/// notes a second apart span three seconds, and whether the counter turns over part-way
/// through depends entirely on where the run started.
#[test]
fn timestamps_stay_ordered_across_the_turnover(start in 0_u64..TIMESTAMP_PERIOD) {
let timed: Vec<_> = (0..4)
.map(|step| (start + step * 1000, note_on(60)))
.collect();
let read = decode_all(&encode(MAX_PACKET, &timed));
let times: Vec<u64> = read
.iter()
.map(|entry| match entry {
Read::Message(at, _) => *at,
Read::SysEx(at, ..) => *at,
})
.collect();
// The decoder counts from its own zero rather than the sender's, so what must hold is the
// spacing, not the absolute value.
prop_assert_eq!(times.len(), timed.len(), "every note sent must be read back");
for pair in times.windows(2) {
prop_assert_eq!(
pair[1] - pair[0], 1000,
"a one-second gap was lost across the turnover: {:?}", times
);
}
}
/// A dump of any size arrives byte-identical however many packets it took.
///
/// Continuation packets carry a header and then data with no timestamp bytes, so a decoder
/// that expected timestamps in them would eat every other byte of the dump.
#[test]
fn a_dump_split_across_packets_is_rejoined_byte_for_byte(
body in prop::collection::vec(0_u8..128, 0..600),
capacity in MIN_PACKET..=64_usize,
) {
let mut payload = vec![0xF0];
payload.extend_from_slice(&body);
payload.push(0xF7);
let mut encoder = Encoder::new(capacity);
let mut packets = Vec::new();
encoder
.push_sysex(1234, &payload, &mut |packet| packets.push(packet.to_vec()))
.expect("the payload is framed by F0 and F7, so the encoder must accept it");
encoder.flush(&mut |packet| packets.push(packet.to_vec()));
prop_assert!(
packets.iter().all(|packet| packet.len() <= capacity),
"a dump packet is longer than the link can write"
);
let mut rejoined = Vec::new();
let mut ended = None;
for entry in decode_all(&packets) {
match entry {
Read::SysEx(_, bytes, end) => {
rejoined.extend_from_slice(&bytes);
if end != SysExEnd::Open {
ended = Some(end);
}
}
Read::Message(_, message) => panic!("a dump alone produced {message:?}"),
}
}
prop_assert_eq!(ended, Some(SysExEnd::Complete), "the dump must be read as complete");
prop_assert_eq!(rejoined, payload, "the dump must be rejoined byte for byte");
}
}
/// Packets as devices write them decode as the BLE-MIDI 1.0 specification frames them, and the
/// malformed ones are refused rather than guessed at.
///
/// Running status is accepted inside a packet, because devices send it, but not across a packet
/// boundary, because a lost packet between the two would attach the data to the wrong status. A
/// real-time byte may interrupt a dump with its own timestamp byte before it, which is the shape a
/// naive parser mistakes for the terminator. A dump interrupted by a channel message was
/// abandoned by its sender and must say so, or a receiver acts on the fragment. Timestamps in the
/// wants are `high * 128 + low` from the header and timestamp bytes: `0x82, 0xC0` is
/// `2 * 128 + 64 = 320`, and a timestamp byte of `0xF0` is `0x70 = 112`.
#[test]
fn device_packets_decode_as_the_specification_frames_them() {
struct Case {
name: &'static str,
packets: &'static [&'static [u8]],
want: Vec<Read>,
want_last: Result<(), DecodeError>,
}
let cases = [
Case {
name: "running status inside one packet",
packets: &[&[0x82, 0xC0, 0x90, 60, 100, 62, 100]],
want: vec![
Read::Message(320, note_on(60)),
Read::Message(320, note_on(62)),
],
want_last: Ok(()),
},
Case {
name: "running status carried across a packet boundary",
packets: &[&[0x80, 0x80, 0x90, 60, 100], &[0x80, 0x80, 62, 100]],
want: vec![Read::Message(0, note_on(60))],
want_last: Err(DecodeError::Orphaned(62)),
},
Case {
name: "a clock inside a dump",
packets: &[&[
0x80, 0x80, 0xF0, 0x43, 0x10, 0x81, 0xF8, 0x44, 0x45, 0x81, 0xF7,
]],
want: vec![
Read::SysEx(0, vec![0xF0, 0x43, 0x10], SysExEnd::Open),
Read::Message(1, MidiMessage::System { status: 0xF8 }),
Read::SysEx(1, vec![0x44, 0x45], SysExEnd::Open),
Read::SysEx(1, vec![0xF7], SysExEnd::Complete),
],
want_last: Ok(()),
},
Case {
name: "a dump abandoned for a note",
packets: &[&[0x80, 0x80, 0xF0, 0x43, 0x10, 0x81, 0x90, 60, 100]],
want: vec![
Read::SysEx(0, vec![0xF0, 0x43, 0x10], SysExEnd::Abandoned),
Read::Message(1, note_on(60)),
],
want_last: Ok(()),
},
Case {
name: "a continuation whose timestamp byte reads as 0xF0",
packets: &[&[0x80, 0x80, 0xF0, 0x43], &[0x80, 0xF0, 0xF7]],
want: vec![
Read::SysEx(0, vec![0xF0, 0x43], SysExEnd::Open),
Read::SysEx(112, vec![0xF7], SysExEnd::Complete),
],
want_last: Ok(()),
},
Case {
name: "an empty write",
packets: &[&[]],
want: vec![],
want_last: Err(DecodeError::Empty),
},
Case {
name: "a write opening on a data byte",
packets: &[&[0x00, 0x90, 60, 100]],
want: vec![],
want_last: Err(DecodeError::NotAHeader(0x00)),
},
Case {
name: "a timestamp with no message after it",
packets: &[&[0x80, 0x80]],
want: vec![],
want_last: Err(DecodeError::Truncated),
},
];
for case in cases {
let mut decoder = Decoder::new();
let mut read = Vec::new();
let mut last = Ok(());
for packet in case.packets {
let (events, result) = decode(&mut decoder, packet);
read.extend(events);
last = result;
}
assert_eq!(
read, case.want,
"{}: the decoder must read exactly these events",
case.name
);
assert_eq!(
last, case.want_last,
"{}: the last packet must end with this result",
case.name
);
}
}
/// The encoder sends a dump only when it is a whole message, framed by `F0` and `F7`.
///
/// Half a dump on the wire is worse than none: the receiver waits for a terminator that is never
/// coming and swallows everything after it. The smallest framed dump, `F0 F7`, is the accepted
/// boundary, and goes out as header, timestamp, `F0`, timestamp, `F7`, with every timestamp zero.
#[test]
fn only_a_framed_dump_is_sent() {
let cases = [
(
"the smallest framed dump",
vec![0xF0, 0xF7],
Ok(()),
vec![vec![0x80, 0x80, 0xF0, 0x80, 0xF7]],
),
(
"a dump with its terminator missing",
vec![0xF0, 0x43, 0x10],
Err(EncodeError::NotFramed),
vec![],
),
(
"a dump with its opening byte missing",
vec![0x43, 0x10, 0xF7],
Err(EncodeError::NotFramed),
vec![],
),
(
"an opening byte alone",
vec![0xF0],
Err(EncodeError::NotFramed),
vec![],
),
];
for (name, payload, want, want_packets) in cases {
let mut encoder = Encoder::new(MAX_PACKET);
let mut packets = Vec::new();
let result = encoder.push_sysex(0, &payload, &mut |packet| packets.push(packet.to_vec()));
encoder.flush(&mut |packet| packets.push(packet.to_vec()));
assert_eq!(result, want, "{name}: the framing decides acceptance");
assert_eq!(
packets, want_packets,
"{name}: only an accepted dump may reach the wire"
);
}
}