Initial Commit
This commit is contained in:
@@ -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