129 lines
4.5 KiB
TypeScript
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 };
|
|
}
|