Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
# 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`