Initial Commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type LearnTarget,
|
||||
type MidiControllerRegistry,
|
||||
MidiControllerRegistryContext,
|
||||
type UseMidiControllerResult,
|
||||
useMidiController,
|
||||
} from "./use-midi-controller.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user