Files
2026-05-27 23:29:53 +01:00

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 noteOn events 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 MidiPortLike emits raw bytes via its onMessage channel
  • THEN a decoder layered on top of the port observes parsed MidiMessage events

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 UMP32 packet 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 noteOn messages are decoded against an in-memory MeterProvider
  • THEN the counter snapshot reports 100 for type=noteOn

Verified by: src/telemetry/telemetry.test.ts