107 lines
5.1 KiB
Markdown
107 lines
5.1 KiB
Markdown
# 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`
|