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,39 @@
# @suaveplan/midi — universal MIDI primitives
- **Target path**: `packages/media/audio/midi/`
- **Tier**: universal (zero browser/Node coupling)
- **Date**: 2026-05-23
- **Status**: PROPOSED
## Why
The audio family ships codecs, plugins, analysers, an engine, and a React surface — but no MIDI primitives. Every downstream surface that wants to drive plugin parameters via control surfaces (`@suaveplan/react-control-surface`), accept input from hardware controllers (Maschine, Traktor, KORG nanoKONTROL), expose virtual instruments (SoundFont synth, drum machines), or replicate parameter state across a network session needs a shared MIDI vocabulary. Without this package every consumer hand-rolls byte parsers and types, which inevitably diverges (status nibble vs. channel masking, running status, SysEx framing edge cases, NRPN vs. RPN dispatch).
This change introduces a universal-tier, zero-runtime-dependency package owning the canonical MIDI types, byte parsers, message constructors, CC/NRPN/RPN/Pitch/SysEx dispatchers, GM/GS/XG patch tables, MIDI 1.0 + MIDI 2.0 UMP differentiation, and a structural `MidiPortLike` contract that browser/Node adapters implement. No Web MIDI calls live here — that lives in `@suaveplan/react-midi` (browser) and a future `@suaveplan/midi-node` (server).
## What changes
1. **NEW package** `@suaveplan/midi` at `packages/media/audio/midi/`.
2. **Message types** — `MidiMessage` discriminated union: `NoteOn`, `NoteOff`, `PolyPressure`, `ControlChange`, `ProgramChange`, `ChannelPressure`, `PitchBend`, `SystemExclusive`, `TimeCode`, `SongPosition`, `SongSelect`, `TuneRequest`, `Clock`, `Start`, `Continue`, `Stop`, `ActiveSensing`, `Reset`. Each carries the parsed fields (channel 1-16 or `null` for system messages, plus type-specific payload).
3. **Encoders / decoders** — `encodeMessage(msg): Uint8Array`, `decodeMessage(bytes, offset): { msg, next }`. Round-trip property tests via fast-check. Handles running-status decode (when a stream omits the status byte and reuses the previous one).
4. **High-level dispatch** — `NRPNState` and `RPNState` reducers that accumulate the four-CC dance into a single `NRPNChange` / `RPNChange` event. Same for 14-bit CCs (MSB + LSB on pairs 0-31 / 32-63).
5. **SysEx framing** — `SysExFrame` type + universal manufacturer-id registry (Yamaha, Roland, Korg, NI, Ableton, generic non-commercial). `parseSysEx(bytes)`, `buildSysEx(manufacturer, deviceId, payload)`.
6. **Port contract** — `MidiPortLike { send(bytes): void; onMessage(handler): Unsubscribe; name: string; id: string; manufacturer?: string; state: "connected" | "disconnected" }`. Adapters in `@suaveplan/react-midi` (Web MIDI) and future `@suaveplan/midi-node` (node-midi) implement this.
7. **MIDI Clock helpers** — `clockToBpm(intervalsMs[]): number`, `bpmToClock(bpm): clockMs`. 24 PPQN per the spec.
8. **General MIDI tables** — GM Level 1 (128 patches + 128 percussion notes), GM2 extensions, GS/XG dialects as separate exported tables.
9. **MIDI 2.0 UMP** — Universal MIDI Packet framing types (`UMP32`, `UMP64`, `UMP96`, `UMP128`), high-resolution velocity/CC, per-note controllers. Encode/decode + bridge helpers (`midi1ToUmp`, `umpToMidi1` where lossless).
10. **Error codes** — `MIDI.{INVALID_MESSAGE, INVALID_SYSEX, BUFFER_UNDERRUN, UNSUPPORTED_UMP_VARIANT, NRPN_INCOMPLETE}` via `defineCodes`.
11. **OTel instrumentation** — counter `midi_messages_decoded_total{type}`, histogram `midi_decode_duration_ns{type}`.
## Impact
- New package; nothing else changes today.
- Unblocks `@suaveplan/react-midi` (browser Web MIDI adapter), `@suaveplan/audio-fx-plugin-spessasynth` (consumes MIDI for SF2 playback), `@suaveplan/control-surface-schema` (bindings can reference MIDI CC/note ids), `@suaveplan/network-midi` (transport over WebRTC/WebSocket).
- Zero external runtime deps beyond `@suaveplan/error` + `@suaveplan/types` + `tslib` + `zod`.
## Out of scope (later changes)
- Web MIDI API access — `@suaveplan/react-midi`.
- Node MIDI access — `@suaveplan/midi-node` (Wave 3+).
- Wire transport — `@suaveplan/network-midi`.
- SoundFont loading — `@suaveplan/audio-fx-plugin-spessasynth`.

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`

View File

@@ -0,0 +1,55 @@
# Tasks — enterprise-midi
> Every checkbox MUST reflect work actually done in this change. Retroactive ticking is the failure mode this template guards against.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 Account for every `.claude/worktrees/*` entry: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed via `bun scripts/worktree-prune.ts --remove` before opening a new one.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json | wc -l`. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/AGENTS.md` Sections 13 and 14
- [ ] 1.2 Draft `proposal.md` + `tasks.md`; this file
- [ ] 1.3 Draft `specs/midi/spec.md` with numbered Requirements + `**Verified by:**` lines
## 2. Implementation
- [ ] 2.1 `src/error-codes.ts` — `MIDI.{INVALID_MESSAGE, INVALID_SYSEX, BUFFER_UNDERRUN, UNSUPPORTED_UMP_VARIANT, NRPN_INCOMPLETE}` via `defineCodes` + `MidiError`
- [ ] 2.2 `src/message/message.types.ts` — discriminated `MidiMessage` union (18 variants)
- [ ] 2.3 `src/message/encode.ts` + `decode.ts` — round-trip via fast-check property tests
- [ ] 2.4 `src/dispatch/running-status.ts` — running-status decoder state machine
- [ ] 2.5 `src/dispatch/nrpn.ts` + `rpn.ts` + `cc14.ts` — high-resolution-controller reducers
- [ ] 2.6 `src/sysex/sysex.ts` — `parseSysEx`, `buildSysEx`, manufacturer-id registry
- [ ] 2.7 `src/port/port.types.ts` — `MidiPortLike` structural contract
- [ ] 2.8 `src/clock/clock.ts` — `clockToBpm`, `bpmToClock`, 24 PPQN converters
- [ ] 2.9 `src/gm/{level1, level2, gs, xg}.ts` — patch tables as `readonly Record<number, string>`
- [ ] 2.10 `src/ump/ump.ts` — UMP32/64/96/128 framing + MIDI1 ↔ UMP bridge
- [ ] 2.11 `src/telemetry/telemetry.ts` — counter `midi_messages_decoded_total`, histogram `midi_decode_duration_ns`
## 3. Tests
- [ ] 3.1 Per-feature `.test.ts` covering every scenario in `spec.md`
- [ ] 3.2 `tests/property.test.ts` — fast-check round-trip on every message variant
- [ ] 3.3 `tests/conformance.test.ts` — General MIDI patch table integrity, UMP framing invariants
- [ ] 3.4 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
## 4. Documentation
- [ ] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 4.2 README links every co-located `.md` under `## Modules`
## 5. Pre-archive verification
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/midi` — green
- [ ] 5.2 `bunx turbo test:coverage --filter=@suaveplan/midi` — 100/100/100/100
- [ ] 5.3 `bunx turbo build --filter=@suaveplan/midi` — green
- [ ] 5.4 `bun scripts/openspec/check-preflight.ts` — passes (this folder gates the lint)
## 6. Archive
- [ ] 6.1 Fold spec delta into `openspec/specs/midi/spec.md`
- [ ] 6.2 Move `openspec/changes/enterprise-midi/` → `openspec/archive/<yyyy-mm-dd>-enterprise-midi/`
- [ ] 6.3 Refresh `MANIFEST.md`
- [ ] 6.4 Prune any worktree this change opened (`bun scripts/worktree-prune.ts --remove`)