Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
export {
type LearnTarget,
type MidiControllerRegistry,
MidiControllerRegistryContext,
type UseMidiControllerResult,
useMidiController,
} from "./use-midi-controller.js";

View File

@@ -0,0 +1,112 @@
# `hooks/use-midi-controller`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidiController(controllerId)` is the React 19 binding for a single
`MidiControllerPlugin` from `@suaveplan/audio-controllers`. It looks
the controller up by `id` in the surrounding
`MidiControllerRegistryContext`, subscribes to the controller's
`state` event via `useSyncExternalStore`, and exposes a `learn(target)`
method that wraps `captureNextLearn` for the learn-mode UX.
The hook is intentionally narrow: it does **not** own the MIDI
hardware, nor does it instantiate the controller. The application
constructs the controller plugin(s) at startup, places them in a
read-only registry (a plain `Map` is sufficient), and passes that
registry into the React tree via
`<MidiControllerRegistryContext.Provider>`. Every Phase-3 surface
(`useMidiController` and `<MidiControllerSetup>`) reads through the
same context, so swapping registry implementations during tests is
trivial.
## Features
- `useSyncExternalStore` subscription against the controller's
`state` event — re-renders once per FSM transition
(`disconnected → connecting → connected → error`).
- `learn(target)` returns a promise resolving to the captured
`(channel, controller)` pair; rejects with
`REACT_AUDIO.MIDI_LEARN_FAILED` when no controller is bound or
`captureNextLearn` rejects.
- `controller` is `undefined` and `state` is `"disconnected"` when
the registry does not contain the requested id — no throw, no
exception during render.
- Optional `registryOverride` second argument bypasses the context
for tests / isolated stories.
## Usage: Basic
```tsx
import {
MidiControllerRegistryContext,
useMidiController,
} from "@suaveplan/react-audio";
function ControllerBadge({ id }: { id: string }) {
const { state } = useMidiController(id);
return <span data-state={state}>{state}</span>;
}
function App({ controllers }: { controllers: Map<string, MidiControllerPlugin> }) {
return (
<MidiControllerRegistryContext.Provider value={controllers}>
<ControllerBadge id="kontrol-s88" />
</MidiControllerRegistryContext.Provider>
);
}
```
## Usage: Advanced
```tsx
import { useMidiController } from "@suaveplan/react-audio";
function LearnButton({ id, pluginId, paramId }: {
id: string; pluginId: string; paramId: string;
}) {
const { controller, state, learn } = useMidiController(id);
if (state !== "connected" || !controller) return <p>Connect controller…</p>;
return (
<button
type="button"
onClick={async () => {
const captured = await learn({ pluginId, paramId });
console.log("captured", captured);
}}
>
Learn {paramId}
</button>
);
}
```
## API Reference
- `MidiControllerRegistry` — type alias for
`ReadonlyMap<string, MidiControllerPlugin>`.
- `MidiControllerRegistryContext` — React context whose `.Provider`
distributes the registry to descendants.
- `LearnTarget` — `{ pluginId, paramId }` pair.
- `UseMidiControllerResult.controller` — resolved plugin or `undefined`.
- `UseMidiControllerResult.state` — `ConnectionState` mirrored from
the controller FSM.
- `UseMidiControllerResult.learn(target)` — returns
`Promise<{ target, channel, controller }>`; rejects with
`REACT_AUDIO.MIDI_LEARN_FAILED`.
## Implementation Notes
`useSyncExternalStore` requires that `getSnapshot` be referentially
stable for unchanged state. Because `ConnectionState` is a string
union, the snapshot value itself is the comparison key, so the
default `Object.is` equality check fires re-renders only on actual
transitions. The `subscribe` callback returns the controller's own
`off`-style unsubscribe handle, so the hook scales to any number of
mounted consumers without leaking listeners. Errors during `learn`
are wrapped in `ReactAudioError(MIDI_LEARN_FAILED)` so consumers can
handle a single, stable code rather than poking at the underlying
`AudioControllersError` surface.

View File

@@ -0,0 +1,210 @@
import {
type ControllerMetadata,
MidiControllerPlugin,
type MidiControllerPluginOptions,
} from "@suaveplan/audio-controllers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import {
createFakeAccess,
createFakeAdapter,
getFirstInput,
} from "../../../tests/fake-midi.js";
import { FakeTransport } from "../../../tests/fake-transport.js";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import {
MidiControllerRegistryContext,
useMidiController,
} from "./use-midi-controller.js";
const META: ControllerMetadata = {
id: "demo",
name: "Demo",
manufacturer: "Acme",
portNamePatterns: [],
};
class DemoController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(META, opts);
}
}
async function makeController() {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new DemoController({ adapter, transport });
return { plugin, access };
}
describe("useMidiController", () => {
it("re-renders on connection-state transitions", {
timeout: 5000,
}, async () => {
const { plugin } = await makeController();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
expect(result.current.state).toBe("disconnected");
await act(async () => {
await plugin.initialize();
});
await waitFor(() => {
expect(result.current.state).toBe("connected");
});
});
it("returns undefined controller + disconnected state for unknown id", {
timeout: 5000,
}, () => {
const registry = new Map();
const { result } = renderHook(() => useMidiController("nope"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
expect(result.current.controller).toBeUndefined();
expect(result.current.state).toBe("disconnected");
});
it("learn(target) resolves with the captured pair", {
timeout: 5000,
}, async () => {
const { plugin, access } = await makeController();
await plugin.initialize();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
const target = { pluginId: "eq", paramId: "lowShelfGain" };
const promise = result.current.learn(target);
// Allow the promise's listener to register.
await Promise.resolve();
getFirstInput(access).emit([0xb1, 7, 64]);
const captured = await promise;
expect(captured).toEqual({
target,
channel: 1,
controller: 7,
});
});
it("learn rejects with MIDI_LEARN_FAILED when no controller is bound", {
timeout: 5000,
}, async () => {
const registry = new Map();
const { result } = renderHook(() => useMidiController("nope"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
let caught: unknown;
try {
await result.current.learn({
pluginId: "eq",
paramId: "gain",
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED
);
});
it("registryOverride argument bypasses the context", {
timeout: 5000,
}, async () => {
const { plugin } = await makeController();
const override = new Map([["demo", plugin]]);
const { result } = renderHook(() =>
useMidiController("demo", override)
);
expect(result.current.controller).toBe(plugin);
});
it("learn wraps captureNextLearn rejections into MIDI_LEARN_FAILED", {
timeout: 5000,
}, async () => {
const { plugin } = await makeController();
await plugin.initialize();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
// Patch the controller's `on` to throw synchronously to exercise
// the catch path on captureNextLearn.
const origOn = plugin.on.bind(plugin);
(plugin as unknown as { on: typeof origOn }).on = () => {
throw new Error("subscribe-failed");
};
let caught: unknown;
try {
await result.current.learn({
pluginId: "eq",
paramId: "gain",
});
} catch (e) {
caught = e;
}
(plugin as unknown as { on: typeof origOn }).on = origOn;
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED
);
});
it("learn wraps non-Error rejections too", { timeout: 5000 }, async () => {
const { plugin } = await makeController();
await plugin.initialize();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
const origOn = plugin.on.bind(plugin);
// Throw a non-Error value so the `cause instanceof Error`
// branch falls through to `undefined`.
(plugin as unknown as { on: typeof origOn }).on = (() => {
const reason: unknown = "string-rejection";
throw reason;
}) as typeof origOn;
let caught: unknown;
try {
await result.current.learn({
pluginId: "eq",
paramId: "gain",
});
} catch (e) {
caught = e;
}
(plugin as unknown as { on: typeof origOn }).on = origOn;
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,128 @@
import type {
ConnectionState,
MidiControllerPlugin,
} from "@suaveplan/audio-controllers";
import { captureNextLearn } from "@suaveplan/audio-controllers";
import { createContext, useContext, useSyncExternalStore } from "react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
/**
* A `MidiControllerRegistry` is any read-only lookup keyed by
* `controllerId` returning the live `MidiControllerPlugin` instance.
*
* In practice an application wires the registry once at the top of its
* React tree via `<MidiControllerRegistryProvider value={registry}>`
* and the Phase-3 surface (`useMidiController` and
* `<MidiControllerSetup>`) reads from it.
*/
export type MidiControllerRegistry = ReadonlyMap<string, MidiControllerPlugin>;
/**
* React context distributing the `MidiControllerRegistry` to the
* Phase-3 MIDI surface. Defaults to an empty registry so consumers
* outside the provider see "controller not found" instead of "null
* context" (which would couple to the `<AudioProvider>` lifecycle).
*/
export const MidiControllerRegistryContext = createContext<
MidiControllerRegistry | undefined
>(undefined);
MidiControllerRegistryContext.displayName = "SuaveplanMidiControllerRegistry";
/**
* Discriminated learn target — every concrete plugin parameter that
* `useMidiController(...).learn(target)` will bind to. Keeping the
* shape parallel to `bindParameter`'s call-site makes wiring trivial.
*/
export interface LearnTarget {
readonly pluginId: string;
readonly paramId: string;
}
export interface UseMidiControllerResult {
/** The resolved controller. `undefined` if the id is unknown. */
readonly controller: MidiControllerPlugin | undefined;
/**
* Current FSM connection state, mirrored from the controller's
* `state` event. Returns `'disconnected'` when the controller is
* unknown so consumers can render a graceful empty state.
*/
readonly state: ConnectionState;
/**
* Enter learn mode and resolve with the captured `(channel, cc)`
* pair. Rejects with `REACT_AUDIO.MIDI_LEARN_FAILED` if no
* controller is bound.
*/
learn(target: LearnTarget): Promise<{
readonly target: LearnTarget;
readonly channel: number;
readonly controller: number;
}>;
}
const EMPTY_UNSUBSCRIBE = (): void => undefined;
/**
* React hook subscribing to a single controller's connection-state
* event surface via `useSyncExternalStore`. Returns the live
* controller, its current state, and a `learn(target)` method that
* delegates to `captureNextLearn` from `@suaveplan/audio-controllers`.
*
* The lookup runs against the nearest
* `<MidiControllerRegistryProvider>`; an explicit `registry` argument
* overrides the context value (handy for tests).
*/
export function useMidiController(
controllerId: string,
registryOverride?: MidiControllerRegistry
): UseMidiControllerResult {
const ctxRegistry = useContext(MidiControllerRegistryContext);
const registry = registryOverride ?? ctxRegistry;
const controller = registry?.get(controllerId);
const subscribe = (notify: () => void): (() => void) => {
if (!controller) return EMPTY_UNSUBSCRIBE;
return controller.on("state", notify);
};
const getSnapshot = (): ConnectionState =>
controller ? controller.state : "disconnected";
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const learn = async (
target: LearnTarget
): Promise<{
readonly target: LearnTarget;
readonly channel: number;
readonly controller: number;
}> => {
if (!controller) {
throw new ReactAudioError(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED,
"react-audio.midi-learn-no-controller",
{ context: { controllerId, target } }
);
}
try {
const captured = await captureNextLearn(controller);
return {
target,
channel: captured.channel,
controller: captured.controller,
};
} catch (cause) {
throw new ReactAudioError(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED,
"react-audio.midi-learn-capture-failed",
{
context: { controllerId, target },
cause: cause instanceof Error ? cause : undefined,
}
);
}
};
return { controller, state, learn };
}