Initial Commit
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# @suaveplan/react-midi — Web MIDI API React adapter
|
||||
|
||||
- **Target path**: `packages/ui/react/react-midi/`
|
||||
- **Tier**: browser/react
|
||||
- **Date**: 2026-05-23
|
||||
- **Status**: PROPOSED
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/midi` ships universal-tier MIDI types and a `MidiPortLike` structural contract but never touches the platform. To consume MIDI in a browser app, React components need:
|
||||
|
||||
1. Permission gating (`navigator.requestMIDIAccess({ sysex })`) with promise-shaped state for Suspense + ErrorBoundary integration.
|
||||
2. Port enumeration with live add/remove (devices plug/unplug at runtime).
|
||||
3. Per-port input/output hooks that wire into the universal decoder/encoder and surface typed `MidiMessage` events.
|
||||
4. SysEx opt-in (separate permission scope).
|
||||
5. Safe StrictMode behaviour (double-mount + cleanup must not double-bind).
|
||||
|
||||
This package owns the Web MIDI bridge so every downstream React surface (control-surface runtime, react-control-surface editor, react-audio-player's hardware-controller integration) shares one set of hooks instead of reimplementing permission/state plumbing.
|
||||
|
||||
## What changes
|
||||
|
||||
1. **NEW package** `@suaveplan/react-midi` at `packages/ui/react/react-midi/`.
|
||||
2. **`<MidiProvider>`** — context provider that triggers `navigator.requestMIDIAccess` exactly once (StrictMode-safe), exposes the `MIDIAccess` state, supports `sysex: boolean` prop, surfaces permission denial as a typed `MidiPermissionError`.
|
||||
3. **Hooks**:
|
||||
- `useMidi()` — returns `{ access, status: "loading"|"granted"|"denied"|"unsupported", inputs: MidiPortLike[], outputs: MidiPortLike[] }`.
|
||||
- `useMidiInput(portId | "first"): MidiPortLike | null` — picks a single input; subscribers receive parsed `MidiMessage` events via `port.onMessage`.
|
||||
- `useMidiOutput(portId | "first"): MidiPortLike | null` — sends bytes via the universal `encodeMessage`.
|
||||
- `useMidiMessages(portId, filter?)` — returns the most recent N messages (ring buffer) filtered by message type. For UI display.
|
||||
- `useMidiDevices()` — returns the live `{ inputs, outputs }` array, re-renders on `statechange`.
|
||||
- `useNRPN(portId)` / `useRPN(portId)` / `useCC14(portId)` — high-resolution-controller reducers exposed as React hooks.
|
||||
4. **`<MidiDeviceList>` + `<MidiActivityIndicator>`** — small UI primitives for picker / activity visualisation. Each is composable; consumers pass their own renderProps for custom styling.
|
||||
5. **Connect-state events** — fires `onDeviceAdd` / `onDeviceRemove` via context, plus per-port `state` reads.
|
||||
6. **Error codes** — `REACT_MIDI.{ACCESS_DENIED, UNSUPPORTED_PLATFORM, PORT_NOT_FOUND, SYSEX_NOT_PERMITTED}` via `defineCodes`.
|
||||
7. **OTel instrumentation** — counter `react_midi_messages_received_total{port}`, counter `react_midi_messages_sent_total{port}`.
|
||||
|
||||
## Impact
|
||||
|
||||
- New package depending on `@suaveplan/midi@workspace:^` and React 19 peer dep.
|
||||
- Browser-tier — uses `navigator.requestMIDIAccess`. Falls back gracefully (`status: "unsupported"`) on platforms that lack Web MIDI (Safari without flag, Firefox, sandboxed contexts).
|
||||
- Unblocks `@suaveplan/react-control-surface` (CC bindings drive plugin parameters via `useParameter` from `@suaveplan/react-audio`).
|
||||
@@ -0,0 +1,79 @@
|
||||
# react-midi spec delta — enterprise-react-midi
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: MidiProvider grants Web MIDI exactly once
|
||||
|
||||
`<MidiProvider sysex={boolean}>` SHALL call `navigator.requestMIDIAccess` exactly once for the lifetime of the React tree, regardless of StrictMode double-mount. The resulting `MIDIAccess` SHALL be cached in a ref so subsequent re-renders observe the same instance. SysEx access is opt-in via prop.
|
||||
|
||||
#### Scenario: StrictMode double-mount does not double-request
|
||||
|
||||
- **WHEN** `<StrictMode><MidiProvider/></StrictMode>` mounts and `navigator.requestMIDIAccess` is a spy
|
||||
- **THEN** the spy is called exactly once across the double-mount sequence
|
||||
|
||||
#### Scenario: Permission denial surfaces as status
|
||||
|
||||
- **WHEN** the browser rejects `requestMIDIAccess` with `NotAllowedError`
|
||||
- **THEN** `useMidi()` returns `{ status: "denied", access: null }`; no `MidiError` is thrown to the React tree (caller chooses how to handle)
|
||||
|
||||
#### Scenario: Unsupported platform short-circuits
|
||||
|
||||
- **WHEN** `navigator.requestMIDIAccess` is undefined (no Web MIDI)
|
||||
- **THEN** `useMidi()` returns `{ status: "unsupported", access: null }` without throwing
|
||||
|
||||
**Verified by:** `src/provider/midi-provider.test.tsx`
|
||||
|
||||
### Requirement: Hooks expose typed messages
|
||||
|
||||
`useMidiInput(portId | "first")` SHALL return a `MidiPortLike` whose `onMessage` handler receives fully parsed `MidiMessage` events from `@suaveplan/midi`, not raw bytes.
|
||||
|
||||
#### Scenario: NoteOn from device → parsed event
|
||||
|
||||
- **WHEN** a fake `MIDIInput` delivers `[0x91, 0x40, 0x64]`
|
||||
- **THEN** the hook's `onMessage` subscriber observes `{ type: "noteOn", channel: 1, note: 64, velocity: 100 }`
|
||||
|
||||
**Verified by:** `src/hooks/use-midi-input/use-midi-input.test.tsx`
|
||||
|
||||
### Requirement: Device list reflects connect/disconnect live
|
||||
|
||||
`useMidiDevices()` SHALL re-render when a MIDI device is plugged in or unplugged at runtime (Web MIDI `statechange` event).
|
||||
|
||||
#### Scenario: Device plug emits new entry
|
||||
|
||||
- **WHEN** a fake `MIDIAccess` fires `statechange` with a newly-connected input
|
||||
- **THEN** the next render of `useMidiDevices()` returns the input in its `inputs` array
|
||||
|
||||
**Verified by:** `src/hooks/use-midi-devices/use-midi-devices.test.tsx`
|
||||
|
||||
### Requirement: NRPN / RPN / CC14 hooks wrap universal reducers
|
||||
|
||||
`useNRPN(portId)`, `useRPN(portId)`, `useCC14(portId)` SHALL consume the input port's message stream and yield the higher-level events (`NRPNChange`, `RPNChange`, `CC14Change`) by delegating to the universal reducers in `@suaveplan/midi`.
|
||||
|
||||
#### Scenario: Four-CC NRPN dance emits one consolidated React event
|
||||
|
||||
- **WHEN** an input sends CC 99/98/6/38
|
||||
- **THEN** the `useNRPN` subscriber observes one `{ paramHigh, paramLow, value }` event, not four CC events
|
||||
|
||||
**Verified by:** `src/hooks/use-nrpn/use-nrpn.test.tsx`
|
||||
|
||||
### Requirement: Output port encodes via universal helper
|
||||
|
||||
`useMidiOutput(portId).send(msg: MidiMessage)` SHALL call `encodeMessage` from `@suaveplan/midi` and forward the resulting bytes to the underlying `MIDIOutput.send`.
|
||||
|
||||
#### Scenario: NoteOn sent → encoded bytes hit transport
|
||||
|
||||
- **WHEN** consumer calls `port.send({ type: "noteOn", channel: 0, note: 60, velocity: 100 })`
|
||||
- **THEN** the underlying `MIDIOutput.send` is called with `[0x90, 0x3c, 0x64]`
|
||||
|
||||
**Verified by:** `src/hooks/use-midi-output/use-midi-output.test.tsx`
|
||||
|
||||
### Requirement: OTel counters increment per port
|
||||
|
||||
Every received/sent message SHALL increment `react_midi_messages_received_total{port}` / `react_midi_messages_sent_total{port}` counters tagged with the port id.
|
||||
|
||||
#### Scenario: Receive/send counters increment
|
||||
|
||||
- **WHEN** 10 messages flow in either direction
|
||||
- **THEN** the in-memory `MeterProvider` snapshot shows the matching count
|
||||
|
||||
**Verified by:** `src/telemetry/telemetry.test.ts`
|
||||
52
openspec/archive/2026-05-23-enterprise-react-midi/tasks.md
Normal file
52
openspec/archive/2026-05-23-enterprise-react-midi/tasks.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Tasks — enterprise-react-midi
|
||||
|
||||
## 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; prune stale ones via `bun scripts/worktree-prune.ts --remove`.
|
||||
- [ ] 0.3 Confirm `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json | wc -l`.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Draft `proposal.md`, `tasks.md`, `specs/react-midi/spec.md`
|
||||
- [ ] 1.2 Every Requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 `src/error-codes.ts` — `REACT_MIDI.{ACCESS_DENIED, UNSUPPORTED_PLATFORM, PORT_NOT_FOUND, SYSEX_NOT_PERMITTED}` via `defineCodes`
|
||||
- [ ] 2.2 `src/provider/midi-provider.tsx` — `<MidiProvider sysex={false}>` StrictMode-safe singleton `MIDIAccess` request
|
||||
- [ ] 2.3 `src/hooks/use-midi/use-midi.ts` — returns `{ access, status, inputs, outputs }`
|
||||
- [ ] 2.4 `src/hooks/use-midi-input/use-midi-input.ts`
|
||||
- [ ] 2.5 `src/hooks/use-midi-output/use-midi-output.ts`
|
||||
- [ ] 2.6 `src/hooks/use-midi-messages/use-midi-messages.ts` — ring buffer
|
||||
- [ ] 2.7 `src/hooks/use-midi-devices/use-midi-devices.ts` — re-renders on `statechange`
|
||||
- [ ] 2.8 `src/hooks/use-nrpn/`, `use-rpn/`, `use-cc14/` — wrap universal reducers
|
||||
- [ ] 2.9 `src/components/midi-device-list/midi-device-list.tsx` + `.stories.tsx`
|
||||
- [ ] 2.10 `src/components/midi-activity-indicator/midi-activity-indicator.tsx` + `.stories.tsx`
|
||||
- [ ] 2.11 `src/port/web-midi-port.ts` — bridges `MIDIInput`/`MIDIOutput` → `MidiPortLike`
|
||||
- [ ] 2.12 `src/telemetry/telemetry.ts` — receive/send counters
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 Per-hook `.test.tsx` with `@testing-library/react` + a fake `MIDIAccess` injected via context
|
||||
- [ ] 3.2 Provider tests cover: permission grant, permission denial, unsupported platform, SysEx prompt
|
||||
- [ ] 3.3 StrictMode double-mount test — `useMidi` does not double-request access
|
||||
- [ ] 3.4 Every `it(...)` has `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` per `.ts`/`.tsx` (≥200 words, basic + advanced examples)
|
||||
- [ ] 4.2 README with quickstart + permission model + device-picker example
|
||||
|
||||
## 5. Pre-archive verification
|
||||
|
||||
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/react-midi` — green
|
||||
- [ ] 5.2 `bunx turbo test:coverage --filter=@suaveplan/react-midi` — 100/100/100/100
|
||||
- [ ] 5.3 `bunx turbo build --filter=@suaveplan/react-midi` — green
|
||||
|
||||
## 6. Archive
|
||||
|
||||
- [ ] 6.1 Fold spec delta → `openspec/specs/react-midi/spec.md`
|
||||
- [ ] 6.2 Move folder → `openspec/archive/<yyyy-mm-dd>-enterprise-react-midi/`
|
||||
- [ ] 6.3 Refresh `MANIFEST.md`
|
||||
- [ ] 6.4 Prune worktree (`bun scripts/worktree-prune.ts --remove`)
|
||||
Reference in New Issue
Block a user