Initial Commit
This commit is contained in:
166
packages/ui/react/react-midi/README.md
Normal file
166
packages/ui/react/react-midi/README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# @suaveplan/react-midi - React 19 adapter for the Web MIDI API: <MidiProvider>, hooks (useMidi, useMidiInput, useMidiOutput, useMidiMessages, useMidiDevices, useNRPN, useRPN, useCC14), and small UI primitives (MidiDeviceList, MidiActivityIndicator).
|
||||
|
||||
React 19 adapter for the Web MIDI API. Provides a strict-mode-safe
|
||||
`<MidiProvider>` that requests `MIDIAccess` exactly once, seven
|
||||
typed hooks for common consumption patterns, and two small UI
|
||||
primitives (`<MidiDeviceList>`, `<MidiActivityIndicator>`).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [midi-activity-indicator](./src/components/midi-activity-indicator/midi-activity-indicator.md) — `<MidiActivityIndicator>` is a small visual primitive that "blinks" on inbound MIDI traffic. It subscribes to the selec…
|
||||
- [midi-activity-indicator](./src/components/midi-activity-indicator/midi-activity-indicator.md) — `<MidiActivityIndicator>` is a small visual primitive that "blinks" on inbound MIDI traffic. It subscribes to the selec…
|
||||
- [midi-device-list](./src/components/midi-device-list/midi-device-list.md) — `<MidiDeviceList>` is a small, accessible list of the available MIDI devices for a chosen kind (inputs or outputs). It…
|
||||
- [midi-device-list](./src/components/midi-device-list/midi-device-list.md) — `<MidiDeviceList>` is a small, accessible list of the available MIDI devices for a chosen kind (inputs or outputs). It…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `REACT_MIDI.*` error-code namespace published by `@suaveplan/react-midi`. Every code is generated vi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `REACT_MIDI.*` error-code namespace published by `@suaveplan/react-midi`. Every code is generated vi…
|
||||
- [hooks](./src/hooks/use-midi-context.md) — Internal helper that returns the live `MidiContextValue` for the nearest `<MidiProvider>` ancestor. It throws `ReactMid…
|
||||
- [use-cc14](./src/hooks/use-cc14/use-cc14.md) — `useCC14(selector)` exposes the consolidated 14-bit CC change for the selected input. Controllers that need finer resol…
|
||||
- [use-cc14](./src/hooks/use-cc14/use-cc14.md) — `useCC14(selector)` exposes the consolidated 14-bit CC change for the selected input. Controllers that need finer resol…
|
||||
- [use-midi](./src/hooks/use-midi/use-midi.md) — `useMidi()` is the most general hook in `@suaveplan/react-midi`. It returns the live `MidiContextValue` surface as `{ s…
|
||||
- [use-midi](./src/hooks/use-midi/use-midi.md) — `useMidi()` is the most general hook in `@suaveplan/react-midi`. It returns the live `MidiContextValue` surface as `{ s…
|
||||
- [use-midi-devices](./src/hooks/use-midi-devices/use-midi-devices.md) — `useMidiDevices()` returns the live `{ inputs, outputs }` device-list view, re-rendering whenever the provider's `state…
|
||||
- [use-midi-devices](./src/hooks/use-midi-devices/use-midi-devices.md) — `useMidiDevices()` returns the live `{ inputs, outputs }` device-list view, re-rendering whenever the provider's `state…
|
||||
- [use-midi-input](./src/hooks/use-midi-input/use-midi-input.md) — `useMidiInput(selector)` returns a typed `MidiPortLike` adapter over a single Web MIDI input. The adapter parses raw by…
|
||||
- [use-midi-input](./src/hooks/use-midi-input/use-midi-input.md) — `useMidiInput(selector)` returns a typed `MidiPortLike` adapter over a single Web MIDI input. The adapter parses raw by…
|
||||
- [use-midi-messages](./src/hooks/use-midi-messages/use-midi-messages.md) — `useMidiMessages(selector, options?)` exposes a bounded ring buffer of the most recent parsed MIDI messages from the se…
|
||||
- [use-midi-messages](./src/hooks/use-midi-messages/use-midi-messages.md) — `useMidiMessages(selector, options?)` exposes a bounded ring buffer of the most recent parsed MIDI messages from the se…
|
||||
- [use-midi-output](./src/hooks/use-midi-output/use-midi-output.md) — `useMidiOutput(selector)` returns a typed handle for a single Web MIDI output. The handle exposes both `send(bytes)` (r…
|
||||
- [use-midi-output](./src/hooks/use-midi-output/use-midi-output.md) — `useMidiOutput(selector)` returns a typed handle for a single Web MIDI output. The handle exposes both `send(bytes)` (r…
|
||||
- [use-nrpn](./src/hooks/use-nrpn/use-nrpn.md) — `useNRPN(selector)` wraps the universal NRPN reducer from the `midi-contract` module and exposes the consolidated `NRPN…
|
||||
- [use-nrpn](./src/hooks/use-nrpn/use-nrpn.md) — `useNRPN(selector)` wraps the universal NRPN reducer from the `midi-contract` module and exposes the consolidated `NRPN…
|
||||
- [use-rpn](./src/hooks/use-rpn/use-rpn.md) — `useRPN(selector)` wraps the universal RPN reducer and exposes the consolidated `RPNChange` as React state. RPN ("Regis…
|
||||
- [use-rpn](./src/hooks/use-rpn/use-rpn.md) — `useRPN(selector)` wraps the universal RPN reducer and exposes the consolidated `RPNChange` as React state. RPN ("Regis…
|
||||
- [use-midi-context](./src/hooks/use-midi-context.md) — Internal helper that returns the live `MidiContextValue` for the nearest `<MidiProvider>` ancestor. It throws `ReactMid…
|
||||
- [codec](./src/midi-contract/codec.md) — `codec.ts` provides minimal `decodeMessage` / `encodeMessage` helpers for the MIDI 1.0 wire format used internally by `…
|
||||
- [midi-message](./src/midi-contract/midi-message.md) — Defines the local mirror of the `@suaveplan/midi` `MidiMessage` discriminated union plus its peer types (`Channel`, `Mi…
|
||||
- [reducers](./src/midi-contract/reducers.md) — Three small reducers that fold the four-CC NRPN, RPN, and 14-bit CC "dances" into single consolidated events. The Web M…
|
||||
- [port](./src/port/web-midi-port.md) — Bridges the native Web MIDI `WebMIDIInput` / `WebMIDIOutput` shapes into the structural `MidiPortLike` consumed by the…
|
||||
- [web-midi-port](./src/port/web-midi-port.md) — Bridges the native Web MIDI `WebMIDIInput` / `WebMIDIOutput` shapes into the structural `MidiPortLike` consumed by the…
|
||||
- [midi-context](./src/provider/midi-context.md) — Defines the two React contexts that wire `<MidiProvider>` to its descendant hooks. `MidiReactContext` carries the resol…
|
||||
- [midi-provider](./src/provider/midi-provider.md) — `<MidiProvider>` is the root context provider for `@suaveplan/react-midi`. It calls `navigator.requestMIDIAccess({ syse…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the two OTel counters published by `@suaveplan/react-midi`: `react_midi_messages_received_total` for parsed inboun…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the two OTel counters published by `@suaveplan/react-midi`: `react_midi_messages_received_total` for parsed inboun…
|
||||
- [test-setup](./src/test-setup.md) — Per-package Vitest bootstrap loaded via `setupFiles` before the first test in a worker runs. Its only job is to registe…
|
||||
- [test-utils](./src/test-utils.md) — Provides the deterministic in-memory implementations of the Web MIDI shape used throughout the `@suaveplan/react-midi`…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/react-midi
|
||||
```
|
||||
|
||||
Peer deps: `react@^19`, `react-dom@^19`.
|
||||
|
||||
## Permission model
|
||||
|
||||
Web MIDI is gated behind `navigator.requestMIDIAccess`. `<MidiProvider>`
|
||||
calls it once on mount and surfaces the outcome as a `status`:
|
||||
|
||||
- `loading` — request in flight.
|
||||
- `granted` — `MIDIAccess` resolved.
|
||||
- `denied` — user rejected, or the runtime threw (e.g. `NotAllowedError`).
|
||||
- `unsupported` — `navigator.requestMIDIAccess` is undefined (Firefox,
|
||||
sandboxed iframes, etc.).
|
||||
|
||||
SysEx access is opt-in via `<MidiProvider sysex>`. The provider is
|
||||
StrictMode-safe — it tracks an in-flight ref so React's double-mount
|
||||
never produces two parallel requests.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```tsx
|
||||
import {
|
||||
MidiProvider,
|
||||
useMidi,
|
||||
useMidiInput,
|
||||
MidiDeviceList,
|
||||
} from "@suaveplan/react-midi";
|
||||
|
||||
function Workspace() {
|
||||
const { status } = useMidi();
|
||||
if (status === "loading") return <p>Connecting MIDI…</p>;
|
||||
if (status === "denied") return <p>MIDI permission denied.</p>;
|
||||
if (status === "unsupported")
|
||||
return <p>MIDI not available in this browser.</p>;
|
||||
return (
|
||||
<>
|
||||
<MidiDeviceList kind="input" />
|
||||
<NoteLogger />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteLogger() {
|
||||
const port = useMidiInput("first");
|
||||
useEffect(() => {
|
||||
if (port === null) return;
|
||||
return port.onMessage((msg) => {
|
||||
if (msg.type === "noteOn") console.log("note", msg.note);
|
||||
});
|
||||
}, [port]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<MidiProvider>
|
||||
<Workspace />
|
||||
</MidiProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Device-picker example
|
||||
|
||||
```tsx
|
||||
function DevicePicker() {
|
||||
const [id, setId] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<MidiDeviceList
|
||||
kind="input"
|
||||
selectedId={id}
|
||||
onSelect={setId}
|
||||
/>
|
||||
{id !== null ? <NoteLogger portId={id} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Hooks reference
|
||||
|
||||
- `useMidi()` — `{ status, access, inputs, outputs, error }`.
|
||||
- `useMidiInput(id | "first")` — typed `MidiPortLike` with parsed
|
||||
`MidiMessage` events.
|
||||
- `useMidiOutput(id | "first")` — typed output handle with
|
||||
`sendMessage(msg)` (encoded) and raw `send(bytes)`.
|
||||
- `useMidiMessages(id, { capacity?, filter? })` — ring buffer of
|
||||
recent messages for log/debug UIs.
|
||||
- `useMidiDevices()` — `{ inputs, outputs }`, re-renders on
|
||||
`statechange`.
|
||||
- `useNRPN(id)`, `useRPN(id)`, `useCC14(id)` — high-resolution
|
||||
reducers folded into React state.
|
||||
|
||||
## Error codes
|
||||
|
||||
`REACT_MIDI.{ACCESS_DENIED, UNSUPPORTED_PLATFORM, PORT_NOT_FOUND,
|
||||
SYSEX_NOT_PERMITTED, NO_PROVIDER}`. Thrown as `ReactMidiError`
|
||||
instances; permission failures are surfaced as a `status` value
|
||||
rather than thrown so consumers can render friendly fallbacks.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Two OTel counters tagged with `port`:
|
||||
`react_midi_messages_received_total{port}` and
|
||||
`react_midi_messages_sent_total{port}`.
|
||||
|
||||
## On the `@suaveplan/midi` dependency
|
||||
|
||||
This package ships a local mirror of the `@suaveplan/midi`
|
||||
`MidiMessage` contract in `src/midi-contract/`. Once
|
||||
`@suaveplan/midi` graduates to `workspace:^`, the local mirror is
|
||||
replaced by a re-export from that package without any shape change.
|
||||
|
||||
161
packages/ui/react/react-midi/package.json
Normal file
161
packages/ui/react/react-midi/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
10
packages/ui/react/react-midi/src/components/index.ts
Normal file
10
packages/ui/react/react-midi/src/components/index.ts
Normal 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";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
MidiActivityIndicator,
|
||||
type MidiActivityIndicatorProps,
|
||||
} from "./midi-activity-indicator.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
# `components/midi-activity-indicator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MidiActivityIndicator>` is a small visual primitive that "blinks"
|
||||
on inbound MIDI traffic. It subscribes to the selected port via
|
||||
`useMidiInput`, latches an internal `active` flag to `true` on every
|
||||
incoming message, and resets the flag to `false` after `decayMs` of
|
||||
quiet. Useful for device-picker UIs (show which row is currently
|
||||
receiving data) and debug overlays (confirm wiring before chasing a
|
||||
bug deeper).
|
||||
|
||||
The default render is a `<span>● </span>` with `data-active="true"`
|
||||
/ `"false"` attributes so consumers can drive their own CSS theme;
|
||||
a `render` prop is provided for callers that want full control over
|
||||
the visual.
|
||||
|
||||
## Features
|
||||
|
||||
- Tracks last message type for downstream theming hints.
|
||||
- Configurable decay window (`decayMs`, default 200ms).
|
||||
- ARIA-live "polite" updates so screen readers announce activity.
|
||||
- `render` callback for fully custom visuals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { MidiActivityIndicator } from "@suaveplan/react-midi";
|
||||
|
||||
function StatusBar() {
|
||||
return (
|
||||
<div>
|
||||
<MidiActivityIndicator portId="first" />
|
||||
<span> MIDI</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<MidiActivityIndicator
|
||||
portId="midi-in-1"
|
||||
decayMs={120}
|
||||
render={({ active, lastType }) => (
|
||||
<Badge tone={active ? "live" : "muted"}>
|
||||
{lastType ?? "idle"}
|
||||
</Badge>
|
||||
)}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<MidiActivityIndicator portId?, decayMs?, render?, className?>`.
|
||||
- `render` callback signature:
|
||||
`({ active: boolean, lastType: MidiMessageType | null }) => ReactNode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decay timer is held in a closure-scoped `timer` reference rather
|
||||
than a `useRef` because it lives entirely within the effect body —
|
||||
clearing on a fresh message is a simple `clearTimeout` against the
|
||||
previous handle. The effect cleanup function clears any pending
|
||||
timer on unmount so a slow-moving test environment never leaks a
|
||||
"fire after unmount" callback.
|
||||
@@ -0,0 +1,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
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type MidiDeviceKind,
|
||||
MidiDeviceList,
|
||||
type MidiDeviceListProps,
|
||||
type MidiDeviceRowRenderProps,
|
||||
} from "./midi-device-list.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# `components/midi-device-list`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<MidiDeviceList>` is a small, accessible list of the available MIDI
|
||||
devices for a chosen kind (inputs or outputs). It is built on
|
||||
`useMidiDevices`, so it re-renders whenever the Web MIDI runtime
|
||||
fires a `statechange` event. The component is intentionally
|
||||
unstyled (no opinionated theme tokens) — callers compose it with
|
||||
their own row renderer via the `renderRow` prop, or rely on the
|
||||
built-in default which renders a button per device showing `name`
|
||||
and `manufacturer`.
|
||||
|
||||
## Features
|
||||
|
||||
- `kind="input" | "output"` selector.
|
||||
- `onSelect(id)` + `selectedId` props for controlled selection.
|
||||
- Optional `renderRow` prop for custom row UIs.
|
||||
- Optional `empty` slot rendered when there are zero devices.
|
||||
- ARIA: `role="listbox"` on the container, `role="option"` and
|
||||
`aria-selected` on each row.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { MidiDeviceList } from "@suaveplan/react-midi";
|
||||
import { useState } from "react";
|
||||
|
||||
function Picker() {
|
||||
const [id, setId] = useState<string | null>(null);
|
||||
return (
|
||||
<MidiDeviceList
|
||||
kind="input"
|
||||
selectedId={id}
|
||||
onSelect={setId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<MidiDeviceList
|
||||
kind="output"
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
renderRow={({ port, selected, onSelect }) => (
|
||||
<Card
|
||||
key={port.id}
|
||||
elevated={selected}
|
||||
onActivate={onSelect}
|
||||
title={port.name ?? port.id}
|
||||
subtitle={port.manufacturer ?? "—"}
|
||||
/>
|
||||
)}
|
||||
empty={<EmptyDeviceCard kind="output" />}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<MidiDeviceList kind?, onSelect?, selectedId?, renderRow?, empty?, className?>`.
|
||||
- `interface MidiDeviceRowRenderProps { port, selected, onSelect }`.
|
||||
- `type MidiDeviceKind = "input" | "output"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component reads `inputs`/`outputs` from `useMidiDevices` and
|
||||
picks the right list based on `kind`. The default row uses
|
||||
`role="option"` directly on the `<button>` for keyboard semantics
|
||||
without nesting (the older `<li><button>` shape forced focus-style
|
||||
duplication on screen readers).
|
||||
|
||||
When zero devices are available the empty branch renders a
|
||||
`role="status"` container so screen readers announce the "no
|
||||
devices" message politely rather than as a list with no entries.
|
||||
The `selected` flag is forwarded to the row renderer plus surfaced
|
||||
as a `data-selected` attribute and an `aria-selected` ARIA prop so
|
||||
both CSS theming and assistive technology have access to the
|
||||
selection without the consumer redoing the comparison.
|
||||
@@ -0,0 +1,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>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
82
packages/ui/react/react-midi/src/error-codes/error-codes.md
Normal file
82
packages/ui/react/react-midi/src/error-codes/error-codes.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
92
packages/ui/react/react-midi/src/error-codes/error-codes.ts
Normal file
92
packages/ui/react/react-midi/src/error-codes/error-codes.ts
Normal 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();
|
||||
10
packages/ui/react/react-midi/src/error-codes/index.ts
Normal file
10
packages/ui/react/react-midi/src/error-codes/index.ts
Normal 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";
|
||||
21
packages/ui/react/react-midi/src/hooks/index.ts
Normal file
21
packages/ui/react/react-midi/src/hooks/index.ts
Normal 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";
|
||||
1
packages/ui/react/react-midi/src/hooks/use-cc14/index.ts
Normal file
1
packages/ui/react/react-midi/src/hooks/use-cc14/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useCC14 } from "./use-cc14.js";
|
||||
65
packages/ui/react/react-midi/src/hooks/use-cc14/use-cc14.md
Normal file
65
packages/ui/react/react-midi/src/hooks/use-cc14/use-cc14.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
41
packages/ui/react/react-midi/src/hooks/use-cc14/use-cc14.ts
Normal file
41
packages/ui/react/react-midi/src/hooks/use-cc14/use-cc14.ts
Normal 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;
|
||||
}
|
||||
70
packages/ui/react/react-midi/src/hooks/use-midi-context.md
Normal file
70
packages/ui/react/react-midi/src/hooks/use-midi-context.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
26
packages/ui/react/react-midi/src/hooks/use-midi-context.ts
Normal file
26
packages/ui/react/react-midi/src/hooks/use-midi-context.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type UseMidiDevicesResult,
|
||||
useMidiDevices,
|
||||
} from "./use-midi-devices.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `hooks/use-midi-devices`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useMidiDevices()` returns the live `{ inputs, outputs }` device-list
|
||||
view, re-rendering whenever the provider's `stateVersion` advances —
|
||||
i.e. whenever a Web MIDI `statechange` event fires (device plugged
|
||||
in, unplugged, or its state otherwise changed). It is a thin wrapper
|
||||
over `useMidi()` for components that only need the device lists and
|
||||
want a narrower call site. Use this hook in device-picker UIs.
|
||||
|
||||
## Features
|
||||
|
||||
- `inputs` and `outputs` are `readonly WebMIDIInput[]` /
|
||||
`readonly WebMIDIOutput[]` materialised on every state change.
|
||||
- Stable array identity between state changes — safe to pass to
|
||||
`key` in renders without forcing unnecessary remounts.
|
||||
- No subscription bookkeeping — the underlying provider already owns
|
||||
the single `onstatechange` listener.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useMidiDevices } from "@suaveplan/react-midi";
|
||||
|
||||
function DeviceCount() {
|
||||
const { inputs, outputs } = useMidiDevices();
|
||||
return (
|
||||
<p>
|
||||
{inputs.length} inputs, {outputs.length} outputs
|
||||
</p>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function PortPicker({ value, onChange }: {
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
}) {
|
||||
const { inputs } = useMidiDevices();
|
||||
return (
|
||||
<select
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{inputs.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useMidiDevices(): { inputs, outputs }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This hook deliberately avoids duplicating the `useMidi` snapshot —
|
||||
it simply destructures and re-projects. Callers that also need the
|
||||
`status` or `error` should reach for `useMidi` instead.
|
||||
|
||||
Because the underlying `useMidi` re-materialises its `inputs` and
|
||||
`outputs` arrays only when the provider's `stateVersion` advances,
|
||||
the values returned here are referentially stable between
|
||||
`statechange` events. That means downstream effects keyed on
|
||||
`inputs` or `outputs` do not fire on every render — only when the
|
||||
device list actually changes — which matters for a device-picker UI
|
||||
that triggers expensive operations (e.g. preset reloads) on the
|
||||
selected device.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type PortSelector, useMidiInput } from "./use-midi-input.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `hooks/use-midi-input`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useMidiInput(selector)` returns a typed `MidiPortLike` adapter over
|
||||
a single Web MIDI input. The adapter parses raw bytes into the
|
||||
discriminated `MidiMessage` union before delivering them through
|
||||
`onMessage`, so consumers never touch byte-level details. The
|
||||
`selector` is either an explicit `MIDIInput.id` or the literal
|
||||
`"first"` shorthand that picks whichever input the access enumerates
|
||||
first — useful when the host has exactly one device plugged in.
|
||||
|
||||
The hook returns `null` when access is still pending, when the
|
||||
selector misses (e.g. a stale id), or when there are zero inputs.
|
||||
This null-return shape composes cleanly with conditional rendering;
|
||||
consumers don't need to thread an explicit loading state.
|
||||
|
||||
## Features
|
||||
|
||||
- Two selector forms: `id` string or `"first"` literal.
|
||||
- Returns `null` during loading / denial / unknown id.
|
||||
- Adapter identity memoised against access + stateVersion + selector.
|
||||
- Multi-subscriber fan-out from a single Web MIDI input.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useMidiInput } from "@suaveplan/react-midi";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function NoteListener() {
|
||||
const port = useMidiInput("first");
|
||||
useEffect(() => {
|
||||
if (port === null) return;
|
||||
return port.onMessage((msg) => {
|
||||
if (msg.type === "noteOn") console.log(msg.note, msg.velocity);
|
||||
});
|
||||
}, [port]);
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function PerDeviceListeners() {
|
||||
const { inputs } = useMidi();
|
||||
return (
|
||||
<>
|
||||
{inputs.map((i) => (
|
||||
<PortFan key={i.id} portId={i.id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PortFan({ portId }: { portId: string }) {
|
||||
const port = useMidiInput(portId);
|
||||
// ... attach handlers via port.onMessage
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useMidiInput(selector: string | "first"): MidiPortLike | null`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook calls `adaptInput` inside `useMemo` so the adapter identity
|
||||
matches the underlying `MIDIInput` for the duration of the hook's
|
||||
lifetime. Because `adaptInput` keeps its own `Set<subscriber>` and
|
||||
the `useMemo` recreates the adapter when the underlying port changes
|
||||
(plug/unplug), any active subscription should re-bind from a
|
||||
`useEffect` keyed on `[port]`.
|
||||
@@ -0,0 +1,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?.();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type UseMidiMessagesOptions,
|
||||
useMidiMessages,
|
||||
} from "./use-midi-messages.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
# `hooks/use-midi-messages`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useMidiMessages(selector, options?)` exposes a bounded ring buffer of
|
||||
the most recent parsed MIDI messages from the selected input. It is a
|
||||
convenience built on top of `useMidiInput` for UI consumers that want
|
||||
to render a live activity feed (e.g. an "incoming messages" panel) or
|
||||
debug a controller wiring problem. The ring buffer trims the oldest
|
||||
entry once `capacity` is exceeded, so the array length is stable and
|
||||
the component can render it directly without further bookkeeping.
|
||||
|
||||
An optional `filter` whitelist accepts only specific message types
|
||||
into the buffer — handy when a device floods the bus with active-
|
||||
sensing or clock ticks the operator does not want in the visible log.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded ring buffer, default capacity 64.
|
||||
- Optional `filter: readonly MidiMessageType[]` whitelist.
|
||||
- Rebinds the underlying `port.onMessage` automatically when the
|
||||
port identity changes (plug/unplug, selector switch).
|
||||
- Buffer resets when the underlying port becomes `null`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useMidiMessages } from "@suaveplan/react-midi";
|
||||
|
||||
function ActivityLog() {
|
||||
const msgs = useMidiMessages("first", { capacity: 32 });
|
||||
return (
|
||||
<ul>
|
||||
{msgs.map((m, i) => (
|
||||
<li key={`${m.type}-${i}`}>{m.type}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function NotesOnly({ portId }: { portId: string }) {
|
||||
// Drop active-sensing/clock noise.
|
||||
const notes = useMidiMessages(portId, {
|
||||
filter: ["noteOn", "noteOff"],
|
||||
capacity: 128,
|
||||
});
|
||||
return <NoteRoll messages={notes} />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useMidiMessages(selector, options?): readonly MidiMessage[]`
|
||||
- `interface UseMidiMessagesOptions { capacity?, filter? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal state holds the ring buffer as a `readonly MidiMessage[]`
|
||||
rather than a `Map` or a separate write-cursor so consumers can drop
|
||||
straight into a `.map(...)` render. The buffer-replace path always
|
||||
allocates a fresh array (rather than mutating in place) so React
|
||||
detects the state change. The set-of-accepted-types is rebuilt only
|
||||
when the `filter` reference or the joined key changes, not per
|
||||
incoming message.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type MidiOutputHandle, useMidiOutput } from "./use-midi-output.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `hooks/use-midi-output`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useMidiOutput(selector)` returns a typed handle for a single Web
|
||||
MIDI output. The handle exposes both `send(bytes)` (raw bytes,
|
||||
typically for SysEx pass-through) and `sendMessage(msg)`, which
|
||||
encodes a `MidiMessage` via `encodeMessage` before forwarding. The
|
||||
selector mirrors `useMidiInput`: pass an explicit `MIDIOutput.id` or
|
||||
the literal `"first"` to pick whichever output the access enumerates
|
||||
first. Returns `null` when access is loading, denied, unsupported,
|
||||
or the selector misses.
|
||||
|
||||
## Features
|
||||
|
||||
- Two selector forms — id string or `"first"`.
|
||||
- Typed `sendMessage(msg: MidiMessage)` plus raw `send(bytes)`.
|
||||
- Telemetry counter fires on every send.
|
||||
- Handle identity memoised against access + stateVersion + selector.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useMidiOutput } from "@suaveplan/react-midi";
|
||||
import { useCallback } from "react";
|
||||
|
||||
function NoteButton() {
|
||||
const out = useMidiOutput("first");
|
||||
const onClick = useCallback(() => {
|
||||
out?.sendMessage({
|
||||
type: "noteOn",
|
||||
channel: 0,
|
||||
note: 60,
|
||||
velocity: 100,
|
||||
});
|
||||
}, [out]);
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={out === null}>
|
||||
Play C4
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function SysExSender({ outputId }: { outputId: string }) {
|
||||
const out = useMidiOutput(outputId);
|
||||
const send = () => {
|
||||
// Universal device-inquiry (real-time bypass).
|
||||
out?.send(new Uint8Array([0xf0, 0x7e, 0x7f, 0x06, 0x01, 0xf7]));
|
||||
};
|
||||
return (
|
||||
<button type="button" onClick={send} disabled={out === null}>
|
||||
Inquire device
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useMidiOutput(selector: string | "first"): MidiOutputHandle | null`
|
||||
- `interface MidiOutputHandle { id, name, manufacturer?, state, send, sendMessage }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`sendMessage` is intentionally distinct from `send`: the encoder
|
||||
prepends/appends framing for SysEx and constructs a fresh
|
||||
`Uint8Array` per call. Hosts that ship one bus-master encoder elsewhere
|
||||
can stick with `send(bytes)` and avoid the per-call allocation. The
|
||||
hook does not currently gate SysEx behind the provider's `sysex`
|
||||
prop — Web MIDI itself enforces that boundary on `port.send` and
|
||||
will surface a `DOMException` if a SysEx-bearing buffer is forwarded
|
||||
from a non-SysEx access. The returned handle is memoised against
|
||||
`(access, stateVersion, selector)` so its identity is stable until
|
||||
the underlying port actually changes; callers that capture the
|
||||
handle inside a `useCallback` will see the dependency change at the
|
||||
right moments (port plug/unplug, selector switch) and at no others.
|
||||
@@ -0,0 +1,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]]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
1
packages/ui/react/react-midi/src/hooks/use-midi/index.ts
Normal file
1
packages/ui/react/react-midi/src/hooks/use-midi/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type UseMidiResult, useMidi } from "./use-midi.js";
|
||||
72
packages/ui/react/react-midi/src/hooks/use-midi/use-midi.md
Normal file
72
packages/ui/react/react-midi/src/hooks/use-midi/use-midi.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
packages/ui/react/react-midi/src/hooks/use-midi/use-midi.ts
Normal file
50
packages/ui/react/react-midi/src/hooks/use-midi/use-midi.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1
packages/ui/react/react-midi/src/hooks/use-nrpn/index.ts
Normal file
1
packages/ui/react/react-midi/src/hooks/use-nrpn/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useNRPN } from "./use-nrpn.js";
|
||||
69
packages/ui/react/react-midi/src/hooks/use-nrpn/use-nrpn.md
Normal file
69
packages/ui/react/react-midi/src/hooks/use-nrpn/use-nrpn.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
42
packages/ui/react/react-midi/src/hooks/use-nrpn/use-nrpn.ts
Normal file
42
packages/ui/react/react-midi/src/hooks/use-nrpn/use-nrpn.ts
Normal 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;
|
||||
}
|
||||
1
packages/ui/react/react-midi/src/hooks/use-rpn/index.ts
Normal file
1
packages/ui/react/react-midi/src/hooks/use-rpn/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useRPN } from "./use-rpn.js";
|
||||
65
packages/ui/react/react-midi/src/hooks/use-rpn/use-rpn.md
Normal file
65
packages/ui/react/react-midi/src/hooks/use-rpn/use-rpn.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
40
packages/ui/react/react-midi/src/hooks/use-rpn/use-rpn.ts
Normal file
40
packages/ui/react/react-midi/src/hooks/use-rpn/use-rpn.ts
Normal 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;
|
||||
}
|
||||
61
packages/ui/react/react-midi/src/index.ts
Normal file
61
packages/ui/react/react-midi/src/index.ts
Normal 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";
|
||||
69
packages/ui/react/react-midi/src/midi-contract/codec.md
Normal file
69
packages/ui/react/react-midi/src/midi-contract/codec.md
Normal 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.
|
||||
172
packages/ui/react/react-midi/src/midi-contract/codec.test.ts
Normal file
172
packages/ui/react/react-midi/src/midi-contract/codec.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
196
packages/ui/react/react-midi/src/midi-contract/codec.ts
Normal file
196
packages/ui/react/react-midi/src/midi-contract/codec.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
26
packages/ui/react/react-midi/src/midi-contract/index.ts
Normal file
26
packages/ui/react/react-midi/src/midi-contract/index.ts
Normal 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";
|
||||
@@ -0,0 +1,68 @@
|
||||
# `midi-contract/midi-message`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the local mirror of the `@suaveplan/midi` `MidiMessage`
|
||||
discriminated union plus its peer types (`Channel`, `MidiPortLike`,
|
||||
`NRPNChange`, `RPNChange`, `CC14Change`, `Unsubscribe`). This file
|
||||
exists so `@suaveplan/react-midi` can be developed and tested on its
|
||||
own branch while `@suaveplan/midi` is being authored in parallel on
|
||||
`feat/midi`. Once both branches land, the type aliases here SHALL be
|
||||
replaced by a re-export from `@suaveplan/midi`; no shape change is
|
||||
intended at swap time.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel-voice variants: `noteOn`, `noteOff`, `polyAftertouch`,
|
||||
`controlChange`, `programChange`, `channelPressure`, `pitchBend`.
|
||||
- System variants: `sysEx`, `clock`, `start`, `continue`, `stop`,
|
||||
`activeSensing`, `systemReset`.
|
||||
- High-resolution reducer events: `NRPNChange`, `RPNChange`,
|
||||
`CC14Change`.
|
||||
- Structural `MidiPortLike` interface compatible with any transport.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { MidiMessage } from "@suaveplan/react-midi/midi-contract";
|
||||
|
||||
function isNote(msg: MidiMessage): boolean {
|
||||
return msg.type === "noteOn" || msg.type === "noteOff";
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { MidiPortLike } from "@suaveplan/react-midi/midi-contract";
|
||||
|
||||
class FakePort implements MidiPortLike {
|
||||
readonly id = "fake-1";
|
||||
readonly name = "Fake In";
|
||||
readonly state = "connected" as const;
|
||||
private subs = new Set<(m: MidiMessage) => void>();
|
||||
send(_: Uint8Array): void {}
|
||||
onMessage(handler: (m: MidiMessage) => void) {
|
||||
this.subs.add(handler);
|
||||
return () => this.subs.delete(handler);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Channel = 0..15` literal union.
|
||||
- `MidiMessage` discriminated union — see Features.
|
||||
- `MidiMessageType = MidiMessage["type"]`.
|
||||
- `MidiPortLike { id, name, manufacturer?, state, send, onMessage }`.
|
||||
- `NRPNChange`, `RPNChange`, `CC14Change` — reducer outputs.
|
||||
- `Unsubscribe = () => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file deliberately uses interface declarations (not type aliases)
|
||||
for variant shapes so each one can be discriminated in user code by
|
||||
hovering over the inferred type. The structural `MidiPortLike` mirrors
|
||||
the Web MIDI shape but adds a `manufacturer` field and a typed
|
||||
`onMessage` that yields parsed `MidiMessage` rather than raw bytes —
|
||||
that lift is performed inside `port/web-midi-port.ts`.
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
67
packages/ui/react/react-midi/src/midi-contract/reducers.md
Normal file
67
packages/ui/react/react-midi/src/midi-contract/reducers.md
Normal 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.
|
||||
115
packages/ui/react/react-midi/src/midi-contract/reducers.test.ts
Normal file
115
packages/ui/react/react-midi/src/midi-contract/reducers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
173
packages/ui/react/react-midi/src/midi-contract/reducers.ts
Normal file
173
packages/ui/react/react-midi/src/midi-contract/reducers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
packages/ui/react/react-midi/src/port/web-midi-port.md
Normal file
72
packages/ui/react/react-midi/src/port/web-midi-port.md
Normal 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.
|
||||
162
packages/ui/react/react-midi/src/port/web-midi-port.test.ts
Normal file
162
packages/ui/react/react-midi/src/port/web-midi-port.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
115
packages/ui/react/react-midi/src/port/web-midi-port.ts
Normal file
115
packages/ui/react/react-midi/src/port/web-midi-port.ts
Normal 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. */
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
52
packages/ui/react/react-midi/src/port/web-midi.types.ts
Normal file
52
packages/ui/react/react-midi/src/port/web-midi.types.ts
Normal 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>;
|
||||
10
packages/ui/react/react-midi/src/provider/index.ts
Normal file
10
packages/ui/react/react-midi/src/provider/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
type MidiContextValue,
|
||||
MidiReactContext,
|
||||
MidiRequestContext,
|
||||
type MidiStatus,
|
||||
} from "./midi-context.js";
|
||||
export {
|
||||
MidiProvider,
|
||||
type MidiProviderProps,
|
||||
} from "./midi-provider.js";
|
||||
70
packages/ui/react/react-midi/src/provider/midi-context.md
Normal file
70
packages/ui/react/react-midi/src/provider/midi-context.md
Normal 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.
|
||||
32
packages/ui/react/react-midi/src/provider/midi-context.ts
Normal file
32
packages/ui/react/react-midi/src/provider/midi-context.ts
Normal 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";
|
||||
75
packages/ui/react/react-midi/src/provider/midi-provider.md
Normal file
75
packages/ui/react/react-midi/src/provider/midi-provider.md
Normal 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.
|
||||
229
packages/ui/react/react-midi/src/provider/midi-provider.test.tsx
Normal file
229
packages/ui/react/react-midi/src/provider/midi-provider.test.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
119
packages/ui/react/react-midi/src/provider/midi-provider.tsx
Normal file
119
packages/ui/react/react-midi/src/provider/midi-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
packages/ui/react/react-midi/src/telemetry/index.ts
Normal file
6
packages/ui/react/react-midi/src/telemetry/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
messagesReceivedCounter,
|
||||
messagesSentCounter,
|
||||
recordReceived,
|
||||
recordSent,
|
||||
} from "./telemetry.js";
|
||||
66
packages/ui/react/react-midi/src/telemetry/telemetry.md
Normal file
66
packages/ui/react/react-midi/src/telemetry/telemetry.md
Normal 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).
|
||||
27
packages/ui/react/react-midi/src/telemetry/telemetry.test.ts
Normal file
27
packages/ui/react/react-midi/src/telemetry/telemetry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
34
packages/ui/react/react-midi/src/telemetry/telemetry.ts
Normal file
34
packages/ui/react/react-midi/src/telemetry/telemetry.ts
Normal 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 });
|
||||
}
|
||||
62
packages/ui/react/react-midi/src/test-setup.md
Normal file
62
packages/ui/react/react-midi/src/test-setup.md
Normal 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.
|
||||
13
packages/ui/react/react-midi/src/test-setup.ts
Normal file
13
packages/ui/react/react-midi/src/test-setup.ts
Normal 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();
|
||||
});
|
||||
85
packages/ui/react/react-midi/src/test-utils.md
Normal file
85
packages/ui/react/react-midi/src/test-utils.md
Normal 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.
|
||||
136
packages/ui/react/react-midi/src/test-utils.tsx
Normal file
136
packages/ui/react/react-midi/src/test-utils.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
packages/ui/react/react-midi/tsconfig.lib.json
Normal file
22
packages/ui/react/react-midi/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
67
packages/ui/react/react-midi/vite.config.ts
Normal file
67
packages/ui/react/react-midi/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
39
packages/ui/react/react-midi/vitest.config.ts
Normal file
39
packages/ui/react/react-midi/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user