Files
suaveplan/packages/ui/react/react-audio/src/hooks/use-midi-controller/use-midi-controller.ts
2026-05-27 23:47:53 +01:00

129 lines
4.5 KiB
TypeScript

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 };
}