Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,161 @@
{
"name": "@suaveplan/react-midi",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "React 19 adapter for the Web MIDI API: <MidiProvider>, hooks (useMidi, useMidiInput, useMidiOutput, useMidiMessages, useMidiDevices, useNRPN, useRPN, useCC14), and small UI primitives (MidiDeviceList, MidiActivityIndicator).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./provider": {
"import": {
"types": "./dist/provider/index.d.ts",
"default": "./dist/provider/index.js"
}
},
"./hooks": {
"import": {
"types": "./dist/hooks/index.d.ts",
"default": "./dist/hooks/index.js"
}
},
"./hooks/use-midi": {
"import": {
"types": "./dist/hooks/use-midi/use-midi.d.ts",
"default": "./dist/hooks/use-midi/use-midi.js"
}
},
"./hooks/use-midi-input": {
"import": {
"types": "./dist/hooks/use-midi-input/use-midi-input.d.ts",
"default": "./dist/hooks/use-midi-input/use-midi-input.js"
}
},
"./hooks/use-midi-output": {
"import": {
"types": "./dist/hooks/use-midi-output/use-midi-output.d.ts",
"default": "./dist/hooks/use-midi-output/use-midi-output.js"
}
},
"./hooks/use-midi-messages": {
"import": {
"types": "./dist/hooks/use-midi-messages/use-midi-messages.d.ts",
"default": "./dist/hooks/use-midi-messages/use-midi-messages.js"
}
},
"./hooks/use-midi-devices": {
"import": {
"types": "./dist/hooks/use-midi-devices/use-midi-devices.d.ts",
"default": "./dist/hooks/use-midi-devices/use-midi-devices.js"
}
},
"./hooks/use-nrpn": {
"import": {
"types": "./dist/hooks/use-nrpn/use-nrpn.d.ts",
"default": "./dist/hooks/use-nrpn/use-nrpn.js"
}
},
"./hooks/use-rpn": {
"import": {
"types": "./dist/hooks/use-rpn/use-rpn.d.ts",
"default": "./dist/hooks/use-rpn/use-rpn.js"
}
},
"./hooks/use-cc14": {
"import": {
"types": "./dist/hooks/use-cc14/use-cc14.d.ts",
"default": "./dist/hooks/use-cc14/use-cc14.js"
}
},
"./components": {
"import": {
"types": "./dist/components/index.d.ts",
"default": "./dist/components/index.js"
}
},
"./components/midi-device-list": {
"import": {
"types": "./dist/components/midi-device-list/midi-device-list.d.ts",
"default": "./dist/components/midi-device-list/midi-device-list.js"
}
},
"./components/midi-activity-indicator": {
"import": {
"types": "./dist/components/midi-activity-indicator/midi-activity-indicator.d.ts",
"default": "./dist/components/midi-activity-indicator/midi-activity-indicator.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./midi-contract": {
"import": {
"types": "./dist/midi-contract/index.d.ts",
"default": "./dist/midi-contract/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@testing-library/jest-dom": "catalog:testing",
"@testing-library/react": "catalog:testing",
"@testing-library/user-event": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,10 @@
export {
MidiActivityIndicator,
type MidiActivityIndicatorProps,
} from "./midi-activity-indicator/midi-activity-indicator.js";
export {
type MidiDeviceKind,
MidiDeviceList,
type MidiDeviceListProps,
type MidiDeviceRowRenderProps,
} from "./midi-device-list/midi-device-list.js";

View File

@@ -0,0 +1,4 @@
export {
MidiActivityIndicator,
type MidiActivityIndicatorProps,
} from "./midi-activity-indicator.js";

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,81 @@
import { useEffect } from "react";
import {
type FakeInput,
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../../test-utils.js";
import { MidiActivityIndicator } from "./midi-activity-indicator.js";
const meta = {
title: "react-midi/MidiActivityIndicator",
component: MidiActivityIndicator,
};
export default meta;
function withProvider(child: React.ReactNode, input?: FakeInput) {
const inp = input ?? makeFakeInput("a", "Story Input");
const access = makeFakeAccess({ inputs: [inp] });
return <FakeMidiProvider access={access}>{child}</FakeMidiProvider>;
}
function PulseFeeder({ input }: { input: FakeInput }) {
useEffect(() => {
const id = setInterval(() => {
input.fire([0x90, 0x40, 0x40]);
}, 500);
return () => clearInterval(id);
}, [input]);
return null;
}
export const Default = {
render: () => {
const input = makeFakeInput("a", "Pulse In");
return withProvider(
<>
<PulseFeeder input={input} />
<MidiActivityIndicator portId="first" />
</>,
input
);
},
};
export const Playground = {
args: { decayMs: 200 },
render: (args: { decayMs: number }) => {
const input = makeFakeInput("a", "Pulse In");
return withProvider(
<>
<PulseFeeder input={input} />
<MidiActivityIndicator portId="first" decayMs={args.decayMs} />
</>,
input
);
},
};
export const CustomRender = {
render: () => {
const input = makeFakeInput("a", "Pulse In");
return withProvider(
<>
<PulseFeeder input={input} />
<MidiActivityIndicator
portId="first"
render={({ active, lastType }) =>
active ? (
<strong style={{ color: "lime" }}>
▲ {lastType}
</strong>
) : (
<span style={{ opacity: 0.4 }}>idle</span>
)
}
/>
</>,
input
);
},
};

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { useMidi } from "../../hooks/use-midi/use-midi.js";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../../test-utils.js";
import { MidiActivityIndicator } from "./midi-activity-indicator.js";
function StatusProbe(): ReactNode {
const { status } = useMidi();
return <span data-testid="probe-status">{status}</span>;
}
function wrap(node: ReactNode, access = makeFakeAccess()) {
return render(
<FakeMidiProvider access={access}>
<StatusProbe />
{node}
</FakeMidiProvider>
);
}
async function waitForGranted(): Promise<void> {
await waitFor(() => {
expect(screen.getByTestId("probe-status").textContent).toBe("granted");
});
// Let subscription effects flush.
await act(async () => {
await Promise.resolve();
});
}
describe("<MidiActivityIndicator>", () => {
it("starts inactive when port is null", { timeout: 5000 }, () => {
render(
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
<MidiActivityIndicator />
</FakeMidiProvider>
);
const el = screen.getByLabelText(/no MIDI activity/);
expect(el.getAttribute("data-active")).toBe("false");
});
it("becomes active on message", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
wrap(<MidiActivityIndicator portId="first" />, access);
await waitForGranted();
await act(async () => {
input.fire([0x90, 0x40, 0x40]);
});
await waitFor(() => {
const el = screen.getByLabelText(/MIDI activity \(noteOn\)/);
expect(el.getAttribute("data-active")).toBe("true");
expect(el.getAttribute("data-last-type")).toBe("noteOn");
});
});
it("decays back to inactive", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
wrap(<MidiActivityIndicator portId="first" decayMs={20} />, access);
await waitForGranted();
await act(async () => {
input.fire([0x90, 0x40, 0x40]);
});
await waitFor(() => {
expect(screen.getByLabelText(/MIDI activity \(/)).toBeTruthy();
});
await waitFor(
() => {
expect(screen.getByLabelText(/no MIDI activity/)).toBeTruthy();
},
{ timeout: 1500 }
);
});
it("supports custom render callback", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
wrap(
<MidiActivityIndicator
portId="first"
render={({ active, lastType }) => (
<span data-testid="custom">
{active ? `live:${lastType}` : "idle"}
</span>
)}
/>,
access
);
await waitForGranted();
expect(screen.getByTestId("custom").textContent).toBe("idle");
await act(async () => {
input.fire([0xb0, 0x07, 0x40]);
});
await waitFor(() => {
expect(screen.getByTestId("custom").textContent).toBe(
"live:controlChange"
);
});
});
it("coalesces rapid messages (timer reset)", {
timeout: 30000,
}, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
wrap(<MidiActivityIndicator portId="first" decayMs={50} />, access);
await waitForGranted();
await act(async () => {
input.fire([0x90, 0x40, 0x40]);
input.fire([0x90, 0x41, 0x41]);
input.fire([0x90, 0x42, 0x42]);
});
await waitFor(() => {
const el = screen.getByLabelText(/MIDI activity \(/);
expect(el.getAttribute("data-active")).toBe("true");
});
});
it("returns to inactive when port becomes null", {
timeout: 30000,
}, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const { rerender } = render(
<FakeMidiProvider access={access}>
<StatusProbe />
<MidiActivityIndicator portId="first" />
</FakeMidiProvider>
);
await waitForGranted();
// Rerender with a fresh provider that has no devices.
rerender(
<FakeMidiProvider access={makeFakeAccess()}>
<StatusProbe />
<MidiActivityIndicator portId="first" />
</FakeMidiProvider>
);
await waitFor(() => {
expect(screen.getByLabelText(/no MIDI activity/)).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,85 @@
import { type ReactNode, useEffect, useState } from "react";
import type { PortSelector } from "../../hooks/use-midi-input/use-midi-input.js";
import { useMidiInput } from "../../hooks/use-midi-input/use-midi-input.js";
import type { MidiMessageType } from "../../midi-contract/midi-message.types.js";
export interface MidiActivityIndicatorProps {
/**
* Which port to monitor. Defaults to `"first"`.
*/
readonly portId?: PortSelector;
/**
* How long (ms) the indicator stays "active" after the most
* recent message. Defaults to 200ms.
*/
readonly decayMs?: number;
/**
* Optional render override. Receives `{ active, lastType }` and
* returns the actual UI. When omitted, a small `<span>` with a
* `data-active` attribute is rendered.
*/
readonly render?: (args: {
active: boolean;
lastType: MidiMessageType | null;
}) => ReactNode;
readonly className?: string;
}
/**
* Tiny activity indicator that latches "active" for `decayMs` after
* each incoming MIDI message on the selected port. The default
* render shows a `<span>` with `data-active="true"` / `"false"` so
* downstream CSS can drive a blink animation; `render` lets callers
* substitute their own visuals.
*/
export function MidiActivityIndicator(
props: MidiActivityIndicatorProps
): ReactNode {
const portId = props.portId ?? "first";
const port = useMidiInput(portId);
const decayMs = props.decayMs ?? 200;
const [active, setActive] = useState(false);
const [lastType, setLastType] = useState<MidiMessageType | null>(null);
useEffect(() => {
if (port === null) {
setActive(false);
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
const off = port.onMessage((msg) => {
setActive(true);
setLastType(msg.type);
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
setActive(false);
}, decayMs);
});
return () => {
off();
if (timer !== null) {
clearTimeout(timer);
}
};
}, [port, decayMs]);
if (props.render !== undefined) {
return props.render({ active, lastType });
}
return (
<span
className={props.className}
role="status"
data-active={active ? "true" : "false"}
data-last-type={lastType ?? ""}
aria-live="polite"
aria-label={
active ? `MIDI activity (${lastType})` : "no MIDI activity"
}
>
●
</span>
);
}

View File

@@ -0,0 +1,6 @@
export {
type MidiDeviceKind,
MidiDeviceList,
type MidiDeviceListProps,
type MidiDeviceRowRenderProps,
} from "./midi-device-list.js";

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,75 @@
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
makeFakeOutput,
} from "../../test-utils.js";
import { MidiDeviceList } from "./midi-device-list.js";
const meta = {
title: "react-midi/MidiDeviceList",
component: MidiDeviceList,
};
export default meta;
function withProvider(node: ReactNode) {
const access = makeFakeAccess({
inputs: [
makeFakeInput("a", "Akai LPK25"),
makeFakeInput("b", "Arturia KeyStep"),
],
outputs: [makeFakeOutput("o1", "IAC Bus 1")],
});
return <FakeMidiProvider access={access}>{node}</FakeMidiProvider>;
}
export const Default = {
render: () => withProvider(<MidiDeviceList kind="input" />),
};
export const Playground = {
args: {
kind: "input" as const,
selectedId: "a",
},
render: (args: { kind: "input" | "output"; selectedId?: string }) =>
withProvider(<MidiDeviceList {...args} />),
};
export const OutputKind = {
render: () => withProvider(<MidiDeviceList kind="output" />),
};
export const EmptyState = {
render: () => (
<FakeMidiProvider access={makeFakeAccess()}>
<MidiDeviceList kind="input" />
</FakeMidiProvider>
),
};
export const CustomRenderer = {
render: () =>
withProvider(
<MidiDeviceList
kind="input"
renderRow={({ port, selected, onSelect }) => (
<button
type="button"
key={port.id}
onClick={onSelect}
style={{
border: selected
? "2px solid #06f"
: "1px solid #999",
padding: "0.5em 1em",
margin: "0.25em",
}}
>
▶ {port.name ?? port.id}
</button>
)}
/>
),
};

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
makeFakeOutput,
} from "../../test-utils.js";
import { MidiDeviceList } from "./midi-device-list.js";
function wrap(node: ReactNode, access = makeFakeAccess()) {
return render(<FakeMidiProvider access={access}>{node}</FakeMidiProvider>);
}
describe("<MidiDeviceList>", () => {
it("renders input device buttons", { timeout: 30000 }, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a", "Alpha"), makeFakeInput("b", "Beta")],
});
wrap(<MidiDeviceList kind="input" />, access);
await waitFor(() => {
expect(screen.getByText("Alpha")).toBeTruthy();
expect(screen.getByText("Beta")).toBeTruthy();
});
});
it("renders output device buttons", { timeout: 30000 }, async () => {
const access = makeFakeAccess({
outputs: [makeFakeOutput("x", "X out")],
});
wrap(<MidiDeviceList kind="output" />, access);
await waitFor(() => {
expect(screen.getByText("X out")).toBeTruthy();
});
});
it("fires onSelect with port id when row clicked", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a", "Alpha")],
});
let chosen: string | null = null;
wrap(
<MidiDeviceList
kind="input"
onSelect={(id) => {
chosen = id;
}}
/>,
access
);
const btn = await screen.findByText("Alpha");
fireEvent.click(btn);
expect(chosen).toBe("a");
});
it("marks the selected row with aria-selected", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a"), makeFakeInput("b")],
});
wrap(<MidiDeviceList kind="input" selectedId="b" />, access);
await waitFor(() => {
const opts = screen.getAllByRole("option");
expect(opts.length).toBe(2);
expect(opts[1]?.getAttribute("aria-selected")).toBe("true");
expect(opts[0]?.getAttribute("aria-selected")).toBe("false");
});
});
it("renders empty slot when no devices", { timeout: 5000 }, async () => {
wrap(<MidiDeviceList kind="input" empty={<span>nothing</span>} />);
await waitFor(() => {
expect(screen.getByText("nothing")).toBeTruthy();
});
});
it("renders default empty fallback", { timeout: 5000 }, async () => {
wrap(<MidiDeviceList kind="output" />);
await waitFor(() => {
expect(screen.getByText(/No output devices/)).toBeTruthy();
});
});
it("supports custom row renderer", { timeout: 30000 }, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a", "Alpha")],
});
wrap(
<MidiDeviceList
kind="input"
renderRow={({ port, selected, onSelect }) => (
<button
type="button"
key={port.id}
data-custom="yes"
data-selected={selected}
onClick={onSelect}
>
custom: {port.id}
</button>
)}
/>,
access
);
await waitFor(() => {
expect(screen.getByText("custom: a")).toBeTruthy();
});
});
it("defaults to kind='input' when prop omitted", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a", "Default Kind")],
});
wrap(<MidiDeviceList />, access);
await waitFor(() => {
expect(screen.getByText("Default Kind")).toBeTruthy();
});
});
it("falls back to port id when name is null", {
timeout: 30000,
}, async () => {
const input = makeFakeInput("port-id-only");
(input as unknown as { name: string | null }).name = null;
const access = makeFakeAccess({ inputs: [input] });
wrap(<MidiDeviceList kind="input" />, access);
await waitFor(() => {
expect(screen.getByText("port-id-only")).toBeTruthy();
});
});
it("hides manufacturer suffix when null", {
timeout: 30000,
}, async () => {
const input = makeFakeInput("a", "Alpha");
(input as unknown as { manufacturer: string | null }).manufacturer =
null;
const access = makeFakeAccess({ inputs: [input] });
wrap(<MidiDeviceList kind="input" />, access);
await waitFor(() => {
expect(screen.getByText("Alpha")).toBeTruthy();
});
// No "(" anywhere in row.
expect(screen.queryByText(/\(.*\)/)).toBeNull();
});
});

View File

@@ -0,0 +1,106 @@
import { type ReactNode, useCallback } from "react";
import { useMidiDevices } from "../../hooks/use-midi-devices/use-midi-devices.js";
import type { WebMIDIInput, WebMIDIOutput } from "../../port/web-midi.types.js";
export type MidiDeviceKind = "input" | "output";
export interface MidiDeviceRowRenderProps {
readonly port: WebMIDIInput | WebMIDIOutput;
readonly selected: boolean;
readonly onSelect: () => void;
}
export interface MidiDeviceListProps {
/** Which device kind to list. Defaults to `"input"`. */
readonly kind?: MidiDeviceKind;
/**
* Fired when a row is activated. The caller usually persists the
* chosen id for downstream `useMidiInput` / `useMidiOutput` calls.
*/
readonly onSelect?: (id: string) => void;
/** The currently selected id, if any. */
readonly selectedId?: string | null;
/**
* Optional custom row renderer. Receives the port plus a
* pre-bound select handler. When omitted the component renders
* a built-in row showing `name` and `manufacturer`.
*/
readonly renderRow?: (args: MidiDeviceRowRenderProps) => ReactNode;
/**
* Element rendered when no device of the chosen `kind` is
* available. Defaults to a small "No devices" string.
*/
readonly empty?: ReactNode;
readonly className?: string;
}
function DefaultRow(props: MidiDeviceRowRenderProps): ReactNode {
const name = props.port.name ?? props.port.id;
return (
<button
type="button"
role="option"
aria-selected={props.selected}
data-port-id={props.port.id}
data-selected={props.selected ? "true" : "false"}
onClick={props.onSelect}
>
<span>{name}</span>
{props.port.manufacturer !== null &&
props.port.manufacturer !== "" ? (
<span data-manufacturer={props.port.manufacturer}>
{" "}
({props.port.manufacturer})
</span>
) : null}
</button>
);
}
/**
* A11y-friendly list of available MIDI devices for the chosen kind.
* Rows are buttons with `role="option"` and `aria-selected`; a custom
* `renderRow` is supported for callers that want to supply their own
* styled rows.
*/
export function MidiDeviceList(props: MidiDeviceListProps): ReactNode {
const kind = props.kind ?? "input";
const { inputs, outputs } = useMidiDevices();
const list = kind === "input" ? inputs : outputs;
const select = useCallback(
(id: string) => () => props.onSelect?.(id),
[props.onSelect]
);
if (list.length === 0) {
return (
<div
className={props.className}
role="status"
aria-label={`MIDI ${kind} devices`}
>
{props.empty ?? <span>No {kind} devices.</span>}
</div>
);
}
const Row = props.renderRow ?? DefaultRow;
return (
<div
className={props.className}
role="listbox"
aria-label={`MIDI ${kind} devices`}
>
{list.map((port) => (
<Row
key={port.id}
port={port}
selected={
props.selectedId !== null &&
props.selectedId !== undefined &&
props.selectedId === port.id
}
onSelect={select(port.id)}
/>
))}
</div>
);
}

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,82 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_MIDI_CODES,
REACT_MIDI_ERROR_METADATA,
ReactMidiError,
registerReactMidiErrorCodes,
} from "./error-codes.js";
describe("REACT_MIDI_CODES", () => {
it("namespaces every reason under REACT_MIDI.*", { timeout: 5000 }, () => {
expect(REACT_MIDI_CODES.ACCESS_DENIED).toBe("REACT_MIDI.ACCESS_DENIED");
expect(REACT_MIDI_CODES.UNSUPPORTED_PLATFORM).toBe(
"REACT_MIDI.UNSUPPORTED_PLATFORM"
);
expect(REACT_MIDI_CODES.PORT_NOT_FOUND).toBe(
"REACT_MIDI.PORT_NOT_FOUND"
);
expect(REACT_MIDI_CODES.SYSEX_NOT_PERMITTED).toBe(
"REACT_MIDI.SYSEX_NOT_PERMITTED"
);
expect(REACT_MIDI_CODES.NO_PROVIDER).toBe("REACT_MIDI.NO_PROVIDER");
});
it("has metadata for every code", { timeout: 5000 }, () => {
const codes = Object.values(REACT_MIDI_CODES);
for (const code of codes) {
expect(REACT_MIDI_ERROR_METADATA[code]).toBeDefined();
expect(
REACT_MIDI_ERROR_METADATA[code].description.length
).toBeGreaterThan(0);
}
});
it("metadata keys match codes one-to-one", { timeout: 5000 }, () => {
const codeSet = new Set(Object.values(REACT_MIDI_CODES));
const metaKeys = new Set(Object.keys(REACT_MIDI_ERROR_METADATA));
expect(codeSet.size).toBe(metaKeys.size);
for (const k of metaKeys) {
expect(codeSet.has(k)).toBe(true);
}
});
});
describe("ReactMidiError", () => {
it("carries code, messageKey, and severity-flavoured name", {
timeout: 5000,
}, () => {
const e = new ReactMidiError(
REACT_MIDI_CODES.ACCESS_DENIED,
"react-midi.access-denied"
);
expect(e.code).toBe("REACT_MIDI.ACCESS_DENIED");
expect(e.name).toBe("ReactMidiError(error)");
expect(e.category).toBe("react-midi");
});
it("supports context option", { timeout: 5000 }, () => {
const e = new ReactMidiError(
REACT_MIDI_CODES.PORT_NOT_FOUND,
"react-midi.port-not-found",
{ context: { portId: "abc" } }
);
expect(e.context).toEqual({ portId: "abc" });
});
it("UNSUPPORTED_PLATFORM is a warning", { timeout: 5000 }, () => {
const e = new ReactMidiError(
REACT_MIDI_CODES.UNSUPPORTED_PLATFORM,
"react-midi.unsupported"
);
expect(e.name).toBe("ReactMidiError(warning)");
});
});
describe("registerReactMidiErrorCodes", () => {
it("is idempotent", { timeout: 5000 }, () => {
expect(() => {
registerReactMidiErrorCodes();
registerReactMidiErrorCodes();
}).not.toThrow();
});
});

View File

@@ -0,0 +1,92 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error codes published by `@suaveplan/react-midi`. The
* `defineCodes` helper namespaces each entry under the
* `REACT_MIDI.` prefix; consumers therefore see
* `REACT_MIDI.ACCESS_DENIED`, `REACT_MIDI.UNSUPPORTED_PLATFORM`,
* `REACT_MIDI.PORT_NOT_FOUND`, `REACT_MIDI.SYSEX_NOT_PERMITTED`,
* and `REACT_MIDI.NO_PROVIDER`.
*/
export const REACT_MIDI_CODES = defineCodes("REACT_MIDI", [
"ACCESS_DENIED",
"UNSUPPORTED_PLATFORM",
"PORT_NOT_FOUND",
"SYSEX_NOT_PERMITTED",
"NO_PROVIDER",
] as const);
export type ReactMidiErrorCode =
(typeof REACT_MIDI_CODES)[keyof typeof REACT_MIDI_CODES];
export interface ReactMidiErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
/**
* Frozen registry of metadata keyed by code. Every code in
* `REACT_MIDI_CODES` has exactly one entry here; the test suite
* cross-checks the two sets.
*/
export const REACT_MIDI_ERROR_METADATA: Readonly<
Record<ReactMidiErrorCode, ReactMidiErrorMetadata>
> = Object.freeze({
[REACT_MIDI_CODES.ACCESS_DENIED]: {
description:
"navigator.requestMIDIAccess rejected with NotAllowedError (user denied the permission prompt).",
severity: "error",
},
[REACT_MIDI_CODES.UNSUPPORTED_PLATFORM]: {
description:
"the runtime lacks the Web MIDI API (navigator.requestMIDIAccess is undefined).",
severity: "warning",
},
[REACT_MIDI_CODES.PORT_NOT_FOUND]: {
description:
"a hook requested a port by id but the id is not present in the current MIDIAccess.inputs/outputs map.",
severity: "error",
},
[REACT_MIDI_CODES.SYSEX_NOT_PERMITTED]: {
description:
"a caller attempted a SysEx send on a port whose MidiProvider did not request SysEx access.",
severity: "error",
},
[REACT_MIDI_CODES.NO_PROVIDER]: {
description:
"a react-midi hook was rendered outside a <MidiProvider> ancestor.",
severity: "error",
},
});
/**
* Base error class for every error thrown by `@suaveplan/react-midi`.
* Every thrown instance carries a code from `REACT_MIDI_CODES`, a
* `messageKey` suitable for i18n resolution, and an optional
* `context` / `cause`.
*/
export class ReactMidiError extends SuaveplanError<ReactMidiErrorCode> {
public readonly category = "react-midi" as const;
public constructor(
code: ReactMidiErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = `ReactMidiError(${REACT_MIDI_ERROR_METADATA[code].severity})`;
}
}
/**
* Idempotent registration hook. The metadata is frozen at module
* load and re-exported directly; this function exists for parity
* with the `registerXxxErrorCodes()` convention adopted by sibling
* packages and is safe to call any number of times.
*/
export function registerReactMidiErrorCodes(): void {
/* metadata is module-static; no-op. */
}
registerReactMidiErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
ReactMidiErrorCode,
ReactMidiErrorMetadata,
} from "./error-codes.js";
export {
REACT_MIDI_CODES,
REACT_MIDI_ERROR_METADATA,
ReactMidiError,
registerReactMidiErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,21 @@
export { useCC14 } from "./use-cc14/use-cc14.js";
export { type UseMidiResult, useMidi } from "./use-midi/use-midi.js";
export { useMidiContextOrThrow } from "./use-midi-context.js";
export {
type UseMidiDevicesResult,
useMidiDevices,
} from "./use-midi-devices/use-midi-devices.js";
export {
type PortSelector,
useMidiInput,
} from "./use-midi-input/use-midi-input.js";
export {
type UseMidiMessagesOptions,
useMidiMessages,
} from "./use-midi-messages/use-midi-messages.js";
export {
type MidiOutputHandle,
useMidiOutput,
} from "./use-midi-output/use-midi-output.js";
export { useNRPN } from "./use-nrpn/use-nrpn.js";
export { useRPN } from "./use-rpn/use-rpn.js";

View File

@@ -0,0 +1 @@
export { useCC14 } from "./use-cc14.js";

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,73 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../../test-utils.js";
import { useMidi } from "../use-midi/use-midi.js";
import { useCC14 } from "./use-cc14.js";
describe("useCC14", () => {
it("emits one event per MSB+LSB pair", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({ cc14: useCC14("first"), midi: useMidi() }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0xb0, 1, 0x40]); // MSB
input.fire([0xb0, 33, 0x20]); // LSB
});
await waitFor(() => {
expect(result.current.cc14?.controller).toBe(1);
expect(result.current.cc14?.value).toBe((0x40 << 7) | 0x20);
});
});
it("ignores non-CC messages", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({ cc14: useCC14("first"), midi: useMidi() }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0x90, 0x40, 0x40]);
});
expect(result.current.cc14).toBeNull();
});
it("returns null when no port is available", { timeout: 5000 }, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useCC14("first"), { wrapper });
expect(result.current).toBeNull();
});
});

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";
import type { CC14Change } from "../../midi-contract/midi-message.types.js";
import { CC14Reducer } from "../../midi-contract/reducers.js";
import type { PortSelector } from "../use-midi-input/use-midi-input.js";
import { useMidiInput } from "../use-midi-input/use-midi-input.js";
/**
* Returns the most recent consolidated `CC14Change` (14-bit CC pair)
* from the selected input, or `null` until one has fired. The hook
* folds CCs 0-31 (MSB) with CCs 32-63 (LSB) via a per-port
* `CC14Reducer`.
*/
export function useCC14(selector: PortSelector): CC14Change | null {
const port = useMidiInput(selector);
const [value, setValue] = useState<CC14Change | null>(null);
const reducerRef = useRef<CC14Reducer | null>(null);
useEffect(() => {
if (port === null) {
reducerRef.current = null;
return;
}
const reducer = new CC14Reducer();
reducerRef.current = reducer;
const off = port.onMessage((msg) => {
if (msg.type !== "controlChange") {
return;
}
const out = reducer.feed(msg);
if (out !== null) {
setValue(out);
}
});
return () => {
off();
reducerRef.current = null;
};
}, [port]);
return value;
}

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,28 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { FakeMidiProvider, makeFakeAccess } from "../test-utils.js";
import { useMidiContextOrThrow } from "./use-midi-context.js";
describe("useMidiContextOrThrow", () => {
it("throws REACT_MIDI.NO_PROVIDER outside <MidiProvider>", {
timeout: 5000,
}, () => {
expect(() => renderHook(() => useMidiContextOrThrow())).toThrow(
/react-midi.no-provider/
);
});
it("returns context value inside provider", { timeout: 5000 }, () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiContextOrThrow(), {
wrapper,
});
expect(result.current.sysex).toBe(false);
// Either loading or granted depending on timing — both are valid.
expect(["loading", "granted"]).toContain(result.current.status);
});
});

View File

@@ -0,0 +1,26 @@
import { useContext } from "react";
import {
REACT_MIDI_CODES,
ReactMidiError,
} from "../error-codes/error-codes.js";
import {
type MidiContextValue,
MidiReactContext,
} from "../provider/midi-context.js";
/**
* Internal helper: returns the live `MidiContextValue` for the
* nearest `<MidiProvider>` ancestor. Throws
* `REACT_MIDI.NO_PROVIDER` if no provider is present so every hook
* surfaces the same diagnostic on misuse.
*/
export function useMidiContextOrThrow(): MidiContextValue {
const ctx = useContext(MidiReactContext);
if (ctx === null) {
throw new ReactMidiError(
REACT_MIDI_CODES.NO_PROVIDER,
"react-midi.no-provider"
);
}
return ctx;
}

View File

@@ -0,0 +1,4 @@
export {
type UseMidiDevicesResult,
useMidiDevices,
} from "./use-midi-devices.js";

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,83 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
makeFakeOutput,
} from "../../test-utils.js";
import { useMidiDevices } from "./use-midi-devices.js";
describe("useMidiDevices", () => {
it("returns initial device lists once granted", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a")],
outputs: [makeFakeOutput("x"), makeFakeOutput("y")],
});
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiDevices(), { wrapper });
await waitFor(() => {
expect(result.current.inputs.length).toBe(1);
expect(result.current.outputs.length).toBe(2);
});
});
it("re-renders on input add", { timeout: 30000 }, async () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiDevices(), { wrapper });
await waitFor(() => {
expect(result.current.inputs.length).toBe(0);
});
await act(async () => {
access.addInput(makeFakeInput("new-1"));
});
await waitFor(() => {
expect(result.current.inputs.length).toBe(1);
});
});
it("re-renders on input remove", { timeout: 30000 }, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a"), makeFakeInput("b")],
});
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiDevices(), { wrapper });
await waitFor(() => {
expect(result.current.inputs.length).toBe(2);
});
await act(async () => {
access.removeInput("a");
});
await waitFor(() => {
expect(result.current.inputs.length).toBe(1);
expect(result.current.inputs[0]?.id).toBe("b");
});
});
it("re-renders on output add", { timeout: 30000 }, async () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiDevices(), { wrapper });
await waitFor(() => {
expect(result.current.outputs.length).toBe(0);
});
await act(async () => {
access.addOutput(makeFakeOutput("new-out"));
});
await waitFor(() => {
expect(result.current.outputs.length).toBe(1);
});
});
});

View File

@@ -0,0 +1,17 @@
import { type UseMidiResult, useMidi } from "../use-midi/use-midi.js";
export interface UseMidiDevicesResult {
readonly inputs: UseMidiResult["inputs"];
readonly outputs: UseMidiResult["outputs"];
}
/**
* Live `{ inputs, outputs }` view that re-renders whenever the
* provider's `stateVersion` advances (i.e. on `statechange`). Thin
* wrapper around `useMidi` for callers that only care about the
* device list and want a narrower API surface.
*/
export function useMidiDevices(): UseMidiDevicesResult {
const { inputs, outputs } = useMidi();
return { inputs, outputs };
}

View File

@@ -0,0 +1 @@
export { type PortSelector, useMidiInput } from "./use-midi-input.js";

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,105 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import type { MidiMessage } from "../../midi-contract/midi-message.types.js";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../../test-utils.js";
import { useMidiInput } from "./use-midi-input.js";
describe("useMidiInput", () => {
it("returns null while loading", { timeout: 5000 }, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiInput("first"), {
wrapper,
});
expect(result.current).toBeNull();
});
it("picks first input with 'first' selector", {
timeout: 30000,
}, async () => {
const inputs = [makeFakeInput("a"), makeFakeInput("b")];
const access = makeFakeAccess({ inputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiInput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current?.id).toBe("a");
});
});
it("picks by id", { timeout: 30000 }, async () => {
const inputs = [makeFakeInput("a"), makeFakeInput("b")];
const access = makeFakeAccess({ inputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiInput("b"), { wrapper });
await waitFor(() => {
expect(result.current?.id).toBe("b");
});
});
it("returns null on unknown id", { timeout: 30000 }, async () => {
const inputs = [makeFakeInput("a")];
const access = makeFakeAccess({ inputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiInput("zzz"), {
wrapper,
});
await waitFor(() => {
// Wait one tick after granted: still null.
expect(result.current).toBeNull();
});
});
it("returns null when access has zero inputs and 'first' is used", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiInput("first"), {
wrapper,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current).toBeNull();
});
it("delivers parsed MidiMessage events", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiInput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current).not.toBeNull();
});
const seen: MidiMessage[] = [];
const off = result.current?.onMessage((m) => seen.push(m));
input.fire([0x91, 0x40, 0x64]);
expect(seen).toEqual([
{ type: "noteOn", channel: 1, note: 64, velocity: 100 },
]);
off?.();
});
});

View File

@@ -0,0 +1,35 @@
import { useMemo } from "react";
import type { MidiPortLike } from "../../midi-contract/midi-message.types.js";
import { adaptInput } from "../../port/web-midi-port.js";
import { useMidiContextOrThrow } from "../use-midi-context.js";
export type PortSelector = string | "first";
/**
* Returns a `MidiPortLike` adapter over a single Web MIDI input.
* Pass an explicit `MIDIInput.id` to pick by id, or `"first"` to
* accept whichever input the access enumerates first. Returns `null`
* when access is not yet granted, when no input matches, or when the
* access has zero inputs.
*
* The returned adapter is memoised against (access, stateVersion,
* resolved port id) so identity is stable until the underlying port
* actually changes.
*/
export function useMidiInput(selector: PortSelector): MidiPortLike | null {
const ctx = useMidiContextOrThrow();
const stateVersion = ctx.stateVersion;
return useMemo<MidiPortLike | null>(() => {
// Touch stateVersion so the memo recomputes on plug/unplug.
void stateVersion;
if (ctx.access === null) {
return null;
}
if (selector === "first") {
const next = ctx.access.inputs.values().next();
return next.done === true ? null : adaptInput(next.value);
}
const target = ctx.access.inputs.get(selector);
return target === undefined ? null : adaptInput(target);
}, [ctx.access, stateVersion, selector]);
}

View File

@@ -0,0 +1,4 @@
export {
type UseMidiMessagesOptions,
useMidiMessages,
} from "./use-midi-messages.js";

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,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../../test-utils.js";
import { useMidi } from "../use-midi/use-midi.js";
import { useMidiMessages } from "./use-midi-messages.js";
describe("useMidiMessages", () => {
it("collects messages into a buffer", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({
buf: useMidiMessages("first", { capacity: 8 }),
midi: useMidi(),
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0x90, 0x40, 0x64]);
});
await waitFor(() => {
expect(result.current.buf.length).toBe(1);
});
expect(result.current.buf[0]?.type).toBe("noteOn");
});
it("respects capacity (oldest dropped)", {
timeout: 30000,
}, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({
buf: useMidiMessages("first", { capacity: 2 }),
midi: useMidi(),
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0x90, 0x40, 0x40]);
input.fire([0x90, 0x41, 0x41]);
input.fire([0x90, 0x42, 0x42]);
});
await waitFor(() => {
expect(result.current.buf.length).toBe(2);
});
const notes = result.current.buf.map((m) =>
m.type === "noteOn" ? m.note : -1
);
expect(notes).toEqual([0x41, 0x42]);
});
it("applies the filter whitelist", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({
buf: useMidiMessages("first", {
filter: ["noteOn"],
capacity: 64,
}),
midi: useMidi(),
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0x90, 0x40, 0x40]); // noteOn
input.fire([0xb0, 0x07, 0x40]); // controlChange — filtered
});
await waitFor(() => {
expect(result.current.buf.length).toBe(1);
});
expect(result.current.buf[0]?.type).toBe("noteOn");
});
it("returns empty buffer when port is null", { timeout: 5000 }, () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={access}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiMessages("first"), {
wrapper,
});
expect(result.current).toEqual([]);
});
it("default capacity is 64", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({ buf: useMidiMessages("first"), midi: useMidi() }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
for (let i = 0; i < 70; i += 1) {
input.fire([0x90, 0x40, 0x40]);
}
});
await waitFor(() => {
expect(result.current.buf.length).toBe(64);
});
});
});

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import type {
MidiMessage,
MidiMessageType,
} from "../../midi-contract/midi-message.types.js";
import type { PortSelector } from "../use-midi-input/use-midi-input.js";
import { useMidiInput } from "../use-midi-input/use-midi-input.js";
export interface UseMidiMessagesOptions {
/** Maximum number of messages kept in the ring buffer. Defaults to `64`. */
readonly capacity?: number;
/**
* Optional whitelist of message types. If supplied, only messages
* whose `type` is in the list are appended to the buffer. The
* empty array degenerates to "accept nothing".
*/
readonly filter?: readonly MidiMessageType[];
}
/**
* Returns the most recent N parsed `MidiMessage` events delivered
* through the selected input. The returned array identity changes on
* every accepted message — pass it directly to a list renderer with
* a stable `key` (the message has no natural id; the consumer should
* append an index or a render-time counter).
*/
export function useMidiMessages(
selector: PortSelector,
options?: UseMidiMessagesOptions
): readonly MidiMessage[] {
const port = useMidiInput(selector);
const capacity = options?.capacity ?? 64;
const filter = options?.filter;
const _filterKey = filter === undefined ? "" : filter.join(",");
const [buf, setBuf] = useState<readonly MidiMessage[]>([]);
useEffect(() => {
if (port === null) {
return;
}
const accepts =
filter === undefined
? null
: new Set<MidiMessageType>(filter as MidiMessageType[]);
const off = port.onMessage((msg) => {
if (accepts !== null && !accepts.has(msg.type)) {
return;
}
setBuf((prev) => {
const next =
prev.length < capacity
? [...prev, msg]
: [...prev.slice(prev.length - capacity + 1), msg];
return next;
});
});
return off;
}, [port, capacity, filter]);
return buf;
}

View File

@@ -0,0 +1 @@
export { type MidiOutputHandle, useMidiOutput } from "./use-midi-output.js";

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,120 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeOutput,
} from "../../test-utils.js";
import { useMidiOutput } from "./use-midi-output.js";
describe("useMidiOutput", () => {
it("returns null while loading", { timeout: 5000 }, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
expect(result.current).toBeNull();
});
it("picks first output with 'first' selector", {
timeout: 30000,
}, async () => {
const outputs = [makeFakeOutput("a"), makeFakeOutput("b")];
const access = makeFakeAccess({ outputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current?.id).toBe("a");
});
});
it("picks by id", { timeout: 30000 }, async () => {
const outputs = [makeFakeOutput("a"), makeFakeOutput("b")];
const access = makeFakeAccess({ outputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("b"), { wrapper });
await waitFor(() => {
expect(result.current?.id).toBe("b");
});
});
it("returns null on unknown id", { timeout: 30000 }, async () => {
const outputs = [makeFakeOutput("a")];
const access = makeFakeAccess({ outputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("zzz"), {
wrapper,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current).toBeNull();
});
it("returns null when no outputs exist and 'first' is used", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current).toBeNull();
});
it("send forwards raw bytes", { timeout: 30000 }, async () => {
const output = makeFakeOutput("a");
const access = makeFakeAccess({ outputs: [output] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current).not.toBeNull();
});
result.current?.send(new Uint8Array([0x90, 0x40, 0x40]));
expect(output.sent).toEqual([[0x90, 0x40, 0x40]]);
});
it("sendMessage encodes via encodeMessage", {
timeout: 30000,
}, async () => {
const output = makeFakeOutput("a");
const access = makeFakeAccess({ outputs: [output] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current).not.toBeNull();
});
result.current?.sendMessage({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
expect(output.sent).toEqual([[0x90, 0x3c, 0x64]]);
});
});

View File

@@ -0,0 +1,39 @@
import { useMemo } from "react";
import type { MidiMessage } from "../../midi-contract/midi-message.types.js";
import { adaptOutput } from "../../port/web-midi-port.js";
import { useMidiContextOrThrow } from "../use-midi-context.js";
import type { PortSelector } from "../use-midi-input/use-midi-input.js";
export interface MidiOutputHandle {
readonly id: string;
readonly name: string;
readonly manufacturer: string | undefined;
readonly state: "connected" | "disconnected";
send(bytes: Uint8Array): void;
sendMessage(msg: MidiMessage): void;
}
/**
* Returns a typed output handle for the selected Web MIDI output, or
* `null` while access is loading / the selector misses. The handle's
* `sendMessage` encodes a `MidiMessage` via `encodeMessage` and
* forwards the resulting bytes to the underlying `MIDIOutput.send`;
* `send(bytes)` is the raw escape-hatch (typically used for SysEx
* pass-through).
*/
export function useMidiOutput(selector: PortSelector): MidiOutputHandle | null {
const ctx = useMidiContextOrThrow();
const stateVersion = ctx.stateVersion;
return useMemo<MidiOutputHandle | null>(() => {
void stateVersion;
if (ctx.access === null) {
return null;
}
if (selector === "first") {
const next = ctx.access.outputs.values().next();
return next.done === true ? null : adaptOutput(next.value);
}
const target = ctx.access.outputs.get(selector);
return target === undefined ? null : adaptOutput(target);
}, [ctx.access, stateVersion, selector]);
}

View File

@@ -0,0 +1 @@
export { type UseMidiResult, useMidi } from "./use-midi.js";

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,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
makeFakeOutput,
} from "../../test-utils.js";
import { useMidi } from "./use-midi.js";
describe("useMidi", () => {
it("throws REACT_MIDI.NO_PROVIDER outside provider", {
timeout: 5000,
}, () => {
expect(() => renderHook(() => useMidi())).toThrow(
/react-midi.no-provider/
);
});
it("returns inputs/outputs once access resolves", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess({
inputs: [makeFakeInput("a"), makeFakeInput("b")],
outputs: [makeFakeOutput("x")],
});
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidi(), { wrapper });
await waitFor(() => {
expect(result.current.status).toBe("granted");
});
expect(result.current.inputs.map((p) => p.id)).toEqual(["a", "b"]);
expect(result.current.outputs.map((p) => p.id)).toEqual(["x"]);
expect(result.current.error).toBeNull();
});
it("returns empty arrays during loading", { timeout: 5000 }, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useMidi(), { wrapper });
expect(result.current.status).toBe("loading");
expect(result.current.inputs).toEqual([]);
expect(result.current.outputs).toEqual([]);
});
it("re-materialises arrays on statechange", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidi(), { wrapper });
await waitFor(() => {
expect(result.current.status).toBe("granted");
});
expect(result.current.inputs.length).toBe(0);
access.addInput(makeFakeInput("new"));
await waitFor(() => {
expect(result.current.inputs.length).toBe(1);
});
});
});

View File

@@ -0,0 +1,50 @@
import { useMemo } from "react";
import type {
WebMIDIAccess,
WebMIDIInput,
WebMIDIOutput,
} from "../../port/web-midi.types.js";
import type { MidiStatus } from "../../provider/midi-context.js";
import { useMidiContextOrThrow } from "../use-midi-context.js";
export interface UseMidiResult {
readonly status: MidiStatus;
readonly access: WebMIDIAccess | null;
readonly inputs: readonly WebMIDIInput[];
readonly outputs: readonly WebMIDIOutput[];
readonly error: Error | null;
}
/**
* Returns the live `{ status, access, inputs, outputs, error }`
* snapshot from the nearest `<MidiProvider>`. The `inputs` and
* `outputs` arrays are materialised from the underlying maps on
* every `stateVersion` bump (i.e. on plug/unplug events), so the
* caller sees a fresh array identity exactly when the device list
* actually changes.
*/
export function useMidi(): UseMidiResult {
const ctx = useMidiContextOrThrow();
const stateVersion = ctx.stateVersion;
const inputs = useMemo<readonly WebMIDIInput[]>(() => {
void stateVersion;
if (ctx.access === null) {
return [];
}
return Array.from(ctx.access.inputs.values());
}, [ctx.access, stateVersion]);
const outputs = useMemo<readonly WebMIDIOutput[]>(() => {
void stateVersion;
if (ctx.access === null) {
return [];
}
return Array.from(ctx.access.outputs.values());
}, [ctx.access, stateVersion]);
return {
status: ctx.status,
access: ctx.access,
inputs,
outputs,
error: ctx.error,
};
}

View File

@@ -0,0 +1 @@
export { useNRPN } from "./use-nrpn.js";

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,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../../test-utils.js";
import { useMidi } from "../use-midi/use-midi.js";
import { useNRPN } from "./use-nrpn.js";
describe("useNRPN", () => {
it("returns null until a complete dance arrives", {
timeout: 30000,
}, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({ nrpn: useNRPN("first"), midi: useMidi() }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
expect(result.current.midi.inputs.length).toBe(1);
});
// Give the subscription effect a tick to attach.
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0xb0, 99, 0x20]);
input.fire([0xb0, 98, 0x10]);
input.fire([0xb0, 6, 0x40]);
});
expect(result.current.nrpn).toBeNull();
await act(async () => {
input.fire([0xb0, 38, 0x00]);
});
await waitFor(() => {
expect(result.current.nrpn).toEqual({
type: "nrpnChange",
channel: 0,
paramHigh: 0x20,
paramLow: 0x10,
value: 0x40 << 7,
});
});
});
it("ignores non-CC messages", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({ nrpn: useNRPN("first"), midi: useMidi() }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0x90, 0x40, 0x40]); // noteOn
});
expect(result.current.nrpn).toBeNull();
});
it("returns null when no port is available", { timeout: 5000 }, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useNRPN("first"), { wrapper });
expect(result.current).toBeNull();
});
});

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from "react";
import type { NRPNChange } from "../../midi-contract/midi-message.types.js";
import { NRPNReducer } from "../../midi-contract/reducers.js";
import type { PortSelector } from "../use-midi-input/use-midi-input.js";
import { useMidiInput } from "../use-midi-input/use-midi-input.js";
/**
* Returns the most recent consolidated `NRPNChange` event from the
* selected input, or `null` until one has fired. The four-CC
* sequence (CC 99/98/6/38) is folded by a per-hook `NRPNReducer`
* that is created once per port and reset whenever the underlying
* port identity changes.
*/
export function useNRPN(selector: PortSelector): NRPNChange | null {
const port = useMidiInput(selector);
const [value, setValue] = useState<NRPNChange | null>(null);
const reducerRef = useRef<NRPNReducer | null>(null);
useEffect(() => {
if (port === null) {
reducerRef.current = null;
return;
}
const reducer = new NRPNReducer();
reducerRef.current = reducer;
const off = port.onMessage((msg) => {
if (msg.type !== "controlChange") {
return;
}
const out = reducer.feed(msg);
if (out !== null) {
setValue(out);
}
});
return () => {
off();
reducerRef.current = null;
};
}, [port]);
return value;
}

View File

@@ -0,0 +1 @@
export { useRPN } from "./use-rpn.js";

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,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../../test-utils.js";
import { useMidi } from "../use-midi/use-midi.js";
import { useRPN } from "./use-rpn.js";
describe("useRPN", () => {
it("emits one event per dance", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({ rpn: useRPN("first"), midi: useMidi() }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0xb0, 101, 0x00]);
input.fire([0xb0, 100, 0x00]);
input.fire([0xb0, 6, 0x02]);
input.fire([0xb0, 38, 0x00]);
});
await waitFor(() => {
expect(result.current.rpn?.paramHigh).toBe(0);
expect(result.current.rpn?.paramLow).toBe(0);
expect(result.current.rpn?.value).toBe(0x02 << 7);
});
});
it("ignores non-CC messages", { timeout: 30000 }, async () => {
const input = makeFakeInput("a");
const access = makeFakeAccess({ inputs: [input] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(
() => ({ rpn: useRPN("first"), midi: useMidi() }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.midi.status).toBe("granted");
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
input.fire([0x90, 0x40, 0x40]);
});
expect(result.current.rpn).toBeNull();
});
it("returns null when no port is available", { timeout: 5000 }, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useRPN("first"), { wrapper });
expect(result.current).toBeNull();
});
});

View File

@@ -0,0 +1,40 @@
import { useEffect, useRef, useState } from "react";
import type { RPNChange } from "../../midi-contract/midi-message.types.js";
import { RPNReducer } from "../../midi-contract/reducers.js";
import type { PortSelector } from "../use-midi-input/use-midi-input.js";
import { useMidiInput } from "../use-midi-input/use-midi-input.js";
/**
* Returns the most recent consolidated `RPNChange` from the
* selected input, or `null` until one has fired. The four-CC
* sequence (CC 101/100/6/38) is folded by a per-hook `RPNReducer`.
*/
export function useRPN(selector: PortSelector): RPNChange | null {
const port = useMidiInput(selector);
const [value, setValue] = useState<RPNChange | null>(null);
const reducerRef = useRef<RPNReducer | null>(null);
useEffect(() => {
if (port === null) {
reducerRef.current = null;
return;
}
const reducer = new RPNReducer();
reducerRef.current = reducer;
const off = port.onMessage((msg) => {
if (msg.type !== "controlChange") {
return;
}
const out = reducer.feed(msg);
if (out !== null) {
setValue(out);
}
});
return () => {
off();
reducerRef.current = null;
};
}, [port]);
return value;
}

View File

@@ -0,0 +1,61 @@
export {
MidiActivityIndicator,
type MidiActivityIndicatorProps,
type MidiDeviceKind,
MidiDeviceList,
type MidiDeviceListProps,
type MidiDeviceRowRenderProps,
} from "./components/index.js";
export {
REACT_MIDI_CODES,
REACT_MIDI_ERROR_METADATA,
ReactMidiError,
type ReactMidiErrorCode,
type ReactMidiErrorMetadata,
registerReactMidiErrorCodes,
} from "./error-codes/index.js";
export {
type MidiOutputHandle,
type PortSelector,
type UseMidiDevicesResult,
type UseMidiMessagesOptions,
type UseMidiResult,
useCC14,
useMidi,
useMidiContextOrThrow,
useMidiDevices,
useMidiInput,
useMidiMessages,
useMidiOutput,
useNRPN,
useRPN,
} from "./hooks/index.js";
export {
type CC14Change,
CC14Reducer,
type Channel,
decodeMessage,
encodeMessage,
type MidiMessage,
type MidiMessageType,
type MidiPortLike,
type NRPNChange,
NRPNReducer,
type RPNChange,
RPNReducer,
type Unsubscribe,
} from "./midi-contract/index.js";
export {
type MidiContextValue,
MidiProvider,
type MidiProviderProps,
MidiReactContext,
MidiRequestContext,
type MidiStatus,
} from "./provider/index.js";
export {
messagesReceivedCounter,
messagesSentCounter,
recordReceived,
recordSent,
} from "./telemetry/index.js";

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,172 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { decodeMessage, encodeMessage } from "./codec.js";
import type { MidiMessage } from "./midi-message.types.js";
describe("decodeMessage", () => {
it("decodes noteOn", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0x91, 0x40, 0x64]));
expect(r?.msg).toEqual({
type: "noteOn",
channel: 1,
note: 64,
velocity: 100,
});
expect(r?.next).toBe(3);
});
it("decodes noteOff", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0x82, 0x40, 0x10]));
expect(r?.msg).toEqual({
type: "noteOff",
channel: 2,
note: 64,
velocity: 16,
});
});
it("normalises noteOn velocity=0 to noteOff", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0x90, 0x40, 0x00]));
expect(r?.msg.type).toBe("noteOff");
});
it("decodes polyAftertouch", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0xa3, 0x10, 0x40]));
expect(r?.msg).toEqual({
type: "polyAftertouch",
channel: 3,
note: 16,
pressure: 64,
});
});
it("decodes controlChange", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0xb0, 0x07, 0x7f]));
expect(r?.msg).toEqual({
type: "controlChange",
channel: 0,
controller: 7,
value: 127,
});
});
it("decodes programChange", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0xc4, 0x05]));
expect(r?.msg).toEqual({
type: "programChange",
channel: 4,
program: 5,
});
expect(r?.next).toBe(2);
});
it("decodes channelPressure", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0xd2, 0x60]));
expect(r?.msg).toEqual({
type: "channelPressure",
channel: 2,
pressure: 96,
});
});
it("decodes pitchBend", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0xe0, 0x00, 0x40]));
expect(r?.msg).toEqual({
type: "pitchBend",
channel: 0,
value: 0x40 << 7,
});
});
it("decodes SysEx with terminator", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0xf0, 0x01, 0x02, 0xf7]));
expect(r?.msg.type).toBe("sysEx");
if (r?.msg.type === "sysEx") {
expect(Array.from(r.msg.data)).toEqual([0x01, 0x02]);
}
expect(r?.next).toBe(4);
});
it("returns null on unterminated SysEx", { timeout: 5000 }, () => {
const r = decodeMessage(new Uint8Array([0xf0, 0x7e, 0x00]));
expect(r).toBeNull();
});
it("decodes real-time clock/start/continue/stop/active/reset", {
timeout: 5000,
}, () => {
expect(decodeMessage(new Uint8Array([0xf8]))?.msg.type).toBe("clock");
expect(decodeMessage(new Uint8Array([0xfa]))?.msg.type).toBe("start");
expect(decodeMessage(new Uint8Array([0xfb]))?.msg.type).toBe(
"continue"
);
expect(decodeMessage(new Uint8Array([0xfc]))?.msg.type).toBe("stop");
expect(decodeMessage(new Uint8Array([0xfe]))?.msg.type).toBe(
"activeSensing"
);
expect(decodeMessage(new Uint8Array([0xff]))?.msg.type).toBe(
"systemReset"
);
});
it("returns null at end of buffer", { timeout: 5000 }, () => {
expect(decodeMessage(new Uint8Array([]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0x90]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0x90, 0x40]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0xa0, 0x40]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0xb0, 0x40]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0xc0]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0xd0]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0xe0, 0x00]), 0)).toBeNull();
expect(decodeMessage(new Uint8Array([0x80, 0x40]), 0)).toBeNull();
});
it("returns null for non-status byte (high bit clear)", {
timeout: 5000,
}, () => {
expect(decodeMessage(new Uint8Array([0x40, 0x40, 0x40]))).toBeNull();
});
it("returns null on unknown system byte", { timeout: 5000 }, () => {
expect(decodeMessage(new Uint8Array([0xf1]))).toBeNull();
});
});
describe("encodeMessage", () => {
it("encode→decode round-trips every variant", { timeout: 5000 }, () => {
const cases: MidiMessage[] = [
{ type: "noteOn", channel: 1, note: 64, velocity: 100 },
{ type: "noteOff", channel: 2, note: 64, velocity: 16 },
{
type: "polyAftertouch",
channel: 3,
note: 16,
pressure: 64,
},
{
type: "controlChange",
channel: 0,
controller: 7,
value: 127,
},
{ type: "programChange", channel: 4, program: 5 },
{ type: "channelPressure", channel: 2, pressure: 96 },
{ type: "pitchBend", channel: 0, value: 8192 },
{
type: "sysEx",
channel: null,
data: new Uint8Array([0x01, 0x02, 0x03]),
},
{ type: "clock", channel: null },
{ type: "start", channel: null },
{ type: "continue", channel: null },
{ type: "stop", channel: null },
{ type: "activeSensing", channel: null },
{ type: "systemReset", channel: null },
];
for (const msg of cases) {
const bytes = encodeMessage(msg);
const decoded = decodeMessage(bytes);
expect(decoded?.msg.type).toBe(msg.type);
}
});
});

View File

@@ -0,0 +1,196 @@
import type { Channel, MidiMessage } from "./midi-message.types.js";
const ASSERT_CHANNEL = 0xf;
const STATUS_HIGH_NIBBLE = 0xf0;
const STATUS_LOW_NIBBLE = 0x0f;
const SYSEX_START = 0xf0;
const SYSEX_END = 0xf7;
function asChannel(n: number): Channel {
return (n & ASSERT_CHANNEL) as Channel;
}
/**
* Decode a single MIDI 1.0 message from a raw `Uint8Array`. Returns
* the parsed `MidiMessage` plus the byte offset immediately after
* the consumed message. SysEx without a terminating `0xF7` returns
* `null` (caller treats as malformed). Status byte must be in the
* high half of the byte (`>= 0x80`).
*/
export function decodeMessage(
bytes: Uint8Array,
offset = 0
): {
readonly msg: MidiMessage;
readonly next: number;
} | null {
if (offset >= bytes.length) {
return null;
}
const status = bytes[offset];
if (status === undefined || (status & 0x80) === 0) {
return null;
}
const high = status & STATUS_HIGH_NIBBLE;
const ch = asChannel(status & STATUS_LOW_NIBBLE);
if (high === 0x80) {
const d1 = bytes[offset + 1];
const d2 = bytes[offset + 2];
if (d1 === undefined || d2 === undefined) return null;
return {
msg: { type: "noteOff", channel: ch, note: d1, velocity: d2 },
next: offset + 3,
};
}
if (high === 0x90) {
const d1 = bytes[offset + 1];
const d2 = bytes[offset + 2];
if (d1 === undefined || d2 === undefined) return null;
const type = d2 === 0 ? "noteOff" : "noteOn";
return {
msg: { type, channel: ch, note: d1, velocity: d2 } as MidiMessage,
next: offset + 3,
};
}
if (high === 0xa0) {
const d1 = bytes[offset + 1];
const d2 = bytes[offset + 2];
if (d1 === undefined || d2 === undefined) return null;
return {
msg: {
type: "polyAftertouch",
channel: ch,
note: d1,
pressure: d2,
},
next: offset + 3,
};
}
if (high === 0xb0) {
const d1 = bytes[offset + 1];
const d2 = bytes[offset + 2];
if (d1 === undefined || d2 === undefined) return null;
return {
msg: {
type: "controlChange",
channel: ch,
controller: d1,
value: d2,
},
next: offset + 3,
};
}
if (high === 0xc0) {
const d1 = bytes[offset + 1];
if (d1 === undefined) return null;
return {
msg: { type: "programChange", channel: ch, program: d1 },
next: offset + 2,
};
}
if (high === 0xd0) {
const d1 = bytes[offset + 1];
if (d1 === undefined) return null;
return {
msg: { type: "channelPressure", channel: ch, pressure: d1 },
next: offset + 2,
};
}
if (high === 0xe0) {
const d1 = bytes[offset + 1];
const d2 = bytes[offset + 2];
if (d1 === undefined || d2 === undefined) return null;
return {
msg: { type: "pitchBend", channel: ch, value: (d2 << 7) | d1 },
next: offset + 3,
};
}
// System common / real-time
if (status === SYSEX_START) {
const end = bytes.indexOf(SYSEX_END, offset + 1);
if (end === -1) return null;
return {
msg: {
type: "sysEx",
channel: null,
data: bytes.slice(offset + 1, end),
},
next: end + 1,
};
}
if (status === 0xf8) {
return { msg: { type: "clock", channel: null }, next: offset + 1 };
}
if (status === 0xfa) {
return { msg: { type: "start", channel: null }, next: offset + 1 };
}
if (status === 0xfb) {
return { msg: { type: "continue", channel: null }, next: offset + 1 };
}
if (status === 0xfc) {
return { msg: { type: "stop", channel: null }, next: offset + 1 };
}
if (status === 0xfe) {
return {
msg: { type: "activeSensing", channel: null },
next: offset + 1,
};
}
if (status === 0xff) {
return {
msg: { type: "systemReset", channel: null },
next: offset + 1,
};
}
return null;
}
/**
* Encode a single `MidiMessage` to its on-wire byte sequence.
*/
export function encodeMessage(msg: MidiMessage): Uint8Array {
switch (msg.type) {
case "noteOff":
return new Uint8Array([0x80 | msg.channel, msg.note, msg.velocity]);
case "noteOn":
return new Uint8Array([0x90 | msg.channel, msg.note, msg.velocity]);
case "polyAftertouch":
return new Uint8Array([0xa0 | msg.channel, msg.note, msg.pressure]);
case "controlChange":
return new Uint8Array([
0xb0 | msg.channel,
msg.controller,
msg.value,
]);
case "programChange":
return new Uint8Array([0xc0 | msg.channel, msg.program]);
case "channelPressure":
return new Uint8Array([0xd0 | msg.channel, msg.pressure]);
case "pitchBend":
return new Uint8Array([
0xe0 | msg.channel,
msg.value & 0x7f,
(msg.value >> 7) & 0x7f,
]);
case "sysEx": {
const out = new Uint8Array(msg.data.length + 2);
out[0] = SYSEX_START;
out.set(msg.data, 1);
out[out.length - 1] = SYSEX_END;
return out;
}
case "clock":
return new Uint8Array([0xf8]);
case "start":
return new Uint8Array([0xfa]);
case "continue":
return new Uint8Array([0xfb]);
case "stop":
return new Uint8Array([0xfc]);
case "activeSensing":
return new Uint8Array([0xfe]);
case "systemReset":
return new Uint8Array([0xff]);
}
}

View File

@@ -0,0 +1,26 @@
export { decodeMessage, encodeMessage } from "./codec.js";
export type {
ActiveSensingMessage,
CC14Change,
Channel,
ChannelPressureMessage,
ClockMessage,
ContinueMessage,
ControlChangeMessage,
MidiMessage,
MidiMessageType,
MidiPortLike,
NoteOffMessage,
NoteOnMessage,
NRPNChange,
PitchBendMessage,
PolyAftertouchMessage,
ProgramChangeMessage,
RPNChange,
StartMessage,
StopMessage,
SysExMessage,
SystemResetMessage,
Unsubscribe,
} from "./midi-message.types.js";
export { CC14Reducer, NRPNReducer, RPNReducer } from "./reducers.js";

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,161 @@
/**
* Local mirror of the `@suaveplan/midi` `MidiMessage` discriminated
* union. This file exists so `@suaveplan/react-midi` can be built in
* isolation from `feat/midi`; once `@suaveplan/midi` lands the union
* here SHALL be replaced by a re-export from that package without any
* shape change. Every variant carries `type` plus type-specific
* fields parsed from raw bytes; channel-voice messages carry
* `channel: 0..15`; system messages carry `channel: null`.
*/
export type Channel =
| 0
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 13
| 14
| 15;
export interface NoteOnMessage {
readonly type: "noteOn";
readonly channel: Channel;
readonly note: number;
readonly velocity: number;
}
export interface NoteOffMessage {
readonly type: "noteOff";
readonly channel: Channel;
readonly note: number;
readonly velocity: number;
}
export interface PolyAftertouchMessage {
readonly type: "polyAftertouch";
readonly channel: Channel;
readonly note: number;
readonly pressure: number;
}
export interface ControlChangeMessage {
readonly type: "controlChange";
readonly channel: Channel;
readonly controller: number;
readonly value: number;
}
export interface ProgramChangeMessage {
readonly type: "programChange";
readonly channel: Channel;
readonly program: number;
}
export interface ChannelPressureMessage {
readonly type: "channelPressure";
readonly channel: Channel;
readonly pressure: number;
}
export interface PitchBendMessage {
readonly type: "pitchBend";
readonly channel: Channel;
readonly value: number;
}
export interface SysExMessage {
readonly type: "sysEx";
readonly channel: null;
readonly data: Uint8Array;
}
export interface ClockMessage {
readonly type: "clock";
readonly channel: null;
}
export interface StartMessage {
readonly type: "start";
readonly channel: null;
}
export interface ContinueMessage {
readonly type: "continue";
readonly channel: null;
}
export interface StopMessage {
readonly type: "stop";
readonly channel: null;
}
export interface ActiveSensingMessage {
readonly type: "activeSensing";
readonly channel: null;
}
export interface SystemResetMessage {
readonly type: "systemReset";
readonly channel: null;
}
export type MidiMessage =
| NoteOnMessage
| NoteOffMessage
| PolyAftertouchMessage
| ControlChangeMessage
| ProgramChangeMessage
| ChannelPressureMessage
| PitchBendMessage
| SysExMessage
| ClockMessage
| StartMessage
| ContinueMessage
| StopMessage
| ActiveSensingMessage
| SystemResetMessage;
export type MidiMessageType = MidiMessage["type"];
export interface NRPNChange {
readonly type: "nrpnChange";
readonly channel: Channel;
readonly paramHigh: number;
readonly paramLow: number;
readonly value: number;
}
export interface RPNChange {
readonly type: "rpnChange";
readonly channel: Channel;
readonly paramHigh: number;
readonly paramLow: number;
readonly value: number;
}
export interface CC14Change {
readonly type: "cc14Change";
readonly channel: Channel;
readonly controller: number;
readonly value: number;
}
export type Unsubscribe = () => void;
export interface MidiPortLike {
readonly id: string;
readonly name: string;
readonly manufacturer: string | undefined;
readonly state: "connected" | "disconnected";
send(bytes: Uint8Array): void;
onMessage(handler: (msg: MidiMessage) => void): Unsubscribe;
}

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,115 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ControlChangeMessage } from "./midi-message.types.js";
import { CC14Reducer, NRPNReducer, RPNReducer } from "./reducers.js";
function cc(
channel: 0 | 1 | 2,
controller: number,
value: number
): ControlChangeMessage {
return { type: "controlChange", channel, controller, value };
}
describe("NRPNReducer", () => {
it("emits one event for a four-CC dance", { timeout: 5000 }, () => {
const r = new NRPNReducer();
expect(r.feed(cc(0, 99, 0x20))).toBeNull();
expect(r.feed(cc(0, 98, 0x10))).toBeNull();
expect(r.feed(cc(0, 6, 0x40))).toBeNull();
const out = r.feed(cc(0, 38, 0x00));
expect(out).toEqual({
type: "nrpnChange",
channel: 0,
paramHigh: 0x20,
paramLow: 0x10,
value: (0x40 << 7) | 0x00,
});
});
it("ignores unrelated CCs", { timeout: 5000 }, () => {
const r = new NRPNReducer();
expect(r.feed(cc(0, 7, 100))).toBeNull();
});
it("resets when a fresh paramHigh arrives", { timeout: 5000 }, () => {
const r = new NRPNReducer();
r.feed(cc(0, 99, 0x20));
r.feed(cc(0, 98, 0x10));
// restart
r.feed(cc(0, 99, 0x40));
r.feed(cc(0, 98, 0x00));
r.feed(cc(0, 6, 0x11));
const out = r.feed(cc(0, 38, 0x22));
expect(out?.paramHigh).toBe(0x40);
expect(out?.paramLow).toBe(0x00);
});
});
describe("RPNReducer", () => {
it("emits one event for a four-CC dance", { timeout: 5000 }, () => {
const r = new RPNReducer();
r.feed(cc(1, 101, 0x05));
r.feed(cc(1, 100, 0x06));
r.feed(cc(1, 6, 0x10));
const out = r.feed(cc(1, 38, 0x20));
expect(out).toEqual({
type: "rpnChange",
channel: 1,
paramHigh: 0x05,
paramLow: 0x06,
value: (0x10 << 7) | 0x20,
});
});
it("ignores unrelated CCs", { timeout: 5000 }, () => {
const r = new RPNReducer();
expect(r.feed(cc(0, 7, 100))).toBeNull();
});
it("resets when a fresh paramHigh arrives", { timeout: 5000 }, () => {
const r = new RPNReducer();
r.feed(cc(0, 101, 0x05));
r.feed(cc(0, 100, 0x06));
r.feed(cc(0, 101, 0x07));
r.feed(cc(0, 100, 0x08));
r.feed(cc(0, 6, 0x09));
const out = r.feed(cc(0, 38, 0x0a));
expect(out?.paramHigh).toBe(0x07);
});
});
describe("CC14Reducer", () => {
it("pairs MSB+LSB into a single event", { timeout: 5000 }, () => {
const r = new CC14Reducer();
expect(r.feed(cc(0, 1, 0x40))).toBeNull();
const out = r.feed(cc(0, 33, 0x20));
expect(out).toEqual({
type: "cc14Change",
channel: 0,
controller: 1,
value: (0x40 << 7) | 0x20,
});
});
it("accepts LSB before MSB", { timeout: 5000 }, () => {
const r = new CC14Reducer();
expect(r.feed(cc(0, 33, 0x20))).toBeNull();
const out = r.feed(cc(0, 1, 0x40));
expect(out?.value).toBe((0x40 << 7) | 0x20);
});
it("ignores CCs outside 0-63", { timeout: 5000 }, () => {
const r = new CC14Reducer();
expect(r.feed(cc(0, 64, 1))).toBeNull();
});
it("keeps per-channel state independent", { timeout: 5000 }, () => {
const r = new CC14Reducer();
r.feed(cc(0, 1, 0x40));
r.feed(cc(1, 1, 0x10));
const a = r.feed(cc(0, 33, 0x20));
const b = r.feed(cc(1, 33, 0x21));
expect(a?.value).toBe((0x40 << 7) | 0x20);
expect(b?.value).toBe((0x10 << 7) | 0x21);
});
});

View File

@@ -0,0 +1,173 @@
import type {
CC14Change,
Channel,
ControlChangeMessage,
NRPNChange,
RPNChange,
} from "./midi-message.types.js";
const CC_NRPN_HIGH = 99;
const CC_NRPN_LOW = 98;
const CC_RPN_HIGH = 101;
const CC_RPN_LOW = 100;
const CC_DATA_HIGH = 6;
const CC_DATA_LOW = 38;
interface PartialState {
paramHigh: number | null;
paramLow: number | null;
valueHigh: number | null;
valueLow: number | null;
}
function emptyState(): PartialState {
return {
paramHigh: null,
paramLow: null,
valueHigh: null,
valueLow: null,
};
}
/**
* NRPN four-CC reducer. Feed channel-voice CC messages into `feed`;
* when CC 99/98/6/38 have all arrived on the same channel, the
* reducer emits a single `NRPNChange` event and resets that channel's
* partial state. Incomplete dances (e.g. missing LSB) remain pending
* until completion or are dropped when a fresh paramHigh arrives.
*/
export class NRPNReducer {
private readonly partials = new Map<Channel, PartialState>();
feed(msg: ControlChangeMessage): NRPNChange | null {
const state = this.partials.get(msg.channel) ?? emptyState();
switch (msg.controller) {
case CC_NRPN_HIGH:
state.paramHigh = msg.value;
state.paramLow = null;
state.valueHigh = null;
state.valueLow = null;
break;
case CC_NRPN_LOW:
state.paramLow = msg.value;
break;
case CC_DATA_HIGH:
state.valueHigh = msg.value;
break;
case CC_DATA_LOW:
state.valueLow = msg.value;
break;
default:
this.partials.set(msg.channel, state);
return null;
}
this.partials.set(msg.channel, state);
if (
state.paramHigh !== null &&
state.paramLow !== null &&
state.valueHigh !== null &&
state.valueLow !== null
) {
const out: NRPNChange = {
type: "nrpnChange",
channel: msg.channel,
paramHigh: state.paramHigh,
paramLow: state.paramLow,
value: (state.valueHigh << 7) | state.valueLow,
};
this.partials.set(msg.channel, emptyState());
return out;
}
return null;
}
}
/**
* RPN four-CC reducer. Identical control flow as `NRPNReducer` but
* uses CC 101/100 for the parameter MSB/LSB selectors.
*/
export class RPNReducer {
private readonly partials = new Map<Channel, PartialState>();
feed(msg: ControlChangeMessage): RPNChange | null {
const state = this.partials.get(msg.channel) ?? emptyState();
switch (msg.controller) {
case CC_RPN_HIGH:
state.paramHigh = msg.value;
state.paramLow = null;
state.valueHigh = null;
state.valueLow = null;
break;
case CC_RPN_LOW:
state.paramLow = msg.value;
break;
case CC_DATA_HIGH:
state.valueHigh = msg.value;
break;
case CC_DATA_LOW:
state.valueLow = msg.value;
break;
default:
this.partials.set(msg.channel, state);
return null;
}
this.partials.set(msg.channel, state);
if (
state.paramHigh !== null &&
state.paramLow !== null &&
state.valueHigh !== null &&
state.valueLow !== null
) {
const out: RPNChange = {
type: "rpnChange",
channel: msg.channel,
paramHigh: state.paramHigh,
paramLow: state.paramLow,
value: (state.valueHigh << 7) | state.valueLow,
};
this.partials.set(msg.channel, emptyState());
return out;
}
return null;
}
}
/**
* 14-bit CC reducer. Pairs CCs 0-31 with CCs 32-63 (MSB+LSB pair on
* the same channel). On reception of either half, the reducer
* remembers the partial value and, once both halves are observed,
* emits a single `CC14Change` event with the combined 14-bit value.
*/
export class CC14Reducer {
private readonly msbs = new Map<string, number>();
private readonly lsbs = new Map<string, number>();
feed(msg: ControlChangeMessage): CC14Change | null {
const isMsb = msg.controller < 32;
const isLsb = msg.controller >= 32 && msg.controller < 64;
if (!isMsb && !isLsb) {
return null;
}
const baseController = isMsb ? msg.controller : msg.controller - 32;
const key = `${msg.channel}:${baseController}`;
if (isMsb) {
this.msbs.set(key, msg.value);
} else {
this.lsbs.set(key, msg.value);
}
const msb = this.msbs.get(key);
const lsb = this.lsbs.get(key);
if (msb !== undefined && lsb !== undefined) {
const out: CC14Change = {
type: "cc14Change",
channel: msg.channel,
controller: baseController,
value: (msb << 7) | lsb,
};
this.msbs.delete(key);
this.lsbs.delete(key);
return out;
}
return null;
}
}

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,162 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { MidiMessage } from "../midi-contract/midi-message.types.js";
import type {
WebMIDIInput,
WebMIDIMessageEvent,
WebMIDIOutput,
} from "./web-midi.types.js";
import { adaptInput, adaptOutput } from "./web-midi-port.js";
function makeFakeInput(): WebMIDIInput & {
fire(bytes: number[]): void;
} {
let handler: ((ev: WebMIDIMessageEvent) => void) | null = null;
return {
id: "in-1",
name: "Fake Input",
manufacturer: "Suaveplan",
state: "connected",
type: "input",
get onmidimessage() {
return handler;
},
set onmidimessage(fn) {
handler = fn;
},
fire(bytes: number[]) {
handler?.({ data: new Uint8Array(bytes), timeStamp: 0 });
},
};
}
function makeFakeOutput(): WebMIDIOutput & { sent: number[][] } {
const sent: number[][] = [];
return {
id: "out-1",
name: "Fake Output",
manufacturer: "Suaveplan",
state: "connected",
type: "output",
send(bytes) {
sent.push(Array.from(bytes));
},
sent,
};
}
describe("adaptInput", () => {
it("delivers parsed MidiMessage to subscribers", { timeout: 5000 }, () => {
const input = makeFakeInput();
const port = adaptInput(input);
const seen: MidiMessage[] = [];
const off = port.onMessage((m) => seen.push(m));
input.fire([0x91, 0x40, 0x64]);
expect(seen).toEqual([
{ type: "noteOn", channel: 1, note: 64, velocity: 100 },
]);
off();
});
it("ignores malformed bytes silently", { timeout: 5000 }, () => {
const input = makeFakeInput();
const port = adaptInput(input);
const seen: MidiMessage[] = [];
port.onMessage((m) => seen.push(m));
input.fire([0x40, 0x40]); // status high bit clear
expect(seen).toEqual([]);
});
it("fans out to multiple subscribers", { timeout: 5000 }, () => {
const input = makeFakeInput();
const port = adaptInput(input);
const a: MidiMessage[] = [];
const b: MidiMessage[] = [];
port.onMessage((m) => a.push(m));
port.onMessage((m) => b.push(m));
input.fire([0x90, 0x3c, 0x40]);
expect(a.length).toBe(1);
expect(b.length).toBe(1);
});
it("releases onmidimessage on last unsubscribe", { timeout: 5000 }, () => {
const input = makeFakeInput();
const port = adaptInput(input);
const off1 = port.onMessage(() => {});
const off2 = port.onMessage(() => {});
off1();
expect(input.onmidimessage).not.toBeNull();
off2();
expect(input.onmidimessage).toBeNull();
});
it("exposes port metadata", { timeout: 5000 }, () => {
const input = makeFakeInput();
const port = adaptInput(input);
expect(port.id).toBe("in-1");
expect(port.name).toBe("Fake Input");
expect(port.manufacturer).toBe("Suaveplan");
expect(port.state).toBe("connected");
});
it("falls back to id when name is null", { timeout: 5000 }, () => {
const input = makeFakeInput();
(input as unknown as { name: string | null }).name = null;
const port = adaptInput(input);
expect(port.name).toBe("in-1");
});
it("manufacturer is undefined when null", { timeout: 5000 }, () => {
const input = makeFakeInput();
(input as unknown as { manufacturer: string | null }).manufacturer =
null;
const port = adaptInput(input);
expect(port.manufacturer).toBeUndefined();
});
it("send is a no-op on inputs", { timeout: 5000 }, () => {
const port = adaptInput(makeFakeInput());
expect(() =>
port.send(new Uint8Array([0x90, 0x40, 0x40]))
).not.toThrow();
});
});
describe("adaptOutput", () => {
it("send forwards raw bytes", { timeout: 5000 }, () => {
const output = makeFakeOutput();
const port = adaptOutput(output);
port.send(new Uint8Array([0x90, 0x3c, 0x64]));
expect(output.sent).toEqual([[0x90, 0x3c, 0x64]]);
});
it("sendMessage encodes and forwards", { timeout: 5000 }, () => {
const output = makeFakeOutput();
const port = adaptOutput(output);
port.sendMessage({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
expect(output.sent).toEqual([[0x90, 0x3c, 0x64]]);
});
it("onMessage returns a no-op unsubscriber", { timeout: 5000 }, () => {
const port = adaptOutput(makeFakeOutput());
const off = port.onMessage(() => {});
expect(() => off()).not.toThrow();
});
it("exposes metadata with id fallback and undefined manufacturer", {
timeout: 5000,
}, () => {
const output = makeFakeOutput();
(output as unknown as { name: string | null }).name = null;
(output as unknown as { manufacturer: string | null }).manufacturer =
null;
const port = adaptOutput(output);
expect(port.name).toBe("out-1");
expect(port.manufacturer).toBeUndefined();
expect(port.state).toBe("connected");
});
});

View File

@@ -0,0 +1,115 @@
import { decodeMessage, encodeMessage } from "../midi-contract/codec.js";
import type {
MidiMessage,
MidiPortLike,
Unsubscribe,
} from "../midi-contract/midi-message.types.js";
import { recordReceived, recordSent } from "../telemetry/telemetry.js";
import type {
WebMIDIInput,
WebMIDIMessageEvent,
WebMIDIOutput,
} from "./web-midi.types.js";
/**
* Adapt a `WebMIDIInput` (native Web MIDI input port) into a
* `MidiPortLike` that yields parsed `MidiMessage` events. The
* adapter owns the input's `onmidimessage` slot for the duration of
* any subscription; on the last unsubscribe the slot is restored to
* `null` so subsequent re-subscriptions reattach cleanly.
*/
export function adaptInput(input: WebMIDIInput): MidiPortLike {
const subscribers = new Set<(msg: MidiMessage) => void>();
let bound = false;
function handle(ev: WebMIDIMessageEvent): void {
const r = decodeMessage(ev.data, 0);
if (r === null) {
return;
}
recordReceived(input.id);
for (const sub of subscribers) {
sub(r.msg);
}
}
function bind(): void {
if (!bound) {
input.onmidimessage = handle;
bound = true;
}
}
function unbind(): void {
if (bound && subscribers.size === 0) {
input.onmidimessage = null;
bound = false;
}
}
return {
get id(): string {
return input.id;
},
get name(): string {
return input.name ?? input.id;
},
get manufacturer(): string | undefined {
return input.manufacturer ?? undefined;
},
get state(): "connected" | "disconnected" {
return input.state;
},
send(_: Uint8Array): void {
// Inputs do not send; this branch exists only so the
// structural type unification with output ports works in
// generic code.
},
onMessage(handler: (msg: MidiMessage) => void): Unsubscribe {
subscribers.add(handler);
bind();
return () => {
subscribers.delete(handler);
unbind();
};
},
};
}
/**
* Adapt a `WebMIDIOutput` (native Web MIDI output port) into a
* `MidiPortLike`. `send(msg)` accepts either a `MidiMessage` (which
* is encoded via `encodeMessage`) or a raw `Uint8Array` (forwarded
* untouched). Telemetry fires per encoded message.
*/
export function adaptOutput(output: WebMIDIOutput): MidiPortLike & {
sendMessage(msg: MidiMessage): void;
} {
return {
get id(): string {
return output.id;
},
get name(): string {
return output.name ?? output.id;
},
get manufacturer(): string | undefined {
return output.manufacturer ?? undefined;
},
get state(): "connected" | "disconnected" {
return output.state;
},
send(bytes: Uint8Array): void {
output.send(bytes);
recordSent(output.id);
},
sendMessage(msg: MidiMessage): void {
const bytes = encodeMessage(msg);
output.send(bytes);
recordSent(output.id);
},
onMessage(_: (msg: MidiMessage) => void): Unsubscribe {
return () => {
/* outputs never deliver inbound messages. */
};
},
};
}

View File

@@ -0,0 +1,52 @@
/**
* Minimal structural subset of the Web MIDI API. Reproduced here so
* the package does not need to ship a hard dependency on
* `@types/web-midi-api`. The shape matches the W3C Web MIDI 1.0
* editor's draft and is byte-compatible with the native types
* supplied by browsers that expose `navigator.requestMIDIAccess`.
*/
export interface WebMIDIMessageEvent {
readonly data: Uint8Array;
readonly timeStamp: number;
}
export interface WebMIDIConnectionEvent {
readonly port: WebMIDIPort;
}
export type WebMIDIPortState = "connected" | "disconnected";
export type WebMIDIPortDeviceState = "connected" | "disconnected";
export interface WebMIDIPort {
readonly id: string;
readonly name: string | null;
readonly manufacturer: string | null;
readonly state: WebMIDIPortState;
readonly type: "input" | "output";
}
export interface WebMIDIInput extends WebMIDIPort {
readonly type: "input";
onmidimessage: ((ev: WebMIDIMessageEvent) => void) | null;
}
export interface WebMIDIOutput extends WebMIDIPort {
readonly type: "output";
send(bytes: Uint8Array): void;
}
export interface WebMIDIAccess {
readonly inputs: ReadonlyMap<string, WebMIDIInput>;
readonly outputs: ReadonlyMap<string, WebMIDIOutput>;
readonly sysexEnabled: boolean;
onstatechange: ((ev: WebMIDIConnectionEvent) => void) | null;
}
export interface WebMIDIRequestOptions {
readonly sysex?: boolean;
}
export type WebMIDIRequestFn = (
options?: WebMIDIRequestOptions
) => Promise<WebMIDIAccess>;

View File

@@ -0,0 +1,10 @@
export {
type MidiContextValue,
MidiReactContext,
MidiRequestContext,
type MidiStatus,
} from "./midi-context.js";
export {
MidiProvider,
type MidiProviderProps,
} from "./midi-provider.js";

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,32 @@
import { createContext } from "react";
import type {
WebMIDIAccess,
WebMIDIRequestFn,
} from "../port/web-midi.types.js";
export type MidiStatus = "loading" | "granted" | "denied" | "unsupported";
export interface MidiContextValue {
readonly status: MidiStatus;
readonly access: WebMIDIAccess | null;
readonly error: Error | null;
readonly sysex: boolean;
/** Bumped on every `statechange` event so consumers re-render. */
readonly stateVersion: number;
}
/**
* React context published by `<MidiProvider>`. `null` means a hook
* was rendered outside any provider — every public hook throws
* `REACT_MIDI.NO_PROVIDER` on that sentinel.
*/
export const MidiReactContext = createContext<MidiContextValue | null>(null);
MidiReactContext.displayName = "SuaveplanMidiContext";
/**
* Per-tree override slot for the global `navigator.requestMIDIAccess`
* function. Tests inject a deterministic fake here so a render tree
* is exercised end-to-end without touching the real Web MIDI API.
*/
export const MidiRequestContext = createContext<WebMIDIRequestFn | null>(null);
MidiRequestContext.displayName = "SuaveplanMidiRequest";

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,229 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, StrictMode, useContext } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeInput,
} from "../test-utils.js";
import { MidiReactContext, MidiRequestContext } from "./midi-context.js";
import { MidiProvider } from "./midi-provider.js";
describe("MidiProvider", () => {
it("requests access exactly once under StrictMode", {
timeout: 30000,
}, async () => {
let callCount = 0;
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<StrictMode>
<FakeMidiProvider
access={access}
requestSpy={() => {
callCount += 1;
}}
>
{children}
</FakeMidiProvider>
</StrictMode>
);
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
await waitFor(() => {
expect(result.current?.status).toBe("granted");
});
expect(callCount).toBe(1);
expect(result.current?.access).toBe(access);
});
it("publishes status='granted' when access resolves", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess({ inputs: [makeFakeInput("a")] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
await waitFor(() => {
expect(result.current?.status).toBe("granted");
});
expect(result.current?.access).toBe(access);
});
it("surfaces permission denial as status='denied'", {
timeout: 30000,
}, async () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => {
const e = new Error("nope");
e.name = "NotAllowedError";
return Promise.reject(e);
}}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
await waitFor(() => {
expect(result.current?.status).toBe("denied");
});
expect(result.current?.access).toBeNull();
expect(result.current?.error).not.toBeNull();
});
it("surfaces non-Error rejection as denied with wrapped Error", {
timeout: 30000,
}, async () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => Promise.reject("string-rejection")}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
await waitFor(() => {
expect(result.current?.status).toBe("denied");
});
expect(result.current?.error?.message).toContain("string-rejection");
});
it("publishes status='unsupported' when no requester is available", {
timeout: 30000,
}, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
// No injection AND we deliberately strip navigator.requestMIDIAccess
// by passing a null override.
<MidiRequestContext.Provider value={null}>
<MidiProvider>{children}</MidiProvider>
</MidiRequestContext.Provider>
);
const orig = (
globalThis.navigator as Navigator & { requestMIDIAccess?: unknown }
).requestMIDIAccess;
(
globalThis.navigator as Navigator & { requestMIDIAccess?: unknown }
).requestMIDIAccess = undefined;
try {
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
expect(result.current?.status).toBe("unsupported");
} finally {
(
globalThis.navigator as Navigator & {
requestMIDIAccess?: unknown;
}
).requestMIDIAccess = orig;
}
});
it("publishes status='unsupported' when navigator itself is undefined", {
timeout: 5000,
}, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MidiRequestContext.Provider value={null}>
<MidiProvider>{children}</MidiProvider>
</MidiRequestContext.Provider>
);
const g = globalThis as { navigator?: unknown };
const orig = g.navigator;
g.navigator = undefined;
try {
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
expect(result.current?.status).toBe("unsupported");
} finally {
g.navigator = orig;
}
});
it("bumps stateVersion on statechange", { timeout: 30000 }, async () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
await waitFor(() => {
expect(result.current?.status).toBe("granted");
});
const v0 = result.current?.stateVersion ?? 0;
await waitFor(() => {
access.addInput(makeFakeInput("new-1"));
expect(result.current?.stateVersion ?? 0).toBeGreaterThan(v0);
});
});
it("passes sysex prop through to the requester", {
timeout: 30000,
}, async () => {
let observedSysex: boolean | undefined;
const access = makeFakeAccess({ sysex: true });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={access}
sysex
requestSpy={(o) => {
observedSysex = o?.sysex;
}}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
await waitFor(() => {
expect(result.current?.status).toBe("granted");
});
expect(observedSysex).toBe(true);
expect(result.current?.sysex).toBe(true);
});
it("uses real navigator.requestMIDIAccess when no injection", {
timeout: 30000,
}, async () => {
const fakeAccess = makeFakeAccess();
const orig = (
globalThis.navigator as Navigator & { requestMIDIAccess?: unknown }
).requestMIDIAccess;
(
globalThis.navigator as Navigator & {
requestMIDIAccess?: (o?: {
sysex?: boolean;
}) => Promise<unknown>;
}
).requestMIDIAccess = () => Promise.resolve(fakeAccess);
try {
const wrapper = ({ children }: { children: ReactNode }) => (
<MidiProvider>{children}</MidiProvider>
);
const { result } = renderHook(() => useContext(MidiReactContext), {
wrapper,
});
await waitFor(() => {
expect(result.current?.status).toBe("granted");
});
expect(result.current?.access).toBe(fakeAccess);
} finally {
(
globalThis.navigator as Navigator & {
requestMIDIAccess?: unknown;
}
).requestMIDIAccess = orig;
}
});
});

View File

@@ -0,0 +1,119 @@
import {
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
WebMIDIAccess,
WebMIDIConnectionEvent,
WebMIDIRequestFn,
} from "../port/web-midi.types.js";
import {
type MidiContextValue,
MidiReactContext,
MidiRequestContext,
type MidiStatus,
} from "./midi-context.js";
export interface MidiProviderProps {
/**
* If `true`, the provider requests SysEx access. Required for
* patch dumps and bulk-edit protocols (e.g. Yamaha, Roland).
* Defaults to `false`.
*/
readonly sysex?: boolean;
readonly children?: ReactNode;
}
interface ResolvedState {
status: MidiStatus;
access: WebMIDIAccess | null;
error: Error | null;
}
interface NavigatorWithMidi {
requestMIDIAccess?: WebMIDIRequestFn;
}
function resolveRequester(
injected: WebMIDIRequestFn | null
): WebMIDIRequestFn | null {
if (injected !== null) {
return injected;
}
const nav = (globalThis as { navigator?: NavigatorWithMidi }).navigator;
if (nav !== undefined && typeof nav.requestMIDIAccess === "function") {
return nav.requestMIDIAccess.bind(nav);
}
return null;
}
/**
* Context provider that triggers `navigator.requestMIDIAccess`
* exactly once for the lifetime of the React tree (StrictMode-safe
* via an `inFlight` ref guard). Exposes the resulting `MIDIAccess`
* plus a `status` lifecycle and a `stateVersion` counter bumped
* whenever a port plugs or unplugs.
*/
export function MidiProvider(props: MidiProviderProps): ReactNode {
const sysex = props.sysex ?? false;
const injected = useContext(MidiRequestContext);
const requester = resolveRequester(injected);
const [state, setState] = useState<ResolvedState>(() => ({
status: requester === null ? "unsupported" : "loading",
access: null,
error: null,
}));
const [stateVersion, setStateVersion] = useState(0);
const inFlight = useRef(false);
useEffect(() => {
if (requester === null) {
return;
}
if (inFlight.current) {
return;
}
inFlight.current = true;
const onState = (_ev: WebMIDIConnectionEvent): void => {
setStateVersion((v) => v + 1);
};
void requester({ sysex })
.then((access) => {
access.onstatechange = onState;
setState({ status: "granted", access, error: null });
})
.catch((err: unknown) => {
const e = err instanceof Error ? err : new Error(String(err));
setState({
status: "denied",
access: null,
error: e,
});
});
}, [requester, sysex]);
const value = useMemo<MidiContextValue>(
() => ({
status: state.status,
access: state.access,
error: state.error,
sysex,
stateVersion,
}),
[state.status, state.access, state.error, sysex, stateVersion]
);
return (
<MidiReactContext.Provider value={value}>
{props.children}
</MidiReactContext.Provider>
);
}

View File

@@ -0,0 +1,6 @@
export {
messagesReceivedCounter,
messagesSentCounter,
recordReceived,
recordSent,
} from "./telemetry.js";

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,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
messagesReceivedCounter,
messagesSentCounter,
recordReceived,
recordSent,
} from "./telemetry.js";
describe("telemetry", () => {
it("exposes counter instruments", { timeout: 5000 }, () => {
expect(messagesReceivedCounter).toBeDefined();
expect(messagesSentCounter).toBeDefined();
});
it("recordReceived increments without throwing", { timeout: 5000 }, () => {
expect(() => {
recordReceived("port-a");
recordReceived("port-b");
}).not.toThrow();
});
it("recordSent increments without throwing", { timeout: 5000 }, () => {
expect(() => {
recordSent("out-1");
}).not.toThrow();
});
});

View File

@@ -0,0 +1,34 @@
import { type Counter, metrics } from "@opentelemetry/api";
const METER = metrics.getMeter("@suaveplan/react-midi");
/**
* Counter incremented exactly once per parsed inbound `MidiMessage`
* delivered through `port.onMessage`. Tagged with `port` (the
* `MIDIInput.id`).
*/
export const messagesReceivedCounter: Counter = METER.createCounter(
"react_midi_messages_received_total",
{
description: "Inbound MIDI messages received via Web MIDI inputs.",
}
);
/**
* Counter incremented exactly once per outbound `MidiMessage` sent
* through `port.send`. Tagged with `port` (the `MIDIOutput.id`).
*/
export const messagesSentCounter: Counter = METER.createCounter(
"react_midi_messages_sent_total",
{
description: "Outbound MIDI messages sent via Web MIDI outputs.",
}
);
export function recordReceived(portId: string): void {
messagesReceivedCounter.add(1, { port: portId });
}
export function recordSent(portId: string): void {
messagesSentCounter.add(1, { port: portId });
}

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,13 @@
/**
* Per-package test bootstrap — runs once per worker before the first test.
* Registers an `afterEach` that calls React Testing Library's `cleanup`
* so each test starts with a fresh DOM (the default RTL auto-cleanup
* only fires when `globalThis.afterEach` is present, which is not the
* case under `vitest`'s `globals: false`).
*/
import { afterEach } from "@suaveplan/testing/runner";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

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.

View File

@@ -0,0 +1,136 @@
import { type ReactNode, useMemo } from "react";
import type {
WebMIDIAccess,
WebMIDIConnectionEvent,
WebMIDIInput,
WebMIDIMessageEvent,
WebMIDIOutput,
WebMIDIRequestFn,
} from "./port/web-midi.types.js";
import { MidiRequestContext } from "./provider/midi-context.js";
import { MidiProvider } from "./provider/midi-provider.js";
export interface FakeInput extends WebMIDIInput {
fire(bytes: number[]): void;
}
export interface FakeOutput extends WebMIDIOutput {
readonly sent: number[][];
}
export interface FakeAccess extends WebMIDIAccess {
addInput(input: FakeInput): void;
addOutput(output: FakeOutput): void;
removeInput(id: string): void;
}
export function makeFakeInput(id: string, name = `input-${id}`): FakeInput {
let handler: ((ev: WebMIDIMessageEvent) => void) | null = null;
return {
id,
name,
manufacturer: "Suaveplan",
state: "connected",
type: "input",
get onmidimessage() {
return handler;
},
set onmidimessage(fn) {
handler = fn;
},
fire(bytes) {
handler?.({ data: new Uint8Array(bytes), timeStamp: 0 });
},
};
}
export function makeFakeOutput(id: string, name = `output-${id}`): FakeOutput {
const sent: number[][] = [];
return {
id,
name,
manufacturer: "Suaveplan",
state: "connected",
type: "output",
send(bytes) {
sent.push(Array.from(bytes));
},
sent,
};
}
export function makeFakeAccess(opts?: {
inputs?: FakeInput[];
outputs?: FakeOutput[];
sysex?: boolean;
}): FakeAccess {
const inputs = new Map<string, FakeInput>();
const outputs = new Map<string, FakeOutput>();
let stateChange: ((ev: WebMIDIConnectionEvent) => void) | null = null;
for (const i of opts?.inputs ?? []) inputs.set(i.id, i);
for (const o of opts?.outputs ?? []) outputs.set(o.id, o);
const access: FakeAccess = {
get inputs() {
return inputs as ReadonlyMap<string, WebMIDIInput>;
},
get outputs() {
return outputs as ReadonlyMap<string, WebMIDIOutput>;
},
sysexEnabled: opts?.sysex ?? false,
get onstatechange() {
return stateChange;
},
set onstatechange(fn) {
stateChange = fn;
},
addInput(input) {
inputs.set(input.id, input);
stateChange?.({ port: input });
},
addOutput(output) {
outputs.set(output.id, output);
stateChange?.({ port: output });
},
removeInput(id) {
const port = inputs.get(id);
if (port !== undefined) {
inputs.delete(id);
stateChange?.({
port: { ...port, state: "disconnected" },
});
}
},
};
return access;
}
export interface FakeProviderProps {
readonly access: FakeAccess;
readonly requestImpl?: WebMIDIRequestFn;
readonly sysex?: boolean;
readonly children?: ReactNode;
readonly requestSpy?: (opts?: { sysex?: boolean }) => void;
}
/**
* Renders `<MidiProvider>` with a deterministic fake access injected
* through `MidiRequestContext`. The `requestSpy` callback is invoked
* once per request, allowing tests to assert call counts (e.g.
* exactly-once under StrictMode).
*/
export function FakeMidiProvider(props: FakeProviderProps): ReactNode {
const request: WebMIDIRequestFn = useMemo(() => {
if (props.requestImpl !== undefined) {
return props.requestImpl;
}
return (opts) => {
props.requestSpy?.(opts);
return Promise.resolve(props.access);
};
}, [props.access, props.requestImpl, props.requestSpy]);
return (
<MidiRequestContext.Provider value={request}>
<MidiProvider sysex={props.sysex}>{props.children}</MidiProvider>
</MidiRequestContext.Provider>
);
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../foundation/core/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.stories.tsx",
"src/**/*.bench.ts",
"src/test-utils.ts",
"src/test-utils.tsx",
"src/test-setup.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,67 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/react-midi",
plugins: [
react(),
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"provider/index": "src/provider/index.ts",
"hooks/index": "src/hooks/index.ts",
"hooks/use-midi/use-midi": "src/hooks/use-midi/use-midi.ts",
"hooks/use-midi-input/use-midi-input":
"src/hooks/use-midi-input/use-midi-input.ts",
"hooks/use-midi-output/use-midi-output":
"src/hooks/use-midi-output/use-midi-output.ts",
"hooks/use-midi-messages/use-midi-messages":
"src/hooks/use-midi-messages/use-midi-messages.ts",
"hooks/use-midi-devices/use-midi-devices":
"src/hooks/use-midi-devices/use-midi-devices.ts",
"hooks/use-nrpn/use-nrpn": "src/hooks/use-nrpn/use-nrpn.ts",
"hooks/use-rpn/use-rpn": "src/hooks/use-rpn/use-rpn.ts",
"hooks/use-cc14/use-cc14": "src/hooks/use-cc14/use-cc14.ts",
"components/index": "src/components/index.ts",
"components/midi-device-list/midi-device-list":
"src/components/midi-device-list/midi-device-list.tsx",
"components/midi-activity-indicator/midi-activity-indicator":
"src/components/midi-activity-indicator/midi-activity-indicator.tsx",
"error-codes/index": "src/error-codes/index.ts",
"midi-contract/index": "src/midi-contract/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"react",
"react-dom",
"react/jsx-runtime",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
esbuild: {
legalComments: "inline",
},
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
fileParallelism: false,
setupFiles: ["@suaveplan/testing/polyfills", "./src/test-setup.ts"],
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.stories.tsx",
"src/**/*.types.ts",
"src/**/index.ts",
"src/test-utils.tsx",
"src/test-setup.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});