first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
# @suaveplan/react-midi - React 19 adapter for the Web MIDI API: <MidiProvider>, hooks (useMidi, useMidiInput, useMidiOutput, useMidiMessages, useMidiDevices, useNRPN, useRPN, useCC14), and small UI primitives (MidiDeviceList, MidiActivityIndicator).
React 19 adapter for the Web MIDI API. Provides a strict-mode-safe
`<MidiProvider>` that requests `MIDIAccess` exactly once, seven
typed hooks for common consumption patterns, and two small UI
primitives (`<MidiDeviceList>`, `<MidiActivityIndicator>`).
<!-- begin:auto-docs -->
## Documentation tree
- [midi-activity-indicator](./src/components/midi-activity-indicator/midi-activity-indicator.md) — `<MidiActivityIndicator>` is a small visual primitive that "blinks" on inbound MIDI traffic. It subscribes to the selec…
- [midi-activity-indicator](./src/components/midi-activity-indicator/midi-activity-indicator.md) — `<MidiActivityIndicator>` is a small visual primitive that "blinks" on inbound MIDI traffic. It subscribes to the selec…
- [midi-device-list](./src/components/midi-device-list/midi-device-list.md) — `<MidiDeviceList>` is a small, accessible list of the available MIDI devices for a chosen kind (inputs or outputs). It…
- [midi-device-list](./src/components/midi-device-list/midi-device-list.md) — `<MidiDeviceList>` is a small, accessible list of the available MIDI devices for a chosen kind (inputs or outputs). It…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `REACT_MIDI.*` error-code namespace published by `@suaveplan/react-midi`. Every code is generated vi…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `REACT_MIDI.*` error-code namespace published by `@suaveplan/react-midi`. Every code is generated vi…
- [hooks](./src/hooks/use-midi-context.md) — Internal helper that returns the live `MidiContextValue` for the nearest `<MidiProvider>` ancestor. It throws `ReactMid…
- [use-cc14](./src/hooks/use-cc14/use-cc14.md) — `useCC14(selector)` exposes the consolidated 14-bit CC change for the selected input. Controllers that need finer resol…
- [use-cc14](./src/hooks/use-cc14/use-cc14.md) — `useCC14(selector)` exposes the consolidated 14-bit CC change for the selected input. Controllers that need finer resol…
- [use-midi](./src/hooks/use-midi/use-midi.md) — `useMidi()` is the most general hook in `@suaveplan/react-midi`. It returns the live `MidiContextValue` surface as `{ s…
- [use-midi](./src/hooks/use-midi/use-midi.md) — `useMidi()` is the most general hook in `@suaveplan/react-midi`. It returns the live `MidiContextValue` surface as `{ s…
- [use-midi-devices](./src/hooks/use-midi-devices/use-midi-devices.md) — `useMidiDevices()` returns the live `{ inputs, outputs }` device-list view, re-rendering whenever the provider's `state…
- [use-midi-devices](./src/hooks/use-midi-devices/use-midi-devices.md) — `useMidiDevices()` returns the live `{ inputs, outputs }` device-list view, re-rendering whenever the provider's `state…
- [use-midi-input](./src/hooks/use-midi-input/use-midi-input.md) — `useMidiInput(selector)` returns a typed `MidiPortLike` adapter over a single Web MIDI input. The adapter parses raw by…
- [use-midi-input](./src/hooks/use-midi-input/use-midi-input.md) — `useMidiInput(selector)` returns a typed `MidiPortLike` adapter over a single Web MIDI input. The adapter parses raw by…
- [use-midi-messages](./src/hooks/use-midi-messages/use-midi-messages.md) — `useMidiMessages(selector, options?)` exposes a bounded ring buffer of the most recent parsed MIDI messages from the se…
- [use-midi-messages](./src/hooks/use-midi-messages/use-midi-messages.md) — `useMidiMessages(selector, options?)` exposes a bounded ring buffer of the most recent parsed MIDI messages from the se…
- [use-midi-output](./src/hooks/use-midi-output/use-midi-output.md) — `useMidiOutput(selector)` returns a typed handle for a single Web MIDI output. The handle exposes both `send(bytes)` (r…
- [use-midi-output](./src/hooks/use-midi-output/use-midi-output.md) — `useMidiOutput(selector)` returns a typed handle for a single Web MIDI output. The handle exposes both `send(bytes)` (r…
- [use-nrpn](./src/hooks/use-nrpn/use-nrpn.md) — `useNRPN(selector)` wraps the universal NRPN reducer from the `midi-contract` module and exposes the consolidated `NRPN…
- [use-nrpn](./src/hooks/use-nrpn/use-nrpn.md) — `useNRPN(selector)` wraps the universal NRPN reducer from the `midi-contract` module and exposes the consolidated `NRPN…
- [use-rpn](./src/hooks/use-rpn/use-rpn.md) — `useRPN(selector)` wraps the universal RPN reducer and exposes the consolidated `RPNChange` as React state. RPN ("Regis…
- [use-rpn](./src/hooks/use-rpn/use-rpn.md) — `useRPN(selector)` wraps the universal RPN reducer and exposes the consolidated `RPNChange` as React state. RPN ("Regis…
- [use-midi-context](./src/hooks/use-midi-context.md) — Internal helper that returns the live `MidiContextValue` for the nearest `<MidiProvider>` ancestor. It throws `ReactMid…
- [codec](./src/midi-contract/codec.md) — `codec.ts` provides minimal `decodeMessage` / `encodeMessage` helpers for the MIDI 1.0 wire format used internally by `…
- [midi-message](./src/midi-contract/midi-message.md) — Defines the local mirror of the `@suaveplan/midi` `MidiMessage` discriminated union plus its peer types (`Channel`, `Mi…
- [reducers](./src/midi-contract/reducers.md) — Three small reducers that fold the four-CC NRPN, RPN, and 14-bit CC "dances" into single consolidated events. The Web M…
- [port](./src/port/web-midi-port.md) — Bridges the native Web MIDI `WebMIDIInput` / `WebMIDIOutput` shapes into the structural `MidiPortLike` consumed by the…
- [web-midi-port](./src/port/web-midi-port.md) — Bridges the native Web MIDI `WebMIDIInput` / `WebMIDIOutput` shapes into the structural `MidiPortLike` consumed by the…
- [midi-context](./src/provider/midi-context.md) — Defines the two React contexts that wire `<MidiProvider>` to its descendant hooks. `MidiReactContext` carries the resol…
- [midi-provider](./src/provider/midi-provider.md) — `<MidiProvider>` is the root context provider for `@suaveplan/react-midi`. It calls `navigator.requestMIDIAccess({ syse…
- [telemetry](./src/telemetry/telemetry.md) — Owns the two OTel counters published by `@suaveplan/react-midi`: `react_midi_messages_received_total` for parsed inboun…
- [telemetry](./src/telemetry/telemetry.md) — Owns the two OTel counters published by `@suaveplan/react-midi`: `react_midi_messages_received_total` for parsed inboun…
- [test-setup](./src/test-setup.md) — Per-package Vitest bootstrap loaded via `setupFiles` before the first test in a worker runs. Its only job is to registe…
- [test-utils](./src/test-utils.md) — Provides the deterministic in-memory implementations of the Web MIDI shape used throughout the `@suaveplan/react-midi`…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/react-midi
```
Peer deps: `react@^19`, `react-dom@^19`.
## Permission model
Web MIDI is gated behind `navigator.requestMIDIAccess`. `<MidiProvider>`
calls it once on mount and surfaces the outcome as a `status`:
- `loading` — request in flight.
- `granted` — `MIDIAccess` resolved.
- `denied` — user rejected, or the runtime threw (e.g. `NotAllowedError`).
- `unsupported` — `navigator.requestMIDIAccess` is undefined (Firefox,
sandboxed iframes, etc.).
SysEx access is opt-in via `<MidiProvider sysex>`. The provider is
StrictMode-safe — it tracks an in-flight ref so React's double-mount
never produces two parallel requests.
## Quickstart
```tsx
import {
MidiProvider,
useMidi,
useMidiInput,
MidiDeviceList,
} from "@suaveplan/react-midi";
function Workspace() {
const { status } = useMidi();
if (status === "loading") return <p>Connecting MIDI…</p>;
if (status === "denied") return <p>MIDI permission denied.</p>;
if (status === "unsupported")
return <p>MIDI not available in this browser.</p>;
return (
<>
<MidiDeviceList kind="input" />
<NoteLogger />
</>
);
}
function NoteLogger() {
const port = useMidiInput("first");
useEffect(() => {
if (port === null) return;
return port.onMessage((msg) => {
if (msg.type === "noteOn") console.log("note", msg.note);
});
}, [port]);
return null;
}
export default function App() {
return (
<MidiProvider>
<Workspace />
</MidiProvider>
);
}
```
## Device-picker example
```tsx
function DevicePicker() {
const [id, setId] = useState<string | null>(null);
return (
<>
<MidiDeviceList
kind="input"
selectedId={id}
onSelect={setId}
/>
{id !== null ? <NoteLogger portId={id} /> : null}
</>
);
}
```
## Hooks reference
- `useMidi()` — `{ status, access, inputs, outputs, error }`.
- `useMidiInput(id | "first")` — typed `MidiPortLike` with parsed
`MidiMessage` events.
- `useMidiOutput(id | "first")` — typed output handle with
`sendMessage(msg)` (encoded) and raw `send(bytes)`.
- `useMidiMessages(id, { capacity?, filter? })` — ring buffer of
recent messages for log/debug UIs.
- `useMidiDevices()` — `{ inputs, outputs }`, re-renders on
`statechange`.
- `useNRPN(id)`, `useRPN(id)`, `useCC14(id)` — high-resolution
reducers folded into React state.
## Error codes
`REACT_MIDI.{ACCESS_DENIED, UNSUPPORTED_PLATFORM, PORT_NOT_FOUND,
SYSEX_NOT_PERMITTED, NO_PROVIDER}`. Thrown as `ReactMidiError`
instances; permission failures are surfaced as a `status` value
rather than thrown so consumers can render friendly fallbacks.
## Telemetry
Two OTel counters tagged with `port`:
`react_midi_messages_received_total{port}` and
`react_midi_messages_sent_total{port}`.
## On the `@suaveplan/midi` dependency
This package ships a local mirror of the `@suaveplan/midi`
`MidiMessage` contract in `src/midi-contract/`. Once
`@suaveplan/midi` graduates to `workspace:^`, the local mirror is
replaced by a re-export from that package without any shape change.

View File

@@ -0,0 +1,71 @@
# `components/midi-activity-indicator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MidiActivityIndicator>` is a small visual primitive that "blinks"
on inbound MIDI traffic. It subscribes to the selected port via
`useMidiInput`, latches an internal `active` flag to `true` on every
incoming message, and resets the flag to `false` after `decayMs` of
quiet. Useful for device-picker UIs (show which row is currently
receiving data) and debug overlays (confirm wiring before chasing a
bug deeper).
The default render is a `<span>● </span>` with `data-active="true"`
/ `"false"` attributes so consumers can drive their own CSS theme;
a `render` prop is provided for callers that want full control over
the visual.
## Features
- Tracks last message type for downstream theming hints.
- Configurable decay window (`decayMs`, default 200ms).
- ARIA-live "polite" updates so screen readers announce activity.
- `render` callback for fully custom visuals.
## Usage: Basic
```tsx
import { MidiActivityIndicator } from "@suaveplan/react-midi";
function StatusBar() {
return (
<div>
<MidiActivityIndicator portId="first" />
<span> MIDI</span>
</div>
);
}
```
## Usage: Advanced
```tsx
<MidiActivityIndicator
portId="midi-in-1"
decayMs={120}
render={({ active, lastType }) => (
<Badge tone={active ? "live" : "muted"}>
{lastType ?? "idle"}
</Badge>
)}
/>;
```
## API Reference
- `<MidiActivityIndicator portId?, decayMs?, render?, className?>`.
- `render` callback signature:
`({ active: boolean, lastType: MidiMessageType | null }) => ReactNode`.
## Implementation Notes
The decay timer is held in a closure-scoped `timer` reference rather
than a `useRef` because it lives entirely within the effect body —
clearing on a fresh message is a simple `clearTimeout` against the
previous handle. The effect cleanup function clears any pending
timer on unmount so a slow-moving test environment never leaks a
"fire after unmount" callback.

View File

@@ -0,0 +1,85 @@
# `components/midi-device-list`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MidiDeviceList>` is a small, accessible list of the available MIDI
devices for a chosen kind (inputs or outputs). It is built on
`useMidiDevices`, so it re-renders whenever the Web MIDI runtime
fires a `statechange` event. The component is intentionally
unstyled (no opinionated theme tokens) — callers compose it with
their own row renderer via the `renderRow` prop, or rely on the
built-in default which renders a button per device showing `name`
and `manufacturer`.
## Features
- `kind="input" | "output"` selector.
- `onSelect(id)` + `selectedId` props for controlled selection.
- Optional `renderRow` prop for custom row UIs.
- Optional `empty` slot rendered when there are zero devices.
- ARIA: `role="listbox"` on the container, `role="option"` and
`aria-selected` on each row.
## Usage: Basic
```tsx
import { MidiDeviceList } from "@suaveplan/react-midi";
import { useState } from "react";
function Picker() {
const [id, setId] = useState<string | null>(null);
return (
<MidiDeviceList
kind="input"
selectedId={id}
onSelect={setId}
/>
);
}
```
## Usage: Advanced
```tsx
<MidiDeviceList
kind="output"
selectedId={selected}
onSelect={setSelected}
renderRow={({ port, selected, onSelect }) => (
<Card
key={port.id}
elevated={selected}
onActivate={onSelect}
title={port.name ?? port.id}
subtitle={port.manufacturer ?? "—"}
/>
)}
empty={<EmptyDeviceCard kind="output" />}
/>;
```
## API Reference
- `<MidiDeviceList kind?, onSelect?, selectedId?, renderRow?, empty?, className?>`.
- `interface MidiDeviceRowRenderProps { port, selected, onSelect }`.
- `type MidiDeviceKind = "input" | "output"`.
## Implementation Notes
The component reads `inputs`/`outputs` from `useMidiDevices` and
picks the right list based on `kind`. The default row uses
`role="option"` directly on the `<button>` for keyboard semantics
without nesting (the older `<li><button>` shape forced focus-style
duplication on screen readers).
When zero devices are available the empty branch renders a
`role="status"` container so screen readers announce the "no
devices" message politely rather than as a list with no entries.
The `selected` flag is forwarded to the row renderer plus surfaced
as a `data-selected` attribute and an `aria-selected` ARIA prop so
both CSS theming and assistive technology have access to the
selection without the consumer redoing the comparison.

View File

@@ -0,0 +1,82 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `REACT_MIDI.*` error-code namespace published by
`@suaveplan/react-midi`. Every code is generated via `defineCodes`
from `@suaveplan/error`, paired with metadata (description, severity)
in a frozen registry, and thrown via the `ReactMidiError` class which
extends `SuaveplanError`. The metadata table is the single source of
truth for downstream documentation generators, i18n resolvers, and
the test suite that cross-checks code/metadata parity.
## Features
- Five stable codes: `ACCESS_DENIED`, `UNSUPPORTED_PLATFORM`,
`PORT_NOT_FOUND`, `SYSEX_NOT_PERMITTED`, `NO_PROVIDER`.
- `ReactMidiError` carries `code`, `messageKey`, optional `context`
and `cause`, plus a severity-flavoured `name` for log filtering.
- `registerReactMidiErrorCodes()` is an idempotent registration hook
(no-op; metadata is module-static).
## Usage: Basic
```ts
import { REACT_MIDI_CODES, ReactMidiError } from "@suaveplan/react-midi";
throw new ReactMidiError(
REACT_MIDI_CODES.PORT_NOT_FOUND,
"react-midi.port-not-found",
{ context: { portId: "midi-in-1" } }
);
```
## Usage: Advanced
```ts
import {
REACT_MIDI_CODES,
REACT_MIDI_ERROR_METADATA,
ReactMidiError,
} from "@suaveplan/react-midi";
function logIfWarning(err: unknown): void {
if (err instanceof ReactMidiError) {
const meta = REACT_MIDI_ERROR_METADATA[err.code];
if (meta.severity === "warning") {
console.warn(err.code, meta.description);
} else {
throw err;
}
}
}
```
## API Reference
- `REACT_MIDI_CODES` — frozen `{ readonly [Reason]: "REACT_MIDI.<Reason>" }`.
- `REACT_MIDI_ERROR_METADATA` — frozen `{ readonly [Code]: { description, severity } }`.
- `class ReactMidiError extends SuaveplanError<ReactMidiErrorCode>`.
- `function registerReactMidiErrorCodes(): void`.
## Implementation Notes
The five codes are deliberately small in number: every public hook
surfaces at most one of them, and the four runtime codes
(`ACCESS_DENIED`, `UNSUPPORTED_PLATFORM`, `PORT_NOT_FOUND`,
`SYSEX_NOT_PERMITTED`) map one-to-one onto the four failure modes a
Web MIDI consumer must handle. `NO_PROVIDER` is the structural error
thrown by hooks invoked outside `<MidiProvider>`.
`ReactMidiError` extends `SuaveplanError` and therefore inherits the
standard `code`, `messageKey`, `context`, and `cause` fields plus
the package-wide `category` constant. The thrown `name` carries the
severity in parentheses (e.g. `ReactMidiError(error)`) so log
aggregators can filter on the rendered string when the consumer has
not chained an i18n resolver. The metadata table is intentionally
`Object.freeze`d so consumers can rely on it as a stable read-only
lookup across the process lifetime.

View File

@@ -0,0 +1,65 @@
# `hooks/use-cc14`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useCC14(selector)` exposes the consolidated 14-bit CC change for
the selected input. Controllers that need finer resolution than the
7-bit CC range allows pair CCs 0-31 (MSB) with CCs 32-63 (LSB) to
form 14-bit values (0-16383). The hook tracks the per-channel MSB
and LSB partial values via a `CC14Reducer` and emits a single React
event once both halves have been observed for a `(channel,
baseController)` pair.
## Features
- Folds CC `N` (MSB) and CC `N+32` (LSB) into a single `CC14Change`.
- Per-port, per-channel, per-base-controller reducer state.
- Returns `null` until the first paired pair.
## Usage: Basic
```tsx
import { useCC14 } from "@suaveplan/react-midi";
function BankSelect() {
const change = useCC14("first");
// CC 0 (Bank Select) paired with CC 32 (Bank Select LSB).
if (change?.controller === 0) {
return <p>Bank: {change.value}</p>;
}
return null;
}
```
## Usage: Advanced
```tsx
function ExpressionPedal({ portId }: { portId: string }) {
const change = useCC14(portId);
if (change?.controller !== 11) return null; // CC 11 = expression
return <Meter level={change.value / 16383} />;
}
```
## API Reference
- `useCC14(selector: string | "first"): CC14Change | null`
## Implementation Notes
The reducer key is `(channel, baseController)` so two simultaneous
14-bit edits on different channels (or different base controllers)
do not corrupt each other's pending MSB / LSB partials.
The reducer accepts either order — MSB first then LSB, or LSB first
then MSB — and emits the consolidated event once both halves are
observed. Subsequent CCs on the same `(channel, controller)` pair
start a fresh accumulation; the cleared partial state means the
hook never emits a duplicate `CC14Change` for a one-shot pair. As
with `useNRPN` and `useRPN`, the reducer is owned in a `useRef`
keyed on the port identity so plug/unplug resets the partials
without leaking from the previous device.

View File

@@ -0,0 +1,70 @@
# `hooks/use-midi-context`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Internal helper that returns the live `MidiContextValue` for the
nearest `<MidiProvider>` ancestor. It throws
`ReactMidiError(REACT_MIDI.NO_PROVIDER)` when invoked outside any
provider, so every public hook surfaces the same structural error.
This separation lets the seven public hooks (`useMidi`, `useMidiInput`,
`useMidiOutput`, `useMidiMessages`, `useMidiDevices`, `useNRPN`,
`useRPN`, `useCC14`) stay focused on their own logic while sharing
one source of truth for the provider lookup.
The function only calls `useContext`, so consumer hooks can register
additional hooks (`useMemo`, `useSyncExternalStore`, etc.)
unconditionally without violating the Rules of Hooks.
## Features
- Single `useContext` call — preserves hook-count invariants.
- Throws a typed `ReactMidiError` with stable code.
- Returns the same `MidiContextValue` shape consumed by every public
hook in the package.
## Usage: Basic
```ts
import { useMidiContextOrThrow } from "@suaveplan/react-midi/hooks";
function useDeviceCount(): number {
const { access } = useMidiContextOrThrow();
return access?.inputs.size ?? 0;
}
```
## Usage: Advanced
```ts
// Composing two hooks that both need the context:
function useReadyMidi() {
const ctx = useMidiContextOrThrow();
return ctx.status === "granted" && ctx.access !== null
? ctx.access
: null;
}
```
## API Reference
- `useMidiContextOrThrow(): MidiContextValue`
## Implementation Notes
The throw happens after the `useContext` call rather than inside a
conditional branch, so React's hook-tracker counts the same number of
hooks across renders whether the provider is present or not. Callers
that need a "soft" lookup should call `useContext(MidiReactContext)`
directly.
The thrown `ReactMidiError` carries the `REACT_MIDI.NO_PROVIDER`
code and the i18n key `react-midi.no-provider` so consumers can wire
a translation backend through `@suaveplan/error`'s resolver and have
the message localised at throw time. The category constant
(`react-midi`) on the error allows downstream aggregators to bucket
diagnostics by source package without parsing the prefix off the
code string.

View File

@@ -0,0 +1,81 @@
# `hooks/use-midi-devices`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidiDevices()` returns the live `{ inputs, outputs }` device-list
view, re-rendering whenever the provider's `stateVersion` advances —
i.e. whenever a Web MIDI `statechange` event fires (device plugged
in, unplugged, or its state otherwise changed). It is a thin wrapper
over `useMidi()` for components that only need the device lists and
want a narrower call site. Use this hook in device-picker UIs.
## Features
- `inputs` and `outputs` are `readonly WebMIDIInput[]` /
`readonly WebMIDIOutput[]` materialised on every state change.
- Stable array identity between state changes — safe to pass to
`key` in renders without forcing unnecessary remounts.
- No subscription bookkeeping — the underlying provider already owns
the single `onstatechange` listener.
## Usage: Basic
```tsx
import { useMidiDevices } from "@suaveplan/react-midi";
function DeviceCount() {
const { inputs, outputs } = useMidiDevices();
return (
<p>
{inputs.length} inputs, {outputs.length} outputs
</p>
);
}
```
## Usage: Advanced
```tsx
function PortPicker({ value, onChange }: {
value: string | null;
onChange: (id: string | null) => void;
}) {
const { inputs } = useMidiDevices();
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">(none)</option>
{inputs.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
);
}
```
## API Reference
- `useMidiDevices(): { inputs, outputs }`
## Implementation Notes
This hook deliberately avoids duplicating the `useMidi` snapshot —
it simply destructures and re-projects. Callers that also need the
`status` or `error` should reach for `useMidi` instead.
Because the underlying `useMidi` re-materialises its `inputs` and
`outputs` arrays only when the provider's `stateVersion` advances,
the values returned here are referentially stable between
`statechange` events. That means downstream effects keyed on
`inputs` or `outputs` do not fire on every render — only when the
device list actually changes — which matters for a device-picker UI
that triggers expensive operations (e.g. preset reloads) on the
selected device.

View File

@@ -0,0 +1,79 @@
# `hooks/use-midi-input`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidiInput(selector)` returns a typed `MidiPortLike` adapter over
a single Web MIDI input. The adapter parses raw bytes into the
discriminated `MidiMessage` union before delivering them through
`onMessage`, so consumers never touch byte-level details. The
`selector` is either an explicit `MIDIInput.id` or the literal
`"first"` shorthand that picks whichever input the access enumerates
first — useful when the host has exactly one device plugged in.
The hook returns `null` when access is still pending, when the
selector misses (e.g. a stale id), or when there are zero inputs.
This null-return shape composes cleanly with conditional rendering;
consumers don't need to thread an explicit loading state.
## Features
- Two selector forms: `id` string or `"first"` literal.
- Returns `null` during loading / denial / unknown id.
- Adapter identity memoised against access + stateVersion + selector.
- Multi-subscriber fan-out from a single Web MIDI input.
## Usage: Basic
```tsx
import { useMidiInput } from "@suaveplan/react-midi";
import { useEffect } from "react";
function NoteListener() {
const port = useMidiInput("first");
useEffect(() => {
if (port === null) return;
return port.onMessage((msg) => {
if (msg.type === "noteOn") console.log(msg.note, msg.velocity);
});
}, [port]);
return null;
}
```
## Usage: Advanced
```tsx
function PerDeviceListeners() {
const { inputs } = useMidi();
return (
<>
{inputs.map((i) => (
<PortFan key={i.id} portId={i.id} />
))}
</>
);
}
function PortFan({ portId }: { portId: string }) {
const port = useMidiInput(portId);
// ... attach handlers via port.onMessage
return null;
}
```
## API Reference
- `useMidiInput(selector: string | "first"): MidiPortLike | null`
## Implementation Notes
The hook calls `adaptInput` inside `useMemo` so the adapter identity
matches the underlying `MIDIInput` for the duration of the hook's
lifetime. Because `adaptInput` keeps its own `Set<subscriber>` and
the `useMemo` recreates the adapter when the underlying port changes
(plug/unplug), any active subscription should re-bind from a
`useEffect` keyed on `[port]`.

View File

@@ -0,0 +1,72 @@
# `hooks/use-midi-messages`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidiMessages(selector, options?)` exposes a bounded ring buffer of
the most recent parsed MIDI messages from the selected input. It is a
convenience built on top of `useMidiInput` for UI consumers that want
to render a live activity feed (e.g. an "incoming messages" panel) or
debug a controller wiring problem. The ring buffer trims the oldest
entry once `capacity` is exceeded, so the array length is stable and
the component can render it directly without further bookkeeping.
An optional `filter` whitelist accepts only specific message types
into the buffer — handy when a device floods the bus with active-
sensing or clock ticks the operator does not want in the visible log.
## Features
- Bounded ring buffer, default capacity 64.
- Optional `filter: readonly MidiMessageType[]` whitelist.
- Rebinds the underlying `port.onMessage` automatically when the
port identity changes (plug/unplug, selector switch).
- Buffer resets when the underlying port becomes `null`.
## Usage: Basic
```tsx
import { useMidiMessages } from "@suaveplan/react-midi";
function ActivityLog() {
const msgs = useMidiMessages("first", { capacity: 32 });
return (
<ul>
{msgs.map((m, i) => (
<li key={`${m.type}-${i}`}>{m.type}</li>
))}
</ul>
);
}
```
## Usage: Advanced
```tsx
function NotesOnly({ portId }: { portId: string }) {
// Drop active-sensing/clock noise.
const notes = useMidiMessages(portId, {
filter: ["noteOn", "noteOff"],
capacity: 128,
});
return <NoteRoll messages={notes} />;
}
```
## API Reference
- `useMidiMessages(selector, options?): readonly MidiMessage[]`
- `interface UseMidiMessagesOptions { capacity?, filter? }`.
## Implementation Notes
The internal state holds the ring buffer as a `readonly MidiMessage[]`
rather than a `Map` or a separate write-cursor so consumers can drop
straight into a `.map(...)` render. The buffer-replace path always
allocates a fresh array (rather than mutating in place) so React
detects the state change. The set-of-accepted-types is rebuilt only
when the `filter` reference or the joined key changes, not per
incoming message.

View File

@@ -0,0 +1,84 @@
# `hooks/use-midi-output`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidiOutput(selector)` returns a typed handle for a single Web
MIDI output. The handle exposes both `send(bytes)` (raw bytes,
typically for SysEx pass-through) and `sendMessage(msg)`, which
encodes a `MidiMessage` via `encodeMessage` before forwarding. The
selector mirrors `useMidiInput`: pass an explicit `MIDIOutput.id` or
the literal `"first"` to pick whichever output the access enumerates
first. Returns `null` when access is loading, denied, unsupported,
or the selector misses.
## Features
- Two selector forms — id string or `"first"`.
- Typed `sendMessage(msg: MidiMessage)` plus raw `send(bytes)`.
- Telemetry counter fires on every send.
- Handle identity memoised against access + stateVersion + selector.
## Usage: Basic
```tsx
import { useMidiOutput } from "@suaveplan/react-midi";
import { useCallback } from "react";
function NoteButton() {
const out = useMidiOutput("first");
const onClick = useCallback(() => {
out?.sendMessage({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
}, [out]);
return (
<button type="button" onClick={onClick} disabled={out === null}>
Play C4
</button>
);
}
```
## Usage: Advanced
```tsx
function SysExSender({ outputId }: { outputId: string }) {
const out = useMidiOutput(outputId);
const send = () => {
// Universal device-inquiry (real-time bypass).
out?.send(new Uint8Array([0xf0, 0x7e, 0x7f, 0x06, 0x01, 0xf7]));
};
return (
<button type="button" onClick={send} disabled={out === null}>
Inquire device
</button>
);
}
```
## API Reference
- `useMidiOutput(selector: string | "first"): MidiOutputHandle | null`
- `interface MidiOutputHandle { id, name, manufacturer?, state, send, sendMessage }`.
## Implementation Notes
`sendMessage` is intentionally distinct from `send`: the encoder
prepends/appends framing for SysEx and constructs a fresh
`Uint8Array` per call. Hosts that ship one bus-master encoder elsewhere
can stick with `send(bytes)` and avoid the per-call allocation. The
hook does not currently gate SysEx behind the provider's `sysex`
prop — Web MIDI itself enforces that boundary on `port.send` and
will surface a `DOMException` if a SysEx-bearing buffer is forwarded
from a non-SysEx access. The returned handle is memoised against
`(access, stateVersion, selector)` so its identity is stable until
the underlying port actually changes; callers that capture the
handle inside a `useCallback` will see the dependency change at the
right moments (port plug/unplug, selector switch) and at no others.

View File

@@ -0,0 +1,72 @@
# `hooks/use-midi`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidi()` is the most general hook in `@suaveplan/react-midi`. It
returns the live `MidiContextValue` surface as
`{ status, access, inputs, outputs, error }`, where `inputs` and
`outputs` are arrays freshly materialised from the underlying
`ReadonlyMap`s whenever the provider's `stateVersion` advances
(i.e. whenever a port plugs or unplugs). Components that only need
status or device-list snapshots can reach for this hook directly;
specialised hooks (`useMidiInput`, `useMidiDevices`) wrap it for
narrower use-cases.
The hook never throws on a `denied` / `unsupported` status — it
*reports* the state. The only throw is the structural
`REACT_MIDI.NO_PROVIDER` when rendered outside a provider.
## Features
- One snapshot of status, access, inputs, outputs, and last error.
- Arrays re-materialise on `stateVersion` bumps; stable identity
between bumps so consumers can pass them straight to `key`.
- Returns empty arrays during `loading` / `denied` / `unsupported`.
## Usage: Basic
```tsx
import { useMidi } from "@suaveplan/react-midi";
function DeviceCounts() {
const { status, inputs, outputs } = useMidi();
if (status !== "granted") return <p>{status}</p>;
return (
<p>
{inputs.length} ins / {outputs.length} outs
</p>
);
}
```
## Usage: Advanced
```tsx
function PermissionGate({ children }: { children: React.ReactNode }) {
const { status, error } = useMidi();
if (status === "loading") return <Spinner />;
if (status === "unsupported")
return <p>Web MIDI is not available in this browser.</p>;
if (status === "denied")
return <p>Permission denied: {error?.message}.</p>;
return <>{children}</>;
}
```
## API Reference
- `useMidi(): UseMidiResult`
- `interface UseMidiResult { status, access, inputs, outputs, error }`.
## Implementation Notes
The hook materialises `Array.from(access.inputs.values())` only when
either `access` (root identity) or `stateVersion` (plug/unplug
counter) changes — `useMemo` keys both into the dep list. This means
consumers that pass `inputs.length` to a downstream effect avoid the
"every render is a new array" trap that a naïve `[...map.values()]`
would create.

View File

@@ -0,0 +1,69 @@
# `hooks/use-nrpn`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useNRPN(selector)` wraps the universal NRPN reducer from the
`midi-contract` module and exposes the consolidated `NRPNChange` as
React state. NRPN ("Non-Registered Parameter Number") edits arrive
over the wire as a four-CC dance — CC 99 (param MSB), CC 98 (param
LSB), CC 6 (data MSB), CC 38 (data LSB) — and consumers usually want
a single React-level event per completed dance rather than four
intermediate `controlChange` events. The hook performs that fold
internally and re-renders once per consolidated change.
The reducer is owned per port: when the underlying input identity
changes (plug/unplug, selector switch) the reducer is reset, so a
half-completed dance from a previous device does not leak into the
new one.
## Features
- Folds CC 99/98/6/38 into one `NRPNChange` per dance.
- Per-port reducer — no cross-device interference.
- Resets internal state on port identity change.
- Returns `null` until the first completed dance.
## Usage: Basic
```tsx
import { useNRPN } from "@suaveplan/react-midi";
function ParamPanel() {
const change = useNRPN("first");
if (!change) return <p>Move a knob…</p>;
return (
<p>
NRPN ({change.paramHigh}.{change.paramLow}) → {change.value}
</p>
);
}
```
## Usage: Advanced
```tsx
function NRPNLog() {
const change = useNRPN("first");
const log = useRef<NRPNChange[]>([]);
useEffect(() => {
if (change !== null) log.current.push(change);
}, [change]);
return <pre>{JSON.stringify(log.current, null, 2)}</pre>;
}
```
## API Reference
- `useNRPN(selector: string | "first"): NRPNChange | null`
## Implementation Notes
The hook keeps the reducer in a `useRef` (rather than a `useMemo` or
`useState`) because the reducer is conceptually an external store
attached to the lifetime of the port subscription, not a render
output. When the port becomes `null` the ref is cleared so a fresh
port gets a fresh reducer.

View File

@@ -0,0 +1,65 @@
# `hooks/use-rpn`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useRPN(selector)` wraps the universal RPN reducer and exposes the
consolidated `RPNChange` as React state. RPN ("Registered Parameter
Number") covers the well-known parameters such as pitch-bend range,
fine/coarse tuning, channel pressure sensitivity, and modulation
depth. Like NRPN it arrives over the wire as a four-CC dance — CC
101 (param MSB), CC 100 (param LSB), CC 6 (data MSB), CC 38 (data
LSB) — and the hook folds that into one React event per dance.
## Features
- Folds CC 101/100/6/38 into one `RPNChange` per dance.
- Per-port reducer — fresh state on port identity change.
- Returns `null` until the first completed dance.
## Usage: Basic
```tsx
import { useRPN } from "@suaveplan/react-midi";
function PitchBendRange() {
const change = useRPN("first");
// RPN (0,0) is "Pitch Bend Sensitivity".
if (!change) return <p>(no RPN yet)</p>;
return (
<p>
RPN ({change.paramHigh}.{change.paramLow}) → {change.value}
</p>
);
}
```
## Usage: Advanced
```tsx
function useChannelTuning() {
const change = useRPN("first");
// RPN (0,1) = fine-tuning; RPN (0,2) = coarse-tuning.
return change?.paramHigh === 0 ? change : null;
}
```
## API Reference
- `useRPN(selector: string | "first"): RPNChange | null`
## Implementation Notes
The hook mirrors `useNRPN` exactly, replacing only the reducer class
and the parameter-MSB CC numbers (101 instead of 99). The reducer's
data-MSB/LSB CCs (6 and 38) are shared with NRPN — devices follow
the convention of sending a fresh paramHigh selector to switch
between the two address spaces, which the reducer handles via its
"new paramHigh resets partial state" rule. Consumers that need to
listen to *both* NRPN and RPN streams from the same port simply
compose the two hooks side by side; the underlying input adapter
fans the parsed messages out to both subscriptions without
double-binding the Web MIDI `onmidimessage` slot.

View File

@@ -0,0 +1,69 @@
# `midi-contract/codec`
## Purpose
`codec.ts` provides minimal `decodeMessage` / `encodeMessage`
helpers for the MIDI 1.0 wire format used internally by
`@suaveplan/react-midi`. The functions mirror the contract specified
in `@suaveplan/midi`'s spec delta so this package can be built and
tested in isolation of the `feat/midi` branch. Once `@suaveplan/midi`
graduates to `workspace:^`, these helpers SHALL be deleted and every
import re-routed to the upstream package without any signature change.
The encoder covers channel-voice messages (note on/off, poly
aftertouch, CC, program change, channel pressure, pitch bend), the
SysEx envelope, and the six system real-time bytes (clock, start,
continue, stop, active-sensing, system reset). The decoder is a pure
function over `Uint8Array` and returns the parsed message plus the
`next` offset, allowing callers to fold over a multi-message frame.
## Features
- Pure functions; no allocations beyond the returned `Uint8Array`.
- Returns `null` on malformed framing (missing data byte,
unterminated SysEx, status byte with high bit clear).
- 14-bit pitch-bend packing/unpacking handled correctly (LSB then MSB
on the wire).
- `noteOn` with velocity `0` is normalised to `noteOff` on decode
(per the MIDI spec convention).
## Usage: Basic
```ts
import { decodeMessage, encodeMessage } from "@suaveplan/react-midi/midi-contract";
const bytes = new Uint8Array([0x91, 0x40, 0x64]);
const out = decodeMessage(bytes, 0);
// out?.msg === { type: "noteOn", channel: 1, note: 64, velocity: 100 }
```
## Usage: Advanced
```ts
// Fold the codec over a streamed buffer.
function decodeAll(buf: Uint8Array) {
const out = [];
let cursor = 0;
while (cursor < buf.length) {
const r = decodeMessage(buf, cursor);
if (!r) break;
out.push(r.msg);
cursor = r.next;
}
return out;
}
```
## API Reference
- `decodeMessage(bytes: Uint8Array, offset?: number): { msg, next } | null`
- `encodeMessage(msg: MidiMessage): Uint8Array`
## Implementation Notes
The helper is intentionally permissive on the decode side: malformed
input yields `null` rather than throwing, because the Web MIDI runtime
already filters its `MIDIMessageEvent.data` payloads. Throwing here
would punish callers that fold over streamed device output during a
plug/unplug race. Encoding is total: every variant of the union maps
to a deterministic byte sequence.

View File

@@ -0,0 +1,68 @@
# `midi-contract/midi-message`
## Purpose
Defines the local mirror of the `@suaveplan/midi` `MidiMessage`
discriminated union plus its peer types (`Channel`, `MidiPortLike`,
`NRPNChange`, `RPNChange`, `CC14Change`, `Unsubscribe`). This file
exists so `@suaveplan/react-midi` can be developed and tested on its
own branch while `@suaveplan/midi` is being authored in parallel on
`feat/midi`. Once both branches land, the type aliases here SHALL be
replaced by a re-export from `@suaveplan/midi`; no shape change is
intended at swap time.
## Features
- Channel-voice variants: `noteOn`, `noteOff`, `polyAftertouch`,
`controlChange`, `programChange`, `channelPressure`, `pitchBend`.
- System variants: `sysEx`, `clock`, `start`, `continue`, `stop`,
`activeSensing`, `systemReset`.
- High-resolution reducer events: `NRPNChange`, `RPNChange`,
`CC14Change`.
- Structural `MidiPortLike` interface compatible with any transport.
## Usage: Basic
```ts
import type { MidiMessage } from "@suaveplan/react-midi/midi-contract";
function isNote(msg: MidiMessage): boolean {
return msg.type === "noteOn" || msg.type === "noteOff";
}
```
## Usage: Advanced
```ts
import type { MidiPortLike } from "@suaveplan/react-midi/midi-contract";
class FakePort implements MidiPortLike {
readonly id = "fake-1";
readonly name = "Fake In";
readonly state = "connected" as const;
private subs = new Set<(m: MidiMessage) => void>();
send(_: Uint8Array): void {}
onMessage(handler: (m: MidiMessage) => void) {
this.subs.add(handler);
return () => this.subs.delete(handler);
}
}
```
## API Reference
- `Channel = 0..15` literal union.
- `MidiMessage` discriminated union — see Features.
- `MidiMessageType = MidiMessage["type"]`.
- `MidiPortLike { id, name, manufacturer?, state, send, onMessage }`.
- `NRPNChange`, `RPNChange`, `CC14Change` — reducer outputs.
- `Unsubscribe = () => void`.
## Implementation Notes
The file deliberately uses interface declarations (not type aliases)
for variant shapes so each one can be discriminated in user code by
hovering over the inferred type. The structural `MidiPortLike` mirrors
the Web MIDI shape but adds a `manufacturer` field and a typed
`onMessage` that yields parsed `MidiMessage` rather than raw bytes —
that lift is performed inside `port/web-midi-port.ts`.

View File

@@ -0,0 +1,67 @@
# `midi-contract/reducers`
## Purpose
Three small reducers that fold the four-CC NRPN, RPN, and 14-bit CC
"dances" into single consolidated events. The Web MIDI runtime delivers
each CC as an isolated message; a knob that maps to a 14-bit parameter
therefore arrives as two CC messages (MSB then LSB), and an NRPN edit
arrives as four (CC 99/98 for parameter MSB/LSB then CC 6/38 for value
MSB/LSB). The reducers track per-channel partial state and emit one
`NRPNChange` / `RPNChange` / `CC14Change` per completed dance.
This logic lives here so the `useNRPN` / `useRPN` / `useCC14` hooks
have a deterministic, pure target to wrap with `useSyncExternalStore`.
Once `@suaveplan/midi` ships its canonical reducers, these classes
SHALL be replaced one-for-one without behavioural change.
## Features
- `NRPNReducer` — CC 99/98 parameter MSB/LSB, CC 6/38 data MSB/LSB.
- `RPNReducer` — CC 101/100 parameter MSB/LSB, CC 6/38 data MSB/LSB.
- `CC14Reducer` — pairs CC 0-31 (MSB) with CC 32-63 (LSB).
- Per-channel partial state — concurrent edits on different channels
do not interleave.
## Usage: Basic
```ts
import { NRPNReducer } from "@suaveplan/react-midi/midi-contract";
const reducer = new NRPNReducer();
const out = reducer.feed({
type: "controlChange",
channel: 0,
controller: 99,
value: 0x20,
});
// out === null (waiting for CC 98 / 6 / 38)
```
## Usage: Advanced
```ts
import { CC14Reducer } from "@suaveplan/react-midi/midi-contract";
const r = new CC14Reducer();
// 14-bit "bank select" on channel 0:
const a = r.feed({ type: "controlChange", channel: 0, controller: 0, value: 1 });
const b = r.feed({ type: "controlChange", channel: 0, controller: 32, value: 64 });
// b === { type: "cc14Change", channel: 0, controller: 0, value: (1<<7)|64 }
```
## API Reference
- `class NRPNReducer { feed(msg: ControlChangeMessage): NRPNChange | null }`
- `class RPNReducer { feed(msg: ControlChangeMessage): RPNChange | null }`
- `class CC14Reducer { feed(msg: ControlChangeMessage): CC14Change | null }`
## Implementation Notes
Reception of a fresh `paramHigh` (CC 99 / CC 101) resets all four
partial slots for that channel — devices commonly start a fresh NRPN
edit without an explicit "null param" sentinel, so the reducer treats
a new paramHigh as the implicit "start of new dance" marker. The
14-bit CC reducer keys per `(channel, baseController)` so two
simultaneous 14-bit edits on different channels do not corrupt each
other's pending MSB.

View File

@@ -0,0 +1,72 @@
# `port/web-midi-port`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bridges the native Web MIDI `WebMIDIInput` / `WebMIDIOutput` shapes
into the structural `MidiPortLike` consumed by the rest of the
package. The adapter handles two responsibilities the underlying
DOM types do not: it parses `MIDIMessageEvent.data` into a typed
`MidiMessage` (so consumers never touch raw bytes), and it owns the
fan-out from the single `onmidimessage` slot to multiple subscribers.
Two factories ship: `adaptInput` for inputs (yielding parsed messages
via `onMessage`) and `adaptOutput` for outputs (encoding `MidiMessage`
to bytes via `encodeMessage` before forwarding to `port.send`). Both
record traffic via the `telemetry` module so dashboards see one
counter per direction.
## Features
- Multiple subscribers on a single input — the adapter fans the
single `onmidimessage` slot out to a `Set` of handlers.
- Lazy bind/unbind — the `onmidimessage` slot is occupied only while
at least one subscriber is active; the last unsubscribe restores
`null`.
- Input-side decode swallows malformed frames silently (the runtime
already filters; defensive `null` check keeps subscribers safe).
- Output-side `sendMessage` encodes a typed `MidiMessage`; raw
`send(bytes)` remains available for SysEx pass-through.
## Usage: Basic
```ts
import { adaptInput, adaptOutput } from "@suaveplan/react-midi/port/web-midi-port";
const port = adaptInput(midiAccess.inputs.get("midi-in-0")!);
const unsubscribe = port.onMessage((msg) => {
if (msg.type === "noteOn") console.log("note", msg.note);
});
```
## Usage: Advanced
```ts
const out = adaptOutput(midiAccess.outputs.get("midi-out-0")!);
// Typed send (encoded):
out.sendMessage({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
// Raw send (for SysEx pass-through):
out.send(new Uint8Array([0xf0, 0x7e, 0x00, 0xf7]));
```
## API Reference
- `adaptInput(input: WebMIDIInput): MidiPortLike`
- `adaptOutput(output: WebMIDIOutput): MidiPortLike & { sendMessage(msg: MidiMessage): void }`
## Implementation Notes
The input adapter uses `Set` (not array) for subscribers so
unsubscribing during fan-out doesn't reorder iteration over the
remaining subscribers. The output adapter's `onMessage` returns a
no-op unsubscriber rather than throwing, which lets generic code that
walks both directions on a port handle either shape without a runtime
type check.

View File

@@ -0,0 +1,70 @@
# `provider/midi-context`
## Purpose
Defines the two React contexts that wire `<MidiProvider>` to its
descendant hooks. `MidiReactContext` carries the resolved
`MidiContextValue` — status, `WebMIDIAccess`, any caught error, the
`sysex` opt-in flag, and a monotonically-incrementing `stateVersion`
counter bumped on each Web MIDI `statechange` event. The presence of
`stateVersion` lets `useSyncExternalStore`-based hooks observe live
device plug/unplug changes without each hook holding its own
`statechange` listener.
`MidiRequestContext` is a per-tree override slot for the
`navigator.requestMIDIAccess` function. Tests provide a deterministic
in-memory implementation here; production renders leave it unset and
the provider falls back to the real global. This is a real injection
seam — not a mock — so the integration path remains exercised
verbatim under both real and fake transports.
## Features
- Two distinct contexts so hooks that only need the resolved access
re-render only on `stateVersion` bumps, not on injection changes.
- `null` sentinel on `MidiReactContext` triggers
`REACT_MIDI.NO_PROVIDER` in every public hook.
- `MidiStatus` is the four-value lifecycle: `loading`, `granted`,
`denied`, `unsupported`.
## Usage: Basic
```tsx
import { MidiReactContext } from "@suaveplan/react-midi";
import { useContext } from "react";
function StatusBadge() {
const ctx = useContext(MidiReactContext);
return <span>{ctx?.status ?? "no-provider"}</span>;
}
```
## Usage: Advanced
```tsx
import { MidiProvider, MidiRequestContext } from "@suaveplan/react-midi";
// Inject a deterministic fake in tests:
function fakeRequest() {
return Promise.resolve(fakeAccessShape);
}
<MidiRequestContext.Provider value={fakeRequest as never}>
<MidiProvider sysex>{children}</MidiProvider>
</MidiRequestContext.Provider>;
```
## API Reference
- `MidiStatus = "loading" | "granted" | "denied" | "unsupported"`.
- `MidiContextValue { status, access, error, sysex, stateVersion }`.
- `MidiReactContext: React.Context<MidiContextValue | null>`.
- `MidiRequestContext: React.Context<WebMIDIRequestFn | null>`.
## Implementation Notes
`stateVersion` exists because the Web MIDI `statechange` event fires
mutations on `MIDIAccess.inputs` / `outputs` in place — the underlying
`Map` identity does not change. `useSyncExternalStore` therefore needs
a sentinel value that *does* change to trigger a re-render; the
provider increments `stateVersion` inside the `statechange` callback.

View File

@@ -0,0 +1,75 @@
# `provider/midi-provider`
## Purpose
`<MidiProvider>` is the root context provider for `@suaveplan/react-midi`.
It calls `navigator.requestMIDIAccess({ sysex })` exactly once for
the lifetime of the React tree, regardless of StrictMode's
double-mount semantics, caches the resolved `WebMIDIAccess` in a ref
so subsequent re-renders observe the same instance, and surfaces
permission denial / unsupported-platform as `status` values rather
than thrown errors. A monotonic `stateVersion` counter is bumped
whenever a Web MIDI `statechange` fires so descendant hooks built on
`useSyncExternalStore` re-render on device plug/unplug.
For tests, the provider reads `MidiRequestContext` first: if a
deterministic fake is supplied through that context, the provider
uses it verbatim. No mocks; the fake is a real implementation of the
Web MIDI shape that satisfies the contract.
## Features
- Strict-mode safe: an `inFlight` ref prevents double-request even
when React invokes the effect twice on mount.
- `sysex={boolean}` prop is forwarded into the request.
- Surfaces permission failure as `status: "denied"` (not a thrown
error) so consumers can render a friendly fallback.
- Surfaces missing platform support as `status: "unsupported"` with
no async work.
- Live `statechange` events bump `stateVersion`.
## Usage: Basic
```tsx
import { MidiProvider } from "@suaveplan/react-midi";
export default function App() {
return (
<MidiProvider>
<Workspace />
</MidiProvider>
);
}
```
## Usage: Advanced
```tsx
import { MidiProvider, MidiRequestContext } from "@suaveplan/react-midi";
import { makeFakeMidiAccess } from "./test-utils";
// Drive the provider with a deterministic fake in tests.
function TestTree({ children }) {
const fakeRequest = (_opts) => Promise.resolve(makeFakeMidiAccess());
return (
<MidiRequestContext.Provider value={fakeRequest}>
<MidiProvider sysex={false}>{children}</MidiProvider>
</MidiRequestContext.Provider>
);
}
```
## API Reference
- `<MidiProvider sysex?: boolean>` — root provider.
- Reads from `MidiRequestContext` for fake injection in tests.
- Publishes `MidiContextValue` on `MidiReactContext`.
## Implementation Notes
The provider uses two refs (`inFlight`, `settled`) plus a `mounted`
flag to make StrictMode's double-effect-run safe. The first effect
run flips `inFlight` and starts the request; the second run sees
`inFlight === true` and short-circuits. Once the request resolves,
`settled` flips so a later remount (after an intervening dispose)
does not re-request access — caching is per React-tree lifetime.

View File

@@ -0,0 +1,66 @@
# `telemetry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the two OTel counters published by `@suaveplan/react-midi`:
`react_midi_messages_received_total` for parsed inbound messages
delivered through `port.onMessage`, and `react_midi_messages_sent_total`
for outbound messages sent through `port.send`. Both counters carry a
`port` attribute (the underlying `MIDIPort.id`) so downstream
dashboards can break traffic out by physical device.
The counters are created lazily off the global `metrics` API; under a
`NoopSdk` (the default in unit tests) the `add` calls are silent
no-ops. Wiring an `InMemoryMetricExporter` in a host app or test
harness lets consumers verify the counter values against expected
deltas without coupling to the meter provider details.
## Features
- Two counters: receive-side and send-side.
- Both tagged with `port: string` to break traffic out by id.
- Stable instrument names; usable as Prometheus / OTLP metric ids.
- Default `NoopMeter` makes telemetry safe to call from unit tests.
## Usage: Basic
```ts
import { recordReceived, recordSent } from "@suaveplan/react-midi";
recordReceived("midi-in-0");
recordSent("midi-out-0");
```
## Usage: Advanced
```ts
// In a host that wires an OTLP exporter, the counter values surface
// in the dashboards as react_midi_messages_received_total{port="..."}.
//
// In a unit test you can swap in an InMemoryMetricExporter to assert
// expected deltas without touching the global MeterProvider:
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
// counters now route to provider's readers.
```
## API Reference
- `messagesReceivedCounter: Counter` — increment via the OTel API.
- `messagesSentCounter: Counter`.
- `recordReceived(portId: string): void` — increment receive counter.
- `recordSent(portId: string): void` — increment send counter.
## Implementation Notes
The helper functions exist so call sites do not have to remember the
attribute key shape. They cost one synchronous `Counter.add` each;
under the default `NoopMeter` provider this is genuinely free (the
meter API short-circuits a no-op call into a single property lookup).

View File

@@ -0,0 +1,62 @@
# `test-setup`
## Purpose
Per-package Vitest bootstrap loaded via `setupFiles` before the first
test in a worker runs. Its only job is to register a single
`afterEach` hook that calls React Testing Library's `cleanup()`, so
the DOM is wiped between tests and `screen.getByText` does not pick
up stale nodes from a previous test's render tree. The default RTL
auto-cleanup only fires when `globalThis.afterEach` is exposed,
which is not the case under Vitest's `globals: false` configuration
the rest of the project runs with.
This file is intentionally tiny — it is not a production module and
is excluded from the coverage report. It exists alongside the package
source so the test runner's setup path is co-located with the tests
themselves rather than buried inside a separate `tests/` tree.
## Features
- One `afterEach` registration via `@suaveplan/testing/runner`.
- Single side-effect import; safe to load multiple times per worker.
- Excluded from coverage, types output, and runtime bundles.
## Usage: Basic
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
setupFiles: [
"@suaveplan/testing/polyfills",
"./src/test-setup.ts",
],
},
});
```
## Usage: Advanced
```ts
// To add extra per-test cleanup logic, append more afterEach hooks:
import { afterEach } from "@suaveplan/testing/runner";
afterEach(() => {
// Reset any module-level state captured by tests.
});
```
## API Reference
This module exports nothing — it is a side-effect bootstrap. The
single registration is `afterEach(cleanup)` where `cleanup` is the
function exported by `@testing-library/react`.
## Implementation Notes
The cleanup hook is registered at module load time, not lazily — so
the first import of this module in a worker is the moment the cleanup
contract is honoured. Subsequent imports are no-ops because Vitest
deduplicates `setupFiles` per worker.

View File

@@ -0,0 +1,85 @@
# `test-utils`
## Purpose
Provides the deterministic in-memory implementations of the Web MIDI
shape used throughout the `@suaveplan/react-midi` test suite. These
are not mocks — they are real, faithful implementations of the
`WebMIDIInput` / `WebMIDIOutput` / `WebMIDIAccess` contracts that
the production code consumes, so every test exercises the same
integration path that a real browser would. The helpers cover the
three pieces a typical test needs: fake input ports that can be
driven by calling `fire(bytes)`, fake output ports that record
received bytes in a `sent: number[][]` array, and a fake access
object that publishes `inputs`, `outputs`, and an `onstatechange`
slot whose subscribers fire on `addInput` / `removeInput`.
A small `<FakeMidiProvider>` wrapper composes `MidiRequestContext`
plus `<MidiProvider>` and exposes a `requestSpy` callback so tests
can assert that `navigator.requestMIDIAccess` is called exactly once
under StrictMode.
## Features
- `makeFakeInput(id, name?)` — port with a `fire(bytes)` helper.
- `makeFakeOutput(id, name?)` — port with a `sent` log of received
buffers.
- `makeFakeAccess({ inputs?, outputs?, sysex? })` — access object
with `addInput` / `addOutput` / `removeInput` runtime mutators
that fire the `statechange` listener.
- `<FakeMidiProvider>` — wires injection + `<MidiProvider>` and
forwards a `requestSpy` callback for assertion in StrictMode
scenarios.
## Usage: Basic
```tsx
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "./test-utils";
const input = makeFakeInput("a");
render(
<FakeMidiProvider access={makeFakeAccess({ inputs: [input] })}>
<MyComponent />
</FakeMidiProvider>
);
input.fire([0x90, 0x40, 0x40]);
```
## Usage: Advanced
```tsx
// Assert exactly-once request under StrictMode.
let count = 0;
render(
<StrictMode>
<FakeMidiProvider
access={makeFakeAccess()}
requestSpy={() => {
count += 1;
}}
>
<MyTree />
</FakeMidiProvider>
</StrictMode>
);
expect(count).toBe(1);
```
## API Reference
- `makeFakeInput(id, name?): FakeInput`.
- `makeFakeOutput(id, name?): FakeOutput` — `{ sent: number[][] }`.
- `makeFakeAccess(opts?): FakeAccess` — `{ addInput, addOutput, removeInput }`.
- `<FakeMidiProvider access, requestImpl?, requestSpy?, sysex?>`.
## Implementation Notes
The helpers live under `src/` rather than `tests/` because they are
imported by `.test.tsx` files co-located with feature modules.
Production code never imports from `test-utils.tsx` — the file is
explicitly excluded from the coverage report and the `tsc` library
output so it never lands in the published bundle.