Files
2026-05-27 23:29:53 +01:00

3.8 KiB

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

<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