Initial Commit
This commit is contained in:
284
openspec/specs/audio-controllers/spec.md
Normal file
284
openspec/specs/audio-controllers/spec.md
Normal file
@@ -0,0 +1,284 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-controllers (audio-controllers)
|
||||
|
||||
**Package**: `@suaveplan/audio-controllers`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Browser-tier purity with injectable MIDI access
|
||||
|
||||
The `@suaveplan/audio-controllers` package MAY use the Web MIDI API (`navigator.requestMIDIAccess`, `MIDIAccess`, `MIDIInput`, `MIDIOutput`, `MIDIMessageEvent`, `MIDIConnectionEvent`) because it is a browser-tier package, but it SHALL import zero Node-only APIs (`node:fs`, `node:path`, `node:crypto`, `process`, `Buffer`). All MIDI access SHALL be reached only through an injected `MidiAccessAdapter` so the package never calls `navigator.requestMIDIAccess` directly — tests inject a real fake-device fixture rather than mocking the platform. The package's runtime `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
|
||||
|
||||
#### Scenario: Built dist references no Node globals
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:crypto`, `process`, or `Buffer`
|
||||
|
||||
#### Scenario: MIDI access flows through the injected adapter
|
||||
|
||||
- **WHEN** a `MidiControllerPlugin` subclass is constructed with a `MidiAccessAdapter` fixture and `requestAccess()` is called
|
||||
- **THEN** the package obtains its `MIDIAccess` from the adapter and never references `navigator` directly, so the fake-device fixture drives the test with no mocks
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist references no Node globals"` AND `src/base/midi-controller-plugin.test.ts::"requestAccess resolves MIDIAccess through the injected adapter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MidiControllerPlugin base class
|
||||
|
||||
The package SHALL export an abstract `MidiControllerPlugin` class exposing readonly `metadata: ControllerMetadata`, a readonly `sequencer: Sequencer`, the MIDI lifecycle methods `requestAccess(sysex?)` and `onStateChange(event)`, the feedback method `flushLEDs(updates)`, the mapping method `defineMappings(mappings)`, the learn-mode methods `enterLearnMode()` / `exitLearnMode()`, the typed event methods `on` / `off`, and the lifecycle methods `initialize()` / `destroy()`. `requestAccess` SHALL obtain a `MIDIAccess` via the injected `MidiAccessAdapter`, optionally requesting SysEx. `destroy()` SHALL release every held MIDI port, unsubscribe every listener, and stop the sequencer, and SHALL be idempotent.
|
||||
|
||||
#### Scenario: initialize wires inputs and exposes a sequencer
|
||||
|
||||
- **WHEN** a caller constructs a concrete `MidiControllerPlugin` subclass and awaits `initialize()`
|
||||
- **THEN** `requestAccess` has resolved through the adapter, `plugin.sequencer` is a usable `Sequencer`, and the plugin is subscribed to its MIDI inputs
|
||||
|
||||
#### Scenario: destroy releases ports and is idempotent
|
||||
|
||||
- **WHEN** a caller awaits `destroy()` twice
|
||||
- **THEN** the first call releases every MIDI port, unsubscribes listeners, and stops the sequencer; the second call is a no-op and does not throw
|
||||
|
||||
**Verified by:** `src/base/midi-controller-plugin.test.ts::"initialize wires MIDI inputs and exposes a sequencer"` AND `src/base/midi-controller-plugin.test.ts::"destroy releases ports and is idempotent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MIDI input and output mapping
|
||||
|
||||
The package SHALL map raw MIDI input through a declarative control-change DSL: `defineMappings(mappings: CCMapping[])` registers `CCMapping` records that bind a `(channel, controller)` pair to a named target parameter with an optional value transform and `min`/`max` range. Incoming MIDI messages whose status and data bytes match a registered mapping SHALL emit a typed `control` event carrying the mapped target name and the transformed value. Output mapping SHALL be the inverse: `flushLEDs(updates: LEDUpdate[])` batches `LEDUpdate` records and writes the corresponding MIDI output bytes to the device's `MIDIOutput` port in a single flush. Unmapped MIDI input SHALL emit a `raw` event and SHALL NOT throw.
|
||||
|
||||
#### Scenario: a mapped CC message emits a transformed control event
|
||||
|
||||
- **WHEN** a `CCMapping` binds `(channel 0, controller 7)` to `"volume"` with range `0..1` and a matching MIDI message arrives via the fake-device fixture
|
||||
- **THEN** a `control` event fires once with `{ target: "volume", value }` where `value` is the controller byte scaled into `0..1`
|
||||
|
||||
#### Scenario: flushLEDs batches output bytes in one write
|
||||
|
||||
- **WHEN** a caller passes three `LEDUpdate` records to `flushLEDs`
|
||||
- **THEN** the injected `MIDIOutput` fixture receives the three corresponding byte sequences in a single flush, in order
|
||||
|
||||
**Verified by:** `src/base/midi-controller-plugin.test.ts::"a mapped CC message emits a transformed control event"` AND `src/base/midi-controller-plugin.test.ts::"flushLEDs writes batched output bytes through the MIDIOutput fixture"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Controller connection-state FSM
|
||||
|
||||
The package SHALL model each controller's connection lifecycle as a `@suaveplan/fsm` state machine with the states `disconnected`, `connecting`, `connected`, and `error`. `onStateChange(event: MIDIConnectionEvent)` SHALL drive transitions: a `connected` port event moves `connecting`/`disconnected` → `connected`; a `disconnected` port event moves `connected` → `disconnected`; an access or port failure moves any state → `error`. On reconnect the machine SHALL re-apply the last known mappings and LED state (graceful state recovery). Every transition SHALL emit a typed `state` event carrying `{ from, to }`. An illegal transition request SHALL throw `AudioControllersError` with code `AUDIO_CONTROLLERS.INVALID_STATE`.
|
||||
|
||||
#### Scenario: hot-unplug then re-plug recovers mappings
|
||||
|
||||
- **WHEN** the fake-device fixture emits a `disconnected` then a `connected` `MIDIConnectionEvent` for the same port
|
||||
- **THEN** the FSM transitions `connected → disconnected → connecting → connected`, two `state` events fire, and the previously registered `CCMapping` set and LED state are re-applied
|
||||
|
||||
#### Scenario: illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE
|
||||
|
||||
- **WHEN** a caller forces a transition the FSM does not permit from the current state
|
||||
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.INVALID_STATE"` with `from` and `to` in `context`
|
||||
|
||||
**Verified by:** `src/base/connection-fsm.test.ts::"hot-unplug then re-plug recovers mappings and LED state"` AND `src/base/connection-fsm.test.ts::"illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Transport-synced Sequencer
|
||||
|
||||
The package SHALL export a `Sequencer` class constructed from a `@suaveplan/audio-transport` `Transport`. It SHALL expose `setTempo(bpm)`, `setSteps(steps: boolean[])`, `setStep(index, active)`, `start()`, `stop()`, `reset()`, and `onStep(index, callback)`. The sequencer SHALL schedule every step on the `Transport` using sample-accurate transport times and SHALL NOT use `setInterval` or `setTimeout` for step timing. `start()` SHALL register a repeating transport callback; on each scheduled tick the active step's `onStep` callbacks SHALL fire with the transport-provided time. `reset()` SHALL return the step cursor to index 0 without stopping the transport.
|
||||
|
||||
#### Scenario: active steps fire callbacks at transport ticks
|
||||
|
||||
- **WHEN** a caller sets a 4-step pattern with steps 0 and 2 active, registers `onStep` callbacks, and advances the `Transport`
|
||||
- **THEN** only the callbacks for steps 0 and 2 fire, each with the transport-supplied sample-accurate time, and no `setInterval` is used
|
||||
|
||||
#### Scenario: sequencer schedules through the transport only
|
||||
|
||||
- **WHEN** the sequencer is started and the test inspects timing sources
|
||||
- **THEN** every step is scheduled via `Transport` callbacks and the package issues no `setInterval`/`setTimeout`/`Date.now` calls for step timing
|
||||
|
||||
**Verified by:** `src/sequencer/sequencer.test.ts::"active steps fire onStep callbacks at transport ticks"` AND `src/sequencer/sequencer.test.ts::"sequencer schedules steps through the transport without setInterval"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Transport-synced Arpeggiator
|
||||
|
||||
The package SHALL export an `Arpeggiator` class constructed from a `Transport`. It SHALL expose `setPattern(notes: number[], mode: ArpMode)`, `setRate(interval)`, `start()`, and `stop()`. The arpeggiator SHALL be a `Transport` event subscriber: `start()` SHALL register a repeating transport callback at the configured rate, and each invocation SHALL emit the next note in the pattern (ordered per `ArpMode` — `up`, `down`, `updown`, `random`) with the transport-supplied time. Because scheduling is transport-driven, an arpeggio SHALL be renderable offline by advancing the transport without a real-time clock.
|
||||
|
||||
#### Scenario: arpeggio walks the pattern on transport ticks
|
||||
|
||||
- **WHEN** a caller sets pattern `[60, 64, 67]` in `up` mode at a `16n` rate, starts the arpeggiator, and advances the `Transport` three steps
|
||||
- **THEN** notes `60`, `64`, `67` are emitted in order, each with the transport-supplied time
|
||||
|
||||
#### Scenario: arpeggio renders offline
|
||||
|
||||
- **WHEN** the transport is advanced offline (no wall-clock) over a fixed range
|
||||
- **THEN** the arpeggiator emits the same note sequence it would emit in real time, proving it depends only on transport ticks
|
||||
|
||||
**Verified by:** `src/arpeggiator/arpeggiator.test.ts::"arpeggio walks the pattern in up mode on transport ticks"` AND `src/arpeggiator/arpeggiator.test.ts::"arpeggio produces an identical sequence under offline transport advance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MIDI clock timestamped against the transport tick timeline
|
||||
|
||||
Incoming MIDI messages SHALL be timestamped against the `Transport` tick timeline, not `Date.now()` or `setInterval`. For each `MIDIMessageEvent` the package SHALL compute the message's position relative to `transport.toSeconds(transport.ticks)`, derive the input latency, and schedule the resulting note or control action with `transport.scheduleOnce` at the transport-relative time. This ensures MIDI events are sample-accurate relative to the transport and not subject to JavaScript event-loop jitter.
|
||||
|
||||
#### Scenario: an incoming note is scheduled on the transport timeline
|
||||
|
||||
- **WHEN** the fake-device fixture emits a note-on `MIDIMessageEvent` with a known `timeStamp`
|
||||
- **THEN** the package schedules the note via `transport.scheduleOnce` at a transport-relative time derived from `transport.ticks`, and no `Date.now()` or `setInterval` call is involved in the timestamp computation
|
||||
|
||||
**Verified by:** `src/base/midi-clock.test.ts::"incoming MIDI message is scheduled against the transport tick timeline"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-device controller plugins
|
||||
|
||||
The package SHALL ship three concrete `MidiControllerPlugin` subclasses, each with a device-specific `ControllerMetadata` (name, manufacturer, port-name match patterns) and preset `CCMapping` set:
|
||||
|
||||
- `KontrolS88Plugin` — exposes `setScale(mode)`, `setArpMode(mode)`, `setPitchBend(value)`, `setModWheel(value)`, and `sendLED(keyIndex, color)`.
|
||||
- `MaschinePlugin` — exposes `setPadVelocity(padIndex, velocity)`, `setStep(padIndex, stepIndex, active)`, and `loadSample(slotIndex, sampleId)`.
|
||||
- `TraktorS4Plugin` — exposes `loadDeck(deckIndex, track)`, `setCrossfader(value)`, and `setChannelVolume(channel, value)`.
|
||||
|
||||
Each subclass SHALL drive its device through the inherited `flushLEDs` / output path and SHALL validate device-specific arguments (key index, pad index, deck index, channel) against the device's known range, throwing `AudioControllersError` with code `AUDIO_CONTROLLERS.DEVICE_ERROR` on an out-of-range argument.
|
||||
|
||||
#### Scenario: KontrolS88Plugin sends a key LED through the output path
|
||||
|
||||
- **WHEN** a caller calls `sendLED(keyIndex, color)` on a connected `KontrolS88Plugin`
|
||||
- **THEN** the injected `MIDIOutput` fixture receives the device-specific LED byte sequence for that key and colour
|
||||
|
||||
#### Scenario: out-of-range device argument throws AUDIO_CONTROLLERS.DEVICE_ERROR
|
||||
|
||||
- **WHEN** a caller calls `MaschinePlugin.setPadVelocity` with a pad index beyond the device's 16-pad range
|
||||
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.DEVICE_ERROR"` with the offending index in `context`
|
||||
|
||||
**Verified by:** `src/plugins/kontrol-s88/kontrol-s88.test.ts::"sendLED writes the device LED byte sequence through the output fixture"` AND `src/plugins/maschine/maschine.test.ts::"setPadVelocity rejects an out-of-range pad index with AUDIO_CONTROLLERS.DEVICE_ERROR"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Learn mode for CC mapping capture
|
||||
|
||||
The package SHALL support a learn mode on `MidiControllerPlugin`: `enterLearnMode()` SHALL put the plugin into a state where the next unmapped MIDI control message is captured as a candidate `CCMapping` and emitted via a typed `learn` event carrying the captured `(channel, controller)` pair; `exitLearnMode()` SHALL leave learn mode. While in learn mode the plugin SHALL NOT emit `control` events for captured messages. Capturing a control message SHALL automatically exit learn mode unless the plugin is configured for continuous capture.
|
||||
|
||||
#### Scenario: learn mode captures the next control message
|
||||
|
||||
- **WHEN** a caller calls `enterLearnMode()` and the fake-device fixture sends one CC message
|
||||
- **THEN** a `learn` event fires with the captured `(channel, controller)` pair, no `control` event fires for that message, and the plugin has exited learn mode
|
||||
|
||||
**Verified by:** `src/learn/learn-mode.test.ts::"learn mode captures the next control message and exits"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
Every public options object — `ControllerMetadata`, `CCMapping`, `LEDUpdate`, `SequencerOptions`, `ArpeggiatorOptions`, and each device plugin's constructor options — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioControllersError` with code `AUDIO_CONTROLLERS.INVALID_OPTIONS` carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: invalid CC mapping is rejected at the boundary
|
||||
|
||||
- **WHEN** `defineMappings` is called with a `CCMapping` whose `controller` byte is `200` (outside `0..127`)
|
||||
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.INVALID_OPTIONS"` with the Zod issues in `context`
|
||||
|
||||
**Verified by:** `src/base/midi-controller-plugin.test.ts::"defineMappings rejects an invalid CCMapping with AUDIO_CONTROLLERS.INVALID_OPTIONS"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL declare its codes via `defineCodes("AUDIO_CONTROLLERS", [...])` producing the dotted codes `AUDIO_CONTROLLERS.ACCESS_DENIED`, `AUDIO_CONTROLLERS.INVALID_OPTIONS`, `AUDIO_CONTROLLERS.INVALID_STATE`, `AUDIO_CONTROLLERS.DEVICE_ERROR`, `AUDIO_CONTROLLERS.NOT_CONNECTED`, and `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER`. The package SHALL export `AudioControllersErrorCode`, `AUDIO_CONTROLLERS_ERROR_METADATA`, `registerAudioControllersErrorCodes()`, and an `AudioControllersError` class extending `AudioControllersError` from `@suaveplan/audio-core`. Every error thrown by the package SHALL be an `AudioControllersError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
|
||||
|
||||
#### Scenario: denied MIDI access throws AUDIO_CONTROLLERS.ACCESS_DENIED
|
||||
|
||||
- **WHEN** the injected `MidiAccessAdapter` fixture rejects the access request
|
||||
- **THEN** `requestAccess()` throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.ACCESS_DENIED"` with the underlying rejection as `cause`
|
||||
|
||||
#### Scenario: every registered code is thrown by a call site
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `AUDIO_CONTROLLERS_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `src/base/midi-controller-plugin.test.ts::"requestAccess throws AUDIO_CONTROLLERS.ACCESS_DENIED when the adapter rejects"` AND `src/error-codes.test.ts::"every AUDIO_CONTROLLER code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation
|
||||
|
||||
`MidiControllerPlugin.requestAccess()` and `MidiControllerPlugin.initialize()`, plus `MaschinePlugin.loadSample()`, SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to reject with `AudioControllersError` code `AUDIO_CONTROLLERS.ACCESS_DENIED` (for access/init) or `AUDIO_CONTROLLERS.DEVICE_ERROR` (for `loadSample`) before any MIDI or device work; a signal aborted mid-operation SHALL terminate the operation at the next await boundary with the same error.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits initialize
|
||||
|
||||
- **WHEN** `initialize()` is called with an already-aborted signal
|
||||
- **THEN** it rejects with `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.ACCESS_DENIED"` before any MIDI access is requested
|
||||
|
||||
#### Scenario: mid-operation abort terminates loadSample
|
||||
|
||||
- **WHEN** a signal is aborted while `MaschinePlugin.loadSample()` is awaiting
|
||||
- **THEN** the operation rejects with `AUDIO_CONTROLLERS.DEVICE_ERROR` at the next await boundary and loads nothing further
|
||||
|
||||
**Verified by:** `src/base/midi-controller-plugin.test.ts::"initialize rejects AUDIO_CONTROLLERS.ACCESS_DENIED for a pre-aborted signal"` AND `src/plugins/maschine/maschine.test.ts::"loadSample honours mid-operation abort"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: spans `controller.midi_message`, `controller.sequencer_step`, and `controller.connect`; counters `audio_controller_midi_messages_total` and `audio_controller_sequencer_steps_total`; histograms `audio_controller_midi_latency_ms` and `audio_controller_sequencer_jitter_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
|
||||
|
||||
#### Scenario: instrumentation is inert without an exporter
|
||||
|
||||
- **WHEN** the package is imported and used with no OTel exporter registered
|
||||
- **THEN** no error is thrown and no global state is mutated at import time
|
||||
|
||||
#### Scenario: a handled MIDI message records a span and counter
|
||||
|
||||
- **WHEN** a MIDI message is handled with an exporter registered
|
||||
- **THEN** a `controller.midi_message` span is recorded and `audio_controller_midi_messages_total` increments by 1
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/base/midi-clock.test.ts::"handling a MIDI message records a span and counter when an exporter is registered"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module (`./base/midi-controller-plugin`, `./sequencer/sequencer`, `./arpeggiator/arpeggiator`, `./learn/learn-mode`, `./plugins/kontrol-s88`, `./plugins/maschine`, `./plugins/traktor-s4`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
|
||||
|
||||
#### Scenario: each feature module is independently importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-controllers/plugins/kontrol-s88`
|
||||
- **THEN** the import resolves to the Kontrol S88 plugin module without pulling the whole package barrel
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Bindable parameter control over AudioFxPlugin
|
||||
|
||||
The package SHALL expose a `bindParameter(plugin: AudioFxPlugin, paramId: string, source: MidiSource, opts?: { smoothingMs?: number }): UnsubscribeFn` helper from `@suaveplan/audio-controllers/binding` that wires a `MidiSource` (a typed value emitted by a controller — CC, note velocity, pitch-bend, aftertouch, fader, encoder) to a plugin parameter on any `AudioFxPlugin` from `@suaveplan/audio-plugins-fx`. The helper SHALL:
|
||||
|
||||
- look up the parameter via `plugin.parameterIndex.get(paramId)` (O(1)) and throw `AudioControllersError` with code `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER` if absent,
|
||||
- read the parameter's declared `min`, `max`, and optional `scale` and map the source's normalised value into that range using the declared scale (linear / log / exponential),
|
||||
- call `plugin.setParameter(paramId, mappedValue)` for immediate parameters and `plugin.setParameterCurve(paramId, { type: 'linear', value: mappedValue, duration: opts.smoothingMs / 1000 })` when an optional `smoothingMs` is provided (so a jittery hardware knob produces a smooth audio-rate ramp),
|
||||
- return an unsubscribe function that removes the binding without disturbing the controller or other bindings,
|
||||
- bind against `plugin.parameterIndex` references so subsequent parameter metadata changes (where supported by the plugin) are honoured without rebinding.
|
||||
|
||||
The package SHALL also expose a `bindPluginPreset(plugin: AudioFxPlugin, source: MidiSource, presets: ReadonlyArray<PresetSnapshot>): UnsubscribeFn` helper that, when triggered, applies the preset whose index corresponds to the source value (typical use: MIDI program-change events). Preset validation failures SHALL propagate the underlying `AudioControllersError` from `plugin.applyPreset` unchanged.
|
||||
|
||||
#### Scenario: bindParameter routes a CC into the plugin's parameter
|
||||
|
||||
- **WHEN** a caller calls `bindParameter(eq, 'lowShelfGain', { type: 'cc', channel: 1, number: 7 })` and an inbound MIDI CC `1/7` value `64/127` arrives
|
||||
- **THEN** `eq.setParameter('lowShelfGain', mid)` is called where `mid` equals the linear mapping of `64/127` into the parameter's declared `[min, max]` range within float tolerance
|
||||
|
||||
#### Scenario: bindParameter rejects an unknown parameter id
|
||||
|
||||
- **WHEN** `bindParameter(eq, 'nonexistent', source)` is called
|
||||
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.UNKNOWN_PARAMETER"` with the offending id in `context`
|
||||
|
||||
#### Scenario: smoothing option produces a parameter curve
|
||||
|
||||
- **WHEN** `bindParameter(eq, 'lowShelfGain', source, { smoothingMs: 20 })` is called and a CC value arrives
|
||||
- **THEN** the binding calls `plugin.setParameterCurve('lowShelfGain', { type: 'linear', value: mappedValue, duration: 0.02 })` instead of `setParameter`
|
||||
|
||||
#### Scenario: unbind removes the binding cleanly
|
||||
|
||||
- **WHEN** a caller calls the unsubscribe function returned by `bindParameter`
|
||||
- **THEN** subsequent inbound events for that source no longer call `setParameter` on the plugin, AND other bindings on the same source continue to fire
|
||||
|
||||
**Verified by:** `src/binding/bind-parameter.test.ts::"routes a CC into the plugin parameter with linear scale mapping"` AND `src/binding/bind-parameter.test.ts::"rejects unknown parameter id with AUDIO_CONTROLLERS.UNKNOWN_PARAMETER"` AND `src/binding/bind-parameter.test.ts::"smoothing option produces a parameter curve"` AND `src/binding/bind-parameter.test.ts::"unbind stops routing without disturbing other bindings"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user