5.1 KiB
midi spec delta — enterprise-midi
ADDED Requirements
Requirement: Discriminated MidiMessage union
@suaveplan/midi SHALL export a discriminated MidiMessage union covering all 18 MIDI 1.0 message types. Each variant carries type plus type-specific fields parsed from the raw bytes. Channel-voice messages carry channel: 0..15; system messages carry channel: null.
Scenario: NoteOn decodes velocity + note
- WHEN
decodeMessage(new Uint8Array([0x91, 0x40, 0x64]), 0)is called - THEN it returns
{ msg: { type: "noteOn", channel: 1, note: 64, velocity: 100 }, next: 3 }
Scenario: Running status is honoured
- WHEN
decodeStream([0x91, 0x40, 0x64, 0x42, 0x55])is parsed with running status enabled - THEN two
noteOnevents emit on channel 1: notes 64+100 and 66+85
Scenario: SysEx framing rejects unterminated payloads
- WHEN
decodeMessage(new Uint8Array([0xf0, 0x7e, 0x00]), 0)is called (no F7 terminator) - THEN it throws
MidiError("MIDI.INVALID_SYSEX")
Verified by: src/message/encode.test.ts, src/message/decode.test.ts, src/sysex/sysex.test.ts
Requirement: Round-trip encode/decode
For every variant in the union, decode(encode(msg)) SHALL equal msg, asserted via fast-check property over the type-bounded value space (channel 0..15, note 0..127, velocity 0..127, 14-bit values 0..16383).
Scenario: Property — every variant round-trips
- WHEN fast-check generates 1000 arbitrary messages
- THEN every one round-trips identically
Verified by: tests/property.test.ts::"every variant round-trips"
Requirement: NRPN / RPN / 14-bit CC accumulation
The package SHALL expose NRPNState, RPNState, and CC14State reducers that accumulate the four-CC dance (CC 99/98 for NRPN MSB/LSB, CC 6/38 for data MSB/LSB, plus CC 96/97 for inc/dec) into a single NRPNChange { paramHigh, paramLow, value } event. Same for RPN (CC 101/100) and paired CCs 0-31 / 32-63.
Scenario: NRPN MSB+LSB+data emits one consolidated event
- WHEN a stream sends CC 99=0x20, CC 98=0x10, CC 6=0x40, CC 38=0x00
- THEN the reducer emits exactly one
{ type: "nrpnChange", channel, paramHigh: 0x20, paramLow: 0x10, value: 0x2000 }event
Scenario: Incomplete NRPN is rejected
- WHEN a stream sends CC 99=0x20 then CC 6=0x40 (skips LSB)
- THEN the reducer throws
MidiError("MIDI.NRPN_INCOMPLETE")carrying the partial state
Verified by: src/dispatch/nrpn.test.ts, src/dispatch/rpn.test.ts, src/dispatch/cc14.test.ts
Requirement: MidiPortLike contract
MidiPortLike SHALL be a structural interface implementable by any transport: { send(bytes: Uint8Array): void; onMessage(handler: (msg: MidiMessage) => void): Unsubscribe; readonly name: string; readonly id: string; readonly manufacturer?: string; readonly state: "connected" | "disconnected" }. No runtime class exposed.
Scenario: A test port can drive the dispatcher
- WHEN a fake
MidiPortLikeemits raw bytes via itsonMessagechannel - THEN a decoder layered on top of the port observes parsed
MidiMessageevents
Verified by: src/port/port.test.ts
Requirement: MIDI 2.0 UMP framing
The package SHALL expose UMP types (UMP32, UMP64, UMP96, UMP128) and lossless midi1ToUmp / umpToMidi1 bridges where conversion is meaning-preserving (lossy conversions surface as MidiError("MIDI.UNSUPPORTED_UMP_VARIANT") rather than silently degrade).
Scenario: MIDI 1.0 NoteOn lifts to UMP32
- WHEN
midi1ToUmp({ type: "noteOn", channel: 0, note: 60, velocity: 100 })is called - THEN it returns a
UMP32packet with MT=2, group=0, status=0x9, channel=0, note=60, velocity=100 (extended to 16-bit per UMP convention)
Verified by: src/ump/ump.test.ts
Requirement: General MIDI tables
The package SHALL export GM_LEVEL1_PATCHES: readonly Record<number, string> (128 entries) and GM_LEVEL1_PERCUSSION: readonly Record<number, string> (notes 35-81 named per the GM spec). GS and XG dialect extensions are exported as separate frozen objects.
Scenario: GM patch 0 is Acoustic Grand Piano
- WHEN consumer reads
GM_LEVEL1_PATCHES[0] - THEN the value is
"Acoustic Grand Piano"
Verified by: tests/conformance.test.ts::"GM level 1 table integrity"
Requirement: MIDI Clock helpers
clockToBpm(intervalsMs: readonly number[]): number computes BPM from a window of MIDI-Clock intervals (24 PPQN). bpmToClock(bpm: number): number returns the per-tick interval in milliseconds.
Scenario: 120 BPM → ~20.833 ms per tick
- WHEN
bpmToClock(120)is called - THEN it returns
60_000 / 120 / 24(≈ 20.833 ms)
Verified by: src/clock/clock.test.ts
Requirement: OTel instrumentation
Every decodeMessage call SHALL increment counter midi_messages_decoded_total{type} and record histogram midi_decode_duration_ns{type}.
Scenario: Counter increments per decoded message
- WHEN 100
noteOnmessages are decoded against an in-memoryMeterProvider - THEN the counter snapshot reports
100fortype=noteOn
Verified by: src/telemetry/telemetry.test.ts