first commit
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# `BindingRegistry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BindingRegistry` is the discriminated dispatcher that links every bound
|
||||
control on a surface to the consumer-side logic that actually performs the
|
||||
side-effect. Atomic controls call `registry.dispatch(binding, value)`
|
||||
whenever a user-initiated `onChange` fires; the registry resolves the
|
||||
binding's `kind` and invokes the registered handler.
|
||||
|
||||
The constructor accepts a partial handler table. Any kind left unset falls
|
||||
back to the built-in default handler, which is the appropriate consumer
|
||||
hook adapter (for `pluginParam`, `transport`, `midiOut`, `midiIn`) or a
|
||||
working fan-out (for `compound`). Every leaf default throws
|
||||
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING` until a consumer registers
|
||||
production wiring — silently dropping a control update is never
|
||||
acceptable.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed kind dispatch for all six `Binding["kind"]` values.
|
||||
- Default compound fan-out via `defaultCompoundHandler`.
|
||||
- Per-kind override via the constructor or `withHandler(kind, handler)`
|
||||
builder.
|
||||
- Type-narrowed handler signatures so consumers can't accidentally cross
|
||||
binding kinds.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
const registry = new BindingRegistry({
|
||||
pluginParam: (binding, value) =>
|
||||
chain.get(binding.pluginId)?.setParameter(binding.paramPath, value),
|
||||
});
|
||||
|
||||
registry.dispatch(binding, knob.value);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
const base = new BindingRegistry({
|
||||
midiOut: (binding, value) =>
|
||||
output.sendMessage({
|
||||
kind: "controlChange",
|
||||
channel: binding.channel,
|
||||
controller: binding.cc,
|
||||
value: Math.round(value * 127),
|
||||
}),
|
||||
});
|
||||
|
||||
// Layer a recording spy on top of the production handler for an audit run.
|
||||
const spy = new BindingRegistry();
|
||||
const audited = base.withHandler("pluginParam", (binding, value) => {
|
||||
audit.push({ binding, value });
|
||||
spy.dispatch(binding, value);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new BindingRegistry(table?: BindingHandlerTable)` — constructor.
|
||||
- `registry.dispatch(binding: Binding, value: number): void` — full
|
||||
dispatch, including compound fan-out.
|
||||
- `registry.dispatchLeaf(binding: LeafBinding, value: number): void` —
|
||||
leaf-only dispatch; called by the default compound handler to avoid
|
||||
re-entering the compound check.
|
||||
- `registry.has(kind: BindingKind): boolean` — `true` when a handler is
|
||||
registered.
|
||||
- `registry.withHandler(kind, handler)` — returns a new registry with the
|
||||
given handler overridden.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dispatcher is built around an exhaustive `switch` over the leaf
|
||||
discriminator so adding a new binding kind is a compile-time fail — the
|
||||
switch needs a new branch and `BindingHandlerTable` needs a new optional
|
||||
slot. The compound default takes the owning registry as a parameter so it
|
||||
can route per-leaf calls through `dispatchLeaf`, sidestepping a recursive
|
||||
compound check that would otherwise be required by every test path.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `compound` handler
|
||||
|
||||
## Purpose
|
||||
|
||||
The `compound` handler is the binding-registry slot responsible for
|
||||
fanning a single control's value out to multiple leaf bindings — for
|
||||
example, a knob that simultaneously drives a plugin parameter, a MIDI CC,
|
||||
and a DMX channel. Unlike the leaf handlers, the compound handler always
|
||||
has a working default because compounding is a pure structural operation
|
||||
that doesn't depend on any external integration.
|
||||
|
||||
The default iterates `binding.bindings` in registration order and calls
|
||||
`registry.dispatchLeaf(leaf, value)` for each — every leaf is sent the
|
||||
same value, so the per-leaf range/scale logic stays in the leaf handlers.
|
||||
The schema rejects nested compounds at parse time, so the iteration is
|
||||
guaranteed to bottom out after one step.
|
||||
|
||||
## Features
|
||||
|
||||
- Default implementation needs no consumer wiring.
|
||||
- Stable iteration order (registration order) for deterministic test
|
||||
assertions.
|
||||
- Takes the owning `BindingRegistry` as a parameter so callers can
|
||||
customise the fan-out target (e.g. dispatch through a wrapping spy
|
||||
registry).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
// No compound override needed; the default fans out automatically.
|
||||
const registry = new BindingRegistry({ pluginParam: handler });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { defaultCompoundHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
// Manually fan out, perhaps after a custom pre-flight check.
|
||||
defaultCompoundHandler(compoundBinding, 0.42, registry);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultCompoundHandler(binding: CompoundBinding, value: number, registry: BindingRegistry): void`
|
||||
— iterates `binding.bindings` and dispatches each leaf through `registry.dispatchLeaf`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema's nested-compound rejection is enforced at parse time, but the
|
||||
default handler defends in depth by routing through `dispatchLeaf`, which
|
||||
accepts only the `LeafBinding` union. A nested compound would fail the
|
||||
TypeScript narrowing at the boundary even if a malicious caller bypassed
|
||||
the schema validator.
|
||||
@@ -0,0 +1,54 @@
|
||||
# `dmx` handler
|
||||
|
||||
## Purpose
|
||||
|
||||
The `dmx` handler is the binding-registry slot reserved for DMX universe /
|
||||
channel updates. The `@suaveplan/react-dmx` integration has not yet shipped,
|
||||
so this default throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`
|
||||
unconditionally. When the DMX integration lands the production handler will
|
||||
replace this default with a closure that writes the scaled value into the
|
||||
named universe/channel via the DMX transport.
|
||||
|
||||
Throwing here is doubly important: stage-lighting bindings tend to be
|
||||
load-bearing in live performance, and a silent drop of a DMX update can
|
||||
plunge a stage into darkness mid-cue. Failing loud at the default ensures
|
||||
operators see the gap before they take the surface live.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed signature `(binding: DmxBinding, value: number) => never`.
|
||||
- Error context preserves `universe`, `channel`, and the dropped `value`.
|
||||
- Default behaviour matches the other unwired handlers for uniform
|
||||
error-handling at the call site.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
const registry = new BindingRegistry({
|
||||
dmx: (binding, value) =>
|
||||
dmxTransport.send(binding.universe, binding.channel, value),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { dmxHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
expect(() =>
|
||||
dmxHandler({ kind: "dmx", universe: 1, channel: 33 }, 0.75)
|
||||
).toThrow(/BINDING_HANDLER_MISSING/);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `dmxHandler(binding: DmxBinding, value: number): never`
|
||||
— always throws with code `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
DMX traffic must be ordered and rate-limited; the production handler will
|
||||
need to coalesce rapid drag-driven updates. None of that is the default's
|
||||
problem — the default's only job is to refuse to silently drop traffic.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `midi-in` handler
|
||||
|
||||
## Purpose
|
||||
|
||||
The `midiIn` handler is the binding-registry slot that hosts an inbound
|
||||
MIDI subscription — a control bound this way reflects external CC traffic
|
||||
rather than driving it. The default export is the "unwired" implementation
|
||||
that throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`; consumers
|
||||
replace it with a closure that captures a subscription created by
|
||||
`useMidiInput(binding.portId)` and pumps the inbound value back into the
|
||||
control via the runtime's `setValue` channel.
|
||||
|
||||
Throwing at the default site is the right call even for a "passive" inbound
|
||||
binding: silently dropping inbound updates would leave the control's
|
||||
visual state out of sync with the device.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed signature `(binding: MidiInBinding, value: number) => never`.
|
||||
- Error context preserves `portId`, `channel`, `cc`, and the most recent
|
||||
inbound value.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
const registry = new BindingRegistry({
|
||||
midiIn: (binding, value) => surfaceValueStore.set(binding, value),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { midiInHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
try {
|
||||
midiInHandler({ kind: "midiIn", portId: "p1", channel: 1, cc: 7 }, 0);
|
||||
} catch (err) {
|
||||
// err.context.kind === "midiIn"
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `midiInHandler(binding: MidiInBinding, value: number): never`
|
||||
— always throws with code `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Because inbound bindings are an "inverse" of outbound ones, the same
|
||||
handler signature is reused with the understanding that the production
|
||||
handler treats the `value` argument as the most recent inbound observation
|
||||
rather than a value to send. Keeping the signatures unified simplifies the
|
||||
generic dispatcher and lets test spies swap inbound + outbound paths with
|
||||
identical types.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `midi-out` handler
|
||||
|
||||
## Purpose
|
||||
|
||||
The `midiOut` handler is the binding-registry slot that converts a
|
||||
control's value into an outbound MIDI Control Change. The default export
|
||||
is the "unwired" implementation that throws
|
||||
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`; consumers replace it with
|
||||
a closure that captures a `MidiOutputHandle` from
|
||||
`useMidiOutput(binding.portId)` and emits CC traffic on the bound
|
||||
`channel`/`cc` pair, scaled into the binding's optional `range`.
|
||||
|
||||
Throwing at the default keeps a partially-wired surface from masquerading
|
||||
as live. Once `useMidiOutput` is bridged the runtime forwards every drag,
|
||||
press, or keyboard increment as a single CC message.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed signature `(binding: MidiOutBinding, value: number) => never`.
|
||||
- Error context preserves `portId`, `channel`, `cc`, and the dropped value.
|
||||
- Encoding/range scaling are deferred to the production handler — this
|
||||
default is value-agnostic.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
const registry = new BindingRegistry({
|
||||
midiOut: (binding, value) =>
|
||||
output.sendMessage({
|
||||
kind: "controlChange",
|
||||
channel: binding.channel,
|
||||
controller: binding.cc,
|
||||
value: Math.round(value * 127),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { midiOutHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
expect(() =>
|
||||
midiOutHandler(
|
||||
{ kind: "midiOut", portId: "p1", channel: 1, cc: 7 },
|
||||
0.5
|
||||
)
|
||||
).toThrow();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `midiOutHandler(binding: MidiOutBinding, value: number): never`
|
||||
— always throws with code `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Range scaling is the consumer's responsibility because the schema's
|
||||
optional `range` can flip min/max, and the production handler may want to
|
||||
interpret it differently (linear vs. log) per surface. Keeping the default
|
||||
value-agnostic ensures the consumer never inherits an opinionated mapping
|
||||
they can't override.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `plugin-param` handler
|
||||
|
||||
## Purpose
|
||||
|
||||
The `pluginParam` handler is the binding-registry slot that dispatches a
|
||||
control's value to a plugin parameter via `useParameter` from
|
||||
`@suaveplan/react-audio`. The default export shipped here is the
|
||||
"unwired" implementation — it throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`
|
||||
unconditionally, on the principle that a consumer who has not registered a
|
||||
production handler should fail fast rather than silently drop control
|
||||
updates.
|
||||
|
||||
Production wiring lives one layer up: the `<Surface>` runtime, which has
|
||||
React-render scope, resolves `binding.pluginId` against the live
|
||||
`PluginChain`, invokes `useParameter(plugin, paramId)`, and registers a
|
||||
closure that calls `param.set(value)` into a `BindingRegistry` clone.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed signature `(binding: PluginParameterBinding, value: number) => never`.
|
||||
- Error context captures `pluginId`, `paramPath`, `value`, and `kind`.
|
||||
- Stable error code suitable for log-scraping and dashboards.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
const registry = new BindingRegistry({
|
||||
pluginParam: (binding, value) => {
|
||||
// call into the user's resolved plugin chain
|
||||
chain.get(binding.pluginId)?.setParameter(binding.paramPath, value);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { pluginParamHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
try {
|
||||
pluginParamHandler({ kind: "pluginParam", pluginId: "synth", paramPath: "cutoff" }, 0.42);
|
||||
} catch (err) {
|
||||
// err.code === REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `pluginParamHandler(binding: PluginParameterBinding, value: number): never`
|
||||
— always throws `ReactControlSurfaceError` with code
|
||||
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handler never returns; its TypeScript return type is `never` so the
|
||||
type system propagates the unreachable-after-call constraint up to callers.
|
||||
The error includes the binding's full identity payload so log scrapers can
|
||||
attribute a missing-handler incident to the precise binding without
|
||||
correlating against the source surface.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `transport` handler
|
||||
|
||||
## Purpose
|
||||
|
||||
The `transport` handler is the binding-registry slot that dispatches a
|
||||
control's value to the audio transport — play/stop toggles, tempo dials, the
|
||||
play-head position, and loop on/off. The default export is the "unwired"
|
||||
implementation that throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`;
|
||||
consumers replace it with a closure that captures the resolved transport
|
||||
from `useTransport` (in `@suaveplan/react-audio`) and switches on
|
||||
`binding.field`.
|
||||
|
||||
Throwing at the default site enforces a fail-fast policy. A surface with a
|
||||
transport-bound play button must never silently drop presses — either the
|
||||
runtime wired the handler, or the runtime crashes loudly on the first
|
||||
press.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed signature `(binding: TransportBinding, value: number) => never`.
|
||||
- Captures `field` and `value` in the error context.
|
||||
- Code identical to the other unwired defaults so consumers can write a
|
||||
single `catch` filter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BindingRegistry } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
const registry = new BindingRegistry({
|
||||
transport: (binding, value) => transport[binding.field](value),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { transportHandler } from "@suaveplan/react-control-surface/runtime/binding-registry";
|
||||
|
||||
// Inside a unit test that asserts the failure path
|
||||
expect(() =>
|
||||
transportHandler({ kind: "transport", field: "play" }, 1)
|
||||
).toThrow(/BINDING_HANDLER_MISSING/);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `transportHandler(binding: TransportBinding, value: number): never`
|
||||
— always throws `ReactControlSurfaceError` with code
|
||||
`REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `field` discriminator is preserved in the error context so the consumer
|
||||
can correlate the missing-handler crash with the precise transport field
|
||||
being driven. The function's return type is `never`, which keeps callers
|
||||
honest about the unreachable branch even in pre-wired test code.
|
||||
Reference in New Issue
Block a user