# Capability Spec: react-midi **Package**: `@suaveplan/react-midi` **Status**: Wave 1 shipped (provider + 7 hooks + 2 components + error codes + telemetry). ## Requirements ### Requirement: MidiProvider grants Web MIDI exactly once `` 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** `` 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`