Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
166
plugins/document-navigator/docs/ui/react/react-midi/README.md
Normal file
166
plugins/document-navigator/docs/ui/react/react-midi/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user