Initial Commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
# Design — enterprise-audio-plugins-fx
|
||||
|
||||
`@suaveplan/audio-plugins-fx` is the FX tier of the audio family — the layer between the universal data/timeline/transport packages below it and the rendering engines above. Its job is **not** to render audio; it is to express *what* audio processing happens, in a way that is universal (runs anywhere a Float32 buffer can flow), composable (plugins chain into ordered FX slots), introspectable (chains expose their full structure to plugins, hosts, and serializers), and extensible (third-party plugins implement one interface).
|
||||
|
||||
This document carries the package-specific design. The cross-cutting audio architecture (three-layer model, plugin slot taxonomy, `AudioError` hierarchy, universal `Segment` shape) lives in `openspec/archive/2026-05-14-enterprise-audio-core/design.md` and is referenced here, not duplicated.
|
||||
|
||||
---
|
||||
|
||||
## 1. Position in the audio family
|
||||
|
||||
```
|
||||
universal — runs anywhere
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ audio-core AudioDocument · PlanEngine · BlockProcessor│
|
||||
│ audio-timeline TimelineEngine · TimelineAdapter port │
|
||||
│ audio-transport Transport (musical clock) │
|
||||
│ audio-plugins-fx ← this package │
|
||||
│ AudioFxPlugin + PluginChain + AmbientState │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌──────────┴───────────┐
|
||||
│ │
|
||||
audio-engine-browser audio-engine-server
|
||||
(TimelineAdapter) (TimelineAdapter)
|
||||
owns: BusRegistry, owns: BusRegistry,
|
||||
hosts chains hosts chains
|
||||
```
|
||||
|
||||
The engines host chains. Each track's FX slot list is one `PluginChain` instance. The engine pushes `AmbientState` into the chain (track gain/pan/mute, transport BPM/state, listener position) so plugins see the truth of the project without coupling to engine types. The engine owns the project-level `BusRegistry`; chains read from / write to it via `ctx.buses`.
|
||||
|
||||
This means **one chain implementation services every render mode**: live browser playback, offline-render via `OfflineAudioContext`, headless server render to file. The same `PluginChain.process()` runs in all of them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why a `PluginChain` primitive belongs here
|
||||
|
||||
Three forces pull `PluginChain` into this package and not into the engines:
|
||||
|
||||
1. **One implementation across engines.** If each engine implemented chain composition, channel-validation, snapshot/restore, and PDC, the three would drift. Putting the chain in `audio-plugins-fx` means the engines reduce to "render the chain's output into my backend."
|
||||
2. **Universal use case beyond engines.** A test suite, a CLI batch processor, a worker that pre-renders a stem, an offline export job, a server-side mastering pipeline — all of these need plugin chaining without an `AudioContext` or `node:fs`. The chain is universal because the plugin contract is universal.
|
||||
3. **Round-trip serialization is a chain-level concern.** A chain knows its plugins, their order, their parameters, their bypass state, and which buses they read/write. Snapshotting that to JSON and restoring it (the "track preset" / "FX chain preset" feature in every DAW) belongs to the chain. The engine just renders the result.
|
||||
|
||||
The chain is **insert-only / serial**. Parallel processing (parallel compression, send effects) is solved with project-level buses: a track writes its post-FX output to a named bus; another chain reads from that bus. This is exactly how Pro Tools / Logic / Ableton / Reaper handle parallel routing — there is no per-chain parallel branching.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `AmbientState` propagation model — your "config tree"
|
||||
|
||||
A plugin needs to know things the chain knows: where in the chain it sits, what plugins came before, what the track's pan setting is, what BPM the transport is at, whether the project is in playback or recording state, where the 3D listener is, what sidechain buses are available.
|
||||
|
||||
Without explicit propagation, plugins reach for:
|
||||
|
||||
- module-level globals (poisons isolation, breaks testing),
|
||||
- per-plugin constructor injection (re-binds on every chain change, leaks engine types into plugins),
|
||||
- post-hoc setters (`plugin.setTrackPan(pan)`) — the upstream `@suaveplan/audio-engine` did this and it produced a mess.
|
||||
|
||||
The clean answer is the React-context pattern adapted to DSP. `setup(config, chain, opts)` hands every plugin a `ChainContext`. The plugin captures the reference and reads from it during `process()` (the reference is stable for the chain's lifetime; the contents update through controlled channels). Reads are O(1) on tracked properties; ambient updates emit `ambient-changed` events so plugins can recompute derived state lazily.
|
||||
|
||||
Concrete cases this design enables:
|
||||
|
||||
| Plugin | What it reads from `ChainContext` |
|
||||
|---|---|
|
||||
| `limiter` | `predecessors[i].getParameter('gain')` to compute makeup-gain headroom |
|
||||
| `compressor` (mid/side mode) | `ambient.track.layout` to decide whether to convert to mid/side |
|
||||
| `compressor` (sidechain) | `buses.get(this.sidechainBusName)` then `bus.read(scratchBuffer)` |
|
||||
| `reverb` | `ambient.track.pan` to skew the stereo image of the early reflections |
|
||||
| `delay` (BPM-sync) | `ambient.transport.tempo` + `setParameterAt(transportTimeSec + 1 bar)` |
|
||||
| `spatial-positional` | `ambient.listener.position` + `ambient.listener.orientation` |
|
||||
| `proximity-voice` | `ambient.listener.position` to attenuate distant voices |
|
||||
| `music-layer-mixer` | `ambient.extension.gameState` (custom domain state via the open extension key) |
|
||||
| Auto-gain compensation | walks `predecessors` summing `getParameter('gain')` to set inverse compensation |
|
||||
|
||||
The pattern is uniform: read once at the top of `process()`, compute derived state if needed, run DSP. No global state, no engine coupling.
|
||||
|
||||
Crucially, `PluginHandle.getParameter` is **read-only**. Peers can read each other's parameters but cannot mutate them — that would create non-local effects no one could reason about. Mutation always goes through the owning plugin's own `setParameter` / `setParameterCurve`.
|
||||
|
||||
---
|
||||
|
||||
## 4. `PluginIo` — channel topology as a contract
|
||||
|
||||
Every plugin declares its IO shape:
|
||||
|
||||
```ts
|
||||
class GainPlugin {
|
||||
readonly io = { inputs: [{ count: 'preserve' }], outputs: [{ count: 'preserve' }] }
|
||||
}
|
||||
class MonoToStereoUpmixer {
|
||||
readonly io = { inputs: [{ count: 1, layout: 'mono' }], outputs: [{ count: 2, layout: 'stereo' }] }
|
||||
}
|
||||
class FivePointOneToStereoDownmixer {
|
||||
readonly io = { inputs: [{ count: 6, layout: '5.1' }], outputs: [{ count: 2, layout: 'stereo' }] }
|
||||
}
|
||||
class SpectrumAnalyser {
|
||||
readonly io = { inputs: [{ count: 'any' }], outputs: [{ count: 'preserve' }] }
|
||||
}
|
||||
```
|
||||
|
||||
`PluginChain.add(plugin)` validates compatibility at insertion time. If the predecessor's effective output channels are 2 and the new plugin's input is `{ count: 1 }` and not `'any'`, the chain refuses with `AUDIO_FX.CHAIN_IO_INCOMPATIBLE` and names the mismatching pair. Insertion fails closed — better an early `throw` than silent channel-count drift mid-render.
|
||||
|
||||
The chain computes the effective output channel count per slot at insertion time and re-validates on `move`, `replace`, and `remove`. The engine reads `chain.getEffectiveOutputChannels()` to wire its post-chain bus correctly.
|
||||
|
||||
---
|
||||
|
||||
## 5. `PluginCapabilities` — explicit feature flags
|
||||
|
||||
Engines need to make routing decisions per plugin:
|
||||
|
||||
- Does it have a `createAudioNode()` fast path? → wire as a native Web Audio node.
|
||||
- Does it have a worklet processor? → fall back to `AudioWorkletNode`.
|
||||
- Is it sample-accurate? → safe to ramp parameters mid-block.
|
||||
- Is it stateful? → must call `reset()` on transport seek.
|
||||
- Does it have async setup? → engine must await before first `process()`.
|
||||
- Does it have a fixed or dynamic latency? → `latencySamples` plus `onLatencyChanged()` subscription for PDC recompute.
|
||||
|
||||
Putting these in a typed `capabilities` record (rather than detecting via optional-method presence) makes the dispatch matrix legible:
|
||||
|
||||
```ts
|
||||
const fxNode =
|
||||
cap.hasAudioNode ? plugin.createAudioNode!(ctx, chainCtx)
|
||||
: cap.hasWorklet ? wrapInWorklet(plugin)
|
||||
: wrapInScriptProcessor(plugin)
|
||||
```
|
||||
|
||||
Capabilities are static per plugin instance; they describe the contract the plugin honors, not runtime state. Bypass and dry/wet are separate (chain wrapper + per-plugin parameter, respectively).
|
||||
|
||||
---
|
||||
|
||||
## 6. Plugin delay compensation
|
||||
|
||||
Latency accrues. A pitch shifter introduces FFT-frame latency. A lookahead limiter introduces lookahead samples. A convolution reverb introduces its impulse-response length minus one. If track A has a pitch shifter and track B is dry, B arrives early and the mix smears.
|
||||
|
||||
Every plugin reports `getLatencySamples(): number`. `PluginChain.getEffectiveLatencySamples()` sums latencies across non-bypassed plugins. The engine reads each chain's effective latency, finds the maximum across all chains, and inserts a compensating delay on every chain shorter than the max. This is identical to how Pro Tools / Logic / Cubase handle PDC.
|
||||
|
||||
Dynamic latency (lookahead limiters that vary lookahead, look-ahead transient designers) is supported via `onLatencyChanged(listener)`. The chain re-emits as a chain-level `latency-changed` event; the engine recomputes per-chain compensation. Latency changes during playback can cause a brief mix-realignment glitch; that's normal in every DAW and is documented as a known cost.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bypass and dry/wet — matching enterprise DAW conventions
|
||||
|
||||
Surveyed: Pro Tools, Logic Pro, Ableton Live, Cubase, Studio One, Reaper, FL Studio.
|
||||
|
||||
**Bypass** is universally a host (slot) feature. Every chain insert has a bypass toggle. When bypassed, the plugin's `process()` is not called and the chain pipes input straight to output for that slot (no allocation; the chain re-uses the input buffer as the slot output). Bypass is therefore a `PluginChain` wrapper concern — the plugin doesn't implement it.
|
||||
|
||||
**Dry/wet** is universally a plugin feature where it makes musical sense (reverb, delay, modulation, distortion); it is not a host wrapper in any major DAW because not every plugin has a dry/wet meaning (an EQ's "wet" is just the EQ; a compressor's "wet" is parallel compression which deserves its own bus, not a knob). Plugins that want dry/wet expose a `wet` parameter (0..1) on their `parameters` array and handle the mix internally. The chain does NOT auto-wrap it.
|
||||
|
||||
Ableton-style "Audio Effect Racks" with chain-level dry/wet are an opt-in container, not a wrapper on every plugin. If we ever add `ChainGroup` (rack-style sub-chains), chain-level dry/wet lives there. Not in v1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Async setup with `AbortSignal`
|
||||
|
||||
Reverb impulse responses, sampler files, worklet module source — these need to load before `process()` can run. The contract: `setup(config, chain, opts?): Promise<void>` where `opts.signal` is an optional `AbortSignal`. Synchronous plugins return `Promise.resolve()`. The engine awaits `chain.setup(config, ambient)` before the first `process()`; `chain.setup()` awaits every plugin's setup in parallel via `Promise.all`. A signal aborted mid-setup propagates to every in-flight plugin via the same signal handle; setup rejects with `AUDIO_FX.ABORTED`.
|
||||
|
||||
`capabilities.asyncSetup === true` is advisory; engines should treat all setups as potentially async.
|
||||
|
||||
---
|
||||
|
||||
## 9. Transport-synced parameter automation
|
||||
|
||||
Two methods:
|
||||
|
||||
- `setParameterCurve(id, curve)` — ramp over `curve.duration` seconds, starting at `curve.startTime ?? now`. UI knobs, hand-drawn envelopes.
|
||||
- `setParameterAt(id, value, transportTimeSec)` — set the parameter to `value` at musical-time `transportTimeSec` (resolved against the active `Transport`). DAW automation lanes, MIDI clock events.
|
||||
|
||||
The chain's `ambient.transport.transportTimeSec` updates every block from the engine; the plugin's base class computes the per-block parameter value from the schedule queue using sample-accurate interpolation. Schedules persist across `reset()` *only* if the schedule's `transportTime` is in the future relative to the current transport position; past events are dropped on reset (matching DAW behavior on stop-rewind).
|
||||
|
||||
---
|
||||
|
||||
## 10. Project-level `BusRegistry`
|
||||
|
||||
Owned by the engine. Each engine adapter (`audio-engine-browser` / `audio-engine-server`) constructs one `BusRegistry` instance at init time and passes it into every chain it hosts via the chain's `ChainContext.buses`. Tracks publish their post-FX output to named buses (`registry.publish('kick', 2)`); plugins on other tracks consume them (`ctx.buses.get('kick')?.read(scratch)`).
|
||||
|
||||
The registry pattern matches Pro Tools "internal buses", Logic's "Bus 1..256", Ableton's "send tracks", Cubase's "group/FX channels", Reaper's "receives". The chain itself never instantiates a bus — that would scope sidechain to single-chain reach.
|
||||
|
||||
`SidechainBus.read(into)` is block-aligned. The engine guarantees that publishers write before subscribers read in the same block by ordering chain processing topologically (publishers first, subscribers after). Cycles in the bus graph are detected and rejected by the engine.
|
||||
|
||||
---
|
||||
|
||||
## 11. Capability dispatch table
|
||||
|
||||
The engine adapters use this matrix to choose how to host each plugin:
|
||||
|
||||
| Plugin path | `hasAudioNode` | `hasWorklet` | Engine renders via |
|
||||
|---|:---:|:---:|---|
|
||||
| `eq` | ✅ | – | `BiquadFilterNode` chain |
|
||||
| `filter` | ✅ | – | `BiquadFilterNode` |
|
||||
| `compressor` | ✅ | – | `DynamicsCompressorNode` (browser-native) |
|
||||
| `limiter` | – | – | `BlockProcessor` only (worklet wrapper) |
|
||||
| `gate` | – | – | `BlockProcessor` only |
|
||||
| `delay` | ✅ | – | `DelayNode` chain |
|
||||
| `reverb` | ✅ | – | `ConvolverNode` (browser); `BlockProcessor` (server) |
|
||||
| `pitch-shifter` | – | ✅ | `AudioWorkletNode` (browser); `BlockProcessor` (server) |
|
||||
| `synthesizer` | ✅ | – | `OscillatorNode` voice pool |
|
||||
| `sampler` | ✅ | – | `AudioBufferSourceNode` pool |
|
||||
| `mixer` | ✅ | – | `GainNode` matrix + `ChannelSplitterNode` / `ChannelMergerNode` |
|
||||
| `spatial-positional` | ✅ | – | `PannerNode` (HRTF) browser; `BlockProcessor` server |
|
||||
| All others | – | – | `BlockProcessor` only |
|
||||
|
||||
The matrix is documented in the plugin spec; the engine's FX-host requirement just states the dispatch order (AudioNode → Worklet → BlockProcessor) and a fallback contract.
|
||||
|
||||
---
|
||||
|
||||
## 12. New plugins from upstream
|
||||
|
||||
**`microphone`** — a complete mic processing chain modeled on the upstream 494-line plugin: frequency response shaping, resonance control, distortion, noise generation (pink/white/hum/hiss), dynamics (compressor + gate), character (wow & flutter, vintage warmth, tape saturation), output gain. Useful for podcast / voice-acting / vocal-effects workflows. Stateful (envelope followers, delay lines for wow/flutter); preserves channel count; no Web Audio fast path (BlockProcessor-only on browser via worklet).
|
||||
|
||||
**`glados`** — character vocoder. Phase-vocoder + formant-shift + ring-modulator pipeline approximating the GLaDOS character voice. Real workload, mid latency. Worklet-only on the browser path.
|
||||
|
||||
**`audio-player`** — a track-style audio playback plugin, distinct from `@suaveplan/audio-sprite-player`:
|
||||
|
||||
- `audio-sprite-player` triggers short cues from a sprite map (game SFX, UI sounds).
|
||||
- `audio-player` is a **track-style** plugin: loads a single source (URL or buffer), exposes transport-style controls (`play`/`pause`/`seek`/`getDuration`), reports playback position, and renders into the chain's output as the chain's *source* (used as the first plugin in a track's chain).
|
||||
|
||||
It implements `AudioFxPlugin` because uniform treatment with FX is desirable — the chain can have an audio-player as plugin 0 (source) and `[eq, compressor, limiter]` as plugins 1..3 in any order. The chain validates that exactly one source-shaped plugin (`io.inputs[0].count === 0`) sits at index 0.
|
||||
|
||||
**Game-audio building blocks**:
|
||||
|
||||
- **`spatial-positional`** — 3D positional audio: HRTF panning (browser), amplitude panning + distance attenuation (universal). Reads `ambient.listener` for position/orientation.
|
||||
- **`proximity-voice`** — voice-chat attenuation by listener distance, optional spatial blend, VAD threshold. Reads `ambient.listener`.
|
||||
- **`music-layer-mixer`** — N music stems, each with a per-stem level driven by a vector in `ambient.extension.gameState` (e.g. `{ tension, exploration, combat, stealth }`). Crossfades smoothly between game states.
|
||||
- **`voice-transform`** — character voice transformation (pitch, formant, distortion, mod). General-purpose; `glados` is a preset/specialization.
|
||||
|
||||
`r3f-audio` composes these four building blocks into the React-Three-Fiber surface. Non-R3F games consume them directly.
|
||||
|
||||
---
|
||||
|
||||
## 13. File layout
|
||||
|
||||
```text
|
||||
packages/audio/audio-plugins-fx/
|
||||
package.json, tsconfig.json, vitest.config.ts
|
||||
src/
|
||||
index.ts ← explicit named re-exports only
|
||||
error-codes.ts (+ .test.ts) ← AUDIO_FX.* codes via defineCodes
|
||||
base/
|
||||
audio-fx-plugin.ts (+ .md .types.ts .test.ts) ← interface + AbstractAudioFxPlugin
|
||||
capabilities.ts (+ .md .types.ts .test.ts) ← PluginCapabilities + helpers
|
||||
plugin-io.ts (+ .md .types.ts .test.ts) ← PluginIo + compatibility check
|
||||
parameter-schedule.ts (+ .md .test.ts) ← curve + at-transport-time evaluator
|
||||
preset.ts (+ .md .types.ts .test.ts) ← snapshot/restore w/ Zod
|
||||
index.ts
|
||||
chain/
|
||||
plugin-chain.ts (+ .md .types.ts .test.ts) ← PluginChain class
|
||||
chain-context.ts (+ .md .types.ts .test.ts) ← ChainContext factory
|
||||
ambient-state.ts (+ .md .types.ts .test.ts) ← AmbientState shape + reducer
|
||||
chain-snapshot.ts (+ .md .test.ts) ← Zod-validated session save/restore
|
||||
pdc.ts (+ .md .test.ts) ← latency summing + recompute
|
||||
bypass-wrapper.ts (+ .md .test.ts) ← chain-level bypass; passthrough buffer reuse
|
||||
bus-registry.ts (+ .md .types.ts .test.ts) ← interface + DefaultBusRegistry impl
|
||||
index.ts
|
||||
registry/
|
||||
plugin-registry.ts (+ .md .types.ts .test.ts) ← id → factory for session restore
|
||||
index.ts
|
||||
plugins/
|
||||
eq/ eq.ts .md .types.ts .schemas.ts .test.ts index.ts
|
||||
filter/ …
|
||||
compressor/ … (sidechain + mid/side)
|
||||
limiter/ …
|
||||
gate/ …
|
||||
reverb/ … (IR + algorithmic + presets/)
|
||||
delay/ … (ping-pong + BPM-sync)
|
||||
chorus/ …
|
||||
phaser/ …
|
||||
flanger/ …
|
||||
tremolo/ …
|
||||
auto-filter/ …
|
||||
distortion/ … (waveshaper/bitcrusher/overdrive)
|
||||
pitch-shifter/ … (FFT vocoder + worklet)
|
||||
synthesizer/ … (oscillator/envelope/voice-pool/portamento)
|
||||
sampler/ … (load + voice pool)
|
||||
mixer/ … (channels + sends + returns + master + VU)
|
||||
widener/ … (mid/side width)
|
||||
spatial-positional/ …
|
||||
proximity-voice/ …
|
||||
music-layer-mixer/ …
|
||||
voice-transform/ …
|
||||
microphone/ …
|
||||
glados/ …
|
||||
audio-player/ …
|
||||
presets/
|
||||
reverb/ cathedrals.ts rooms.ts plates.ts
|
||||
synth/ leads.ts pads.ts basses.ts
|
||||
glados/ defaults.ts
|
||||
__benchmarks__/
|
||||
eq.bench.ts reverb.bench.ts synth.bench.ts chain.bench.ts
|
||||
tests/
|
||||
integration.test.ts ← end-to-end chain w/ ambient + bus
|
||||
purity.test.ts ← processing path = zero browser/Node globals
|
||||
exports.test.ts ← every subpath exported; barrels use named exports
|
||||
bundle-size/
|
||||
eq.size.test.ts reverb.size.test.ts synth.size.test.ts chain.size.test.ts
|
||||
```
|
||||
|
||||
Five files per feature module: `<name>.ts`, `<name>.md`, `<name>.types.ts`, `<name>.schemas.ts` (Zod), `<name>.test.ts`. Barrel `index.ts` uses explicit named re-exports (§6a).
|
||||
|
||||
---
|
||||
|
||||
## 14. Integration with sibling packages
|
||||
|
||||
- **`@suaveplan/audio-core`** — supplies `BlockProcessor`, `BlockContext`, `ProcessorConfig`, and the `AudioError` base. Every plugin extends `BlockProcessor`.
|
||||
- **`@suaveplan/audio-transport`** — supplies the `Transport` type read by chains for `ambient.transport.*`. Used by `setParameterAt` to resolve musical time.
|
||||
- **`@suaveplan/audio-engine-browser`** — hosts chains via the chain's `process()`. Owns the project `BusRegistry`. Uses `capabilities.hasAudioNode` / `hasWorklet` to choose render path per plugin.
|
||||
- **`@suaveplan/audio-engine-server`** — hosts chains via the chain's `process()` (server tier always uses `BlockProcessor`). Owns the project `BusRegistry` for offline render with sidechain.
|
||||
- **`@suaveplan/audio-controllers`** — binds MIDI CCs to `plugin.parameterIndex.get(id)` lookups. Controllers do not depend on individual plugins; they depend on the `AudioFxPlugin` interface from `./base`.
|
||||
- **`@suaveplan/schema-registry`** — registers per-plugin Zod schemas for presets; centralizes versioning and migration.
|
||||
- **`@suaveplan/events`** — backs the chain's typed event bus.
|
||||
|
||||
---
|
||||
|
||||
## 15. Benchmarks
|
||||
|
||||
- `eq.bench.ts` — process 1 second of stereo audio through an 8-band EQ.
|
||||
- `reverb.bench.ts` — process 1 second through algorithmic + IR reverb (10 s cathedral).
|
||||
- `synth.bench.ts` — render 32-voice polyphonic synth.
|
||||
- `chain.bench.ts` — chain composition overhead: 8-plugin serial chain vs. raw `BlockProcessor` loop; expected ≤ 5 % overhead from the chain wrapper.
|
||||
|
||||
Published in README with `bun --cpu-prof-md` output.
|
||||
|
||||
---
|
||||
|
||||
## 16. Decisions and trade-offs
|
||||
|
||||
- **Chain primitive in `audio-plugins-fx`, not in the engines.** One implementation, used by both engines and by direct universal callers (tests, CLIs, workers). The engines reduce to "render the chain's output."
|
||||
- **AmbientState is read-only and engine-pushed.** No plugin can mutate ambient state. Reduces reasoning load to local state per plugin plus an immutable view of context.
|
||||
- **PluginHandle.getParameter is read-only.** Peers can read; mutation goes through the owning plugin. Prevents non-local effects.
|
||||
- **Bypass at chain level, dry/wet per plugin.** Matches every enterprise DAW surveyed. No exceptions, no special cases.
|
||||
- **Project-level BusRegistry on the engine, not the chain.** Cross-chain sidechain only works when buses are project-scoped — matches Pro Tools / Logic / Ableton / Reaper / Cubase.
|
||||
- **Async setup with `Promise<void>` return.** Simpler than a separate `loadAsync`; engines always await. Sync plugins return resolved promises (zero-cost).
|
||||
- **Latency reporting on the plugin, not the chain.** The plugin knows its latency; the chain sums. Dynamic latency via `onLatencyChanged` propagates up through `latency-changed` chain events.
|
||||
- **Game-audio decomposed into building blocks.** Reusable in non-R3F contexts; r3f-audio is one composition. Avoids a 700-line monolith.
|
||||
- **No chain-internal parallel branches.** Parallel routing uses the bus registry. Matches every major DAW; keeps the chain primitive small and predictable.
|
||||
- **`PluginIo` declares topology before insertion.** Validation at insert is loud and early; no silent channel-count drift during render.
|
||||
- **Zod schema as source of truth for parameter shape.** One declaration; types, validators, and preset shape all derive from it.
|
||||
- **No third-party plugin base package extraction in v1.** Third-party plugins implement `@suaveplan/audio-plugins-fx/base`. If a real third-party ecosystem materializes, we extract `audio-plugins-fx-base` then — non-breaking move.
|
||||
@@ -0,0 +1,328 @@
|
||||
# OpenSpec Change: enterprise-audio-plugins-fx
|
||||
|
||||
- **Change ID**: enterprise-audio-plugins-fx
|
||||
- **Package**: `@suaveplan/audio-plugins-fx`
|
||||
- **Category**: universal (with optional browser-native fast paths)
|
||||
- **Target path**: `packages/audio/audio-plugins-fx`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-15
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/audio-plugins-fx/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/audio-plugins-fx` on the genesis branch from scratch at enterprise quality. The package provides:
|
||||
|
||||
1. **A future-proof `AudioFxPlugin` contract** built on `@suaveplan/audio-core`'s `BlockProcessor` — universal DSP that runs identically in Node/Bun/server, browser worklets, and offline render, with an optional `createAudioNode()` fast path for browser-native Web Audio rendering.
|
||||
2. **A `PluginChain` universal primitive** — the missing piece. A first-class, headless, ordered plugin list with insertion-time channel validation, plugin delay compensation, hot-swap, snapshot/restore, an event bus, and chain-level bypass wrapping. The chain is what enterprise DAWs use as the "insert FX slot list"; it lives in `audio-plugins-fx` so every engine adapter (browser, server, offline) reuses one implementation.
|
||||
3. **A `ChainContext` / `AmbientState` propagation system** — every plugin in a chain receives a read-only view of the chain's structure (predecessors, successors, total length), of ambient state (track gain/pan/mute/solo/layout, transport BPM/time/state, listener position for 3D), and of project-level sidechain buses. This is the "config tree plugins can traverse" requirement: a late-chain compressor can read `ctx.ambient.track.pan`, a tempo-synced delay can read `ctx.ambient.transport.tempo`, a sidechain compressor can pull from `ctx.buses.get('kick')`. No global state, no prop drilling, fully typed.
|
||||
4. **22 first-party plugins** — EQ, filter, compressor, limiter, gate, reverb, delay, chorus, phaser, flanger, tremolo, auto-filter, distortion, pitch shifter, synthesizer, sampler, mixer, stereo widener, plus four newly-added plugins from upstream (`microphone`, `glados`, `audio-player`) and the building blocks for game audio (`spatial-positional`, `proximity-voice`, `music-layer-mixer`, `voice-transform`).
|
||||
5. **Subpath exports per plugin** — `sideEffects: false`, one `package.json` `exports` entry per plugin so importing `./eq` does not bundle `./synthesizer`.
|
||||
|
||||
The package is universal-tier: zero browser globals, zero Node globals on the `BlockProcessor` path. Browser-native rendering and worklet hosting are optional surfaces that engine adapters consume via capability flags.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — this is a fresh build. The upstream `packages/browser/audio-plugins-fx/` is browser-only and built on the obsolete monolithic `@suaveplan/audio-engine`; it is being replaced with this universal architecture.
|
||||
|
||||
---
|
||||
|
||||
## Parity Checklist (vs upstream `../suaveplan/packages/browser/audio-plugins-fx/`)
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|:---:|:---:|---|
|
||||
| `AudioPlugin` base class | ✅ | ⬆️ | Replaced by `AudioFxPlugin` interface extending `BlockProcessor` from `@suaveplan/audio-core`. Now universal — runs in browser, server, offline, worklet contexts identically. Optional `createAudioNode` / `getWorkletProcessor` for browser-native fast paths. |
|
||||
| `EQPlugin` (8-band parametric) | ✅ | ✅ | Same surface; now extends `AbstractAudioFxPlugin`; presets validated via Zod. |
|
||||
| `CompressorPlugin` | ✅ | ⬆️ | Adds sidechain via `setSidechain(bus)` consuming the project-level `BusRegistry`; adds mid/side mode reading `ChainContext.ambient.track.layout`. |
|
||||
| `ReverbPlugin` (IR + algorithmic) | ✅ | ⬆️ | `loadImpulseResponse` now accepts `AbortSignal`. |
|
||||
| `DelayPlugin` (ping-pong + BPM sync) | ✅ | ⬆️ | BPM sync now reads `ChainContext.ambient.transport.tempo` instead of a separate transport reference. |
|
||||
| `PitchShifterPlugin` (phase vocoder + worklet) | ✅ | ⬆️ | Reports FFT-frame `latencySamples` via the new `onLatencyChanged` event so chain PDC stays accurate. |
|
||||
| `SynthesizerPlugin` (oscillator + ADSR + voice pool + portamento) | ✅ | ✅ | Source plugin (`io.inputs[0].count === 0`); validates note range with `AUDIO_FX.INVALID_NOTE`. |
|
||||
| `MixerPlugin` (channel strips + sends + returns + master + VU) | ✅ | ✅ | Same surface; integrated with the `PluginChain`'s ambient state for solo across the project. |
|
||||
| `GLaDOSPlugin` (character vocoder) | ✅ | ✅ | Preset envelope validated against the plugin's Zod schema. |
|
||||
| `MicrophonePlugin` (full mic processing chain) | ✅ | ✅ | Worklet path is the browser fast path; universal `BlockProcessor` is the server path. |
|
||||
| `AudioPlayerPlugin` (track-style playback) | ✅ | ✅ | Source plugin; distinct from `@suaveplan/audio-sprite-player` (cue triggering). |
|
||||
| `AudioAnalyzerPlugin` (FFT / tempo / pitch / spectral) | ✅ | 🚫 | Scoped out: replaced by dedicated `@suaveplan/audio-analysis-{beat,pitch,loudness,spectrum}` packages so analysis plugins stay headless and don't depend on the FX-plugin surface. The plugin's old features are 100 % covered by the four split packages. |
|
||||
| `GameAudioPlugin` (spatial + voice-chat + music layers + voice transform) | ✅ | ⬆️ | Decomposed into four building blocks (`spatial-positional`, `proximity-voice`, `music-layer-mixer`, `voice-transform`) so non-R3F games can consume them directly; `r3f-audio` composes them for the R3F surface. |
|
||||
| New plugins (`filter`, `limiter`, `gate`, `chorus`, `phaser`, `flanger`, `tremolo`, `auto-filter`, `distortion`, `sampler`, `widener`) | – | ⬆️ | Genesis additions covering the standard FX surface every DAW expects. |
|
||||
| Chain primitive (`PluginChain`) | – | ⬆️ | Genesis addition — universal insert list with insertion-time IO validation, chain-level bypass, ambient-state propagation, plugin delay compensation, snapshot/restore, typed event surface. |
|
||||
| Project-level `BusRegistry` | – | ⬆️ | Genesis addition — sidechain routing crossing tracks/chains. |
|
||||
| Parameter automation curves + transport-time scheduling | partial (per-plugin) | ⬆️ | Universal `ParameterSchedule` evaluator owns wall-clock curves + transport-time events with shared queue; transport events override in-flight curves. |
|
||||
| Preset system | partial | ⬆️ | Universal `PresetSnapshot` envelope with `schemaVersion`, Zod validation at the boundary, atomic apply. |
|
||||
| Subpath exports per plugin | partial | ⬆️ | One subpath per plugin + `./base` + `./chain` + `./registry`; bundle-size tests prove tree-shaking. |
|
||||
| Unit tests / coverage | partial | ⬆️ | 100/100/100/100 across every module; property-based tests; per-plugin and chain benchmarks. |
|
||||
|
||||
One 🚫 row, justified: `audio-analyzer` is replaced by the dedicated `audio-analysis-*` family so analysis stays headless and composable with non-FX consumers (CLIs, server pipelines).
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/audio-core` (`workspace:^`) | `BlockProcessor`, `BlockContext`, `ProcessorConfig`, `AudioError` base |
|
||||
| `@suaveplan/audio-transport` (`workspace:^`) | `Transport` reference for `setParameterAt(transportTime)` resolution |
|
||||
| `@suaveplan/schema-registry` (`workspace:^`) | Plugin preset Zod schema registration |
|
||||
| `@suaveplan/events` (`workspace:^`) | Typed event bus for chain events |
|
||||
| `@suaveplan/error` (`workspace:^`) | `defineCodes`, `SuaveplanError` |
|
||||
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock` |
|
||||
| `@suaveplan/opentelemetry` (`workspace:^`) | OTel spans, counters, histograms |
|
||||
| `zod` (`catalog:forms`) | Source of truth for parameter shapes + preset schemas |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
### Core contract
|
||||
|
||||
```ts
|
||||
interface AudioFxPlugin extends BlockProcessor {
|
||||
readonly metadata: PluginMetadata
|
||||
readonly schema: ZodType<unknown> // source of truth for options + preset
|
||||
readonly parameters: ReadonlyArray<PluginParameter> // declaration order, for UI iteration
|
||||
readonly parameterIndex: ReadonlyMap<string, PluginParameter> // O(1) lookup
|
||||
readonly io: PluginIo // declared channel topology
|
||||
readonly capabilities: PluginCapabilities // explicit feature flags
|
||||
|
||||
// Lifecycle
|
||||
setup(config: ProcessorConfig, chain: ChainContext, opts?: { signal?: AbortSignal }): Promise<void>
|
||||
process(input: Float32Array[], output: Float32Array[], ctx: BlockContext): void
|
||||
reset(): void
|
||||
dispose(): void
|
||||
getLatencySamples(): number // for plugin delay compensation
|
||||
|
||||
// Parameters
|
||||
getParameter(id: string): number
|
||||
setParameter(id: string, value: number): void
|
||||
setParameterCurve(id: string, curve: ParameterCurve): void // ramp over seconds
|
||||
setParameterAt(id: string, value: number, transportTime: number): void // ramp to musical-time
|
||||
onLatencyChanged(listener: (samples: number) => void): UnsubscribeFn
|
||||
|
||||
// Atomic snapshots
|
||||
getPreset(): PresetSnapshot
|
||||
applyPreset(p: PresetSnapshot): void
|
||||
|
||||
// Optional browser-native fast paths
|
||||
createAudioNode?(ctx: BaseAudioContext, chain: ChainContext): AudioNode
|
||||
getWorkletProcessor?(): string
|
||||
|
||||
// Optional sidechain consumer
|
||||
setSidechain?(bus: SidechainBus | undefined): void
|
||||
}
|
||||
|
||||
interface PluginIo {
|
||||
readonly inputs: ReadonlyArray<ChannelSpec>
|
||||
readonly outputs: ReadonlyArray<ChannelSpec>
|
||||
}
|
||||
|
||||
interface ChannelSpec {
|
||||
readonly count: number | 'preserve' | 'any'
|
||||
readonly layout?: ChannelLayout // 'mono' | 'stereo' | 'mid-side' | '5.1' | '7.1' | 'ambisonic-1'
|
||||
}
|
||||
|
||||
interface PluginCapabilities {
|
||||
readonly hasAudioNode: boolean
|
||||
readonly hasWorklet: boolean
|
||||
readonly hasSidechain: boolean
|
||||
readonly sampleAccurate: boolean
|
||||
readonly stateful: boolean
|
||||
readonly asyncSetup: boolean
|
||||
readonly preservesChannelCount: boolean
|
||||
readonly latencySamples: number // initial; dynamic via getLatencySamples()
|
||||
}
|
||||
|
||||
interface PluginParameter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly value: number
|
||||
readonly min: number
|
||||
readonly max: number
|
||||
readonly default: number
|
||||
readonly unit?: ParameterUnit // 'db' | 'hz' | 'ms' | 'percent' | 'semitones' | …
|
||||
readonly scale?: 'linear' | 'log' | 'exponential'
|
||||
readonly automatable?: boolean
|
||||
}
|
||||
|
||||
interface ParameterCurve {
|
||||
readonly type: 'linear' | 'exponential' | 'target'
|
||||
readonly value: number
|
||||
readonly duration: number
|
||||
readonly startTime?: number
|
||||
}
|
||||
```
|
||||
|
||||
### The PluginChain primitive
|
||||
|
||||
```ts
|
||||
class PluginChain {
|
||||
constructor(opts?: PluginChainOptions) // { id, name, channels, sampleRate, blockSize, buses?, ambient? }
|
||||
|
||||
// Composition (insertion-time IO validation; events emitted)
|
||||
add(plugin: AudioFxPlugin, at?: number): void
|
||||
remove(idOrIndex: string | number): void
|
||||
move(from: number, to: number): void
|
||||
replace(idOrIndex, plugin, opts?: { crossfadeMs?: number }): Promise<void>
|
||||
bypass(idOrIndex, bypassed: boolean): void // chain-level wrapper; standard DAW behavior
|
||||
// Note: dry/wet is per-plugin (matching Pro Tools / Logic / Cubase / Reaper / Studio One);
|
||||
// the chain does NOT auto-wrap dry/wet. Plugins that need it expose a `wet` parameter.
|
||||
|
||||
// Lifecycle
|
||||
setup(config: ProcessorConfig, ambient: AmbientState, opts?: { signal?: AbortSignal }): Promise<void>
|
||||
process(input: Float32Array[], output: Float32Array[], ctx: BlockContext): void
|
||||
reset(): void
|
||||
dispose(): void
|
||||
|
||||
// Inspection — the "config tree plugins can traverse"
|
||||
readonly plugins: ReadonlyArray<AudioFxPlugin>
|
||||
findById(id: string): { plugin: AudioFxPlugin; index: number } | undefined
|
||||
getEffectiveLatencySamples(): number // sum across non-bypassed plugins (PDC)
|
||||
snapshot(): ChainSnapshot // Zod-validated session save
|
||||
restore(s: ChainSnapshot): Promise<void>
|
||||
|
||||
// Ambient updates pushed from track / transport / scene
|
||||
updateAmbient(patch: Partial<AmbientState>): void
|
||||
|
||||
// Events
|
||||
on(event: ChainEvent, listener): UnsubscribeFn
|
||||
// events: 'plugin-added' | 'plugin-removed' | 'plugin-moved' | 'plugin-replaced'
|
||||
// | 'parameter-changed' | 'preset-applied' | 'bypass-changed'
|
||||
// | 'ambient-changed' | 'latency-changed'
|
||||
}
|
||||
```
|
||||
|
||||
### Ambient state and ChainContext
|
||||
|
||||
```ts
|
||||
interface ChainContext {
|
||||
readonly chain: ChainHandle // read-only view of the chain
|
||||
readonly position: { readonly index: number; readonly total: number }
|
||||
readonly predecessors: ReadonlyArray<PluginHandle> // upstream peers
|
||||
readonly successors: ReadonlyArray<PluginHandle> // downstream peers
|
||||
readonly ambient: AmbientState
|
||||
readonly buses: BusRegistry // project-level (engine-owned)
|
||||
}
|
||||
|
||||
interface PluginHandle {
|
||||
readonly id: string
|
||||
readonly metadata: PluginMetadata
|
||||
readonly io: PluginIo
|
||||
readonly capabilities: PluginCapabilities
|
||||
readonly bypassed: boolean
|
||||
getParameter(id: string): number // READ-ONLY view of peer parameters
|
||||
getLatencySamples(): number
|
||||
}
|
||||
|
||||
interface AmbientState {
|
||||
readonly track: {
|
||||
readonly id: string
|
||||
readonly gainDb: number
|
||||
readonly pan: number // -1 (full L) .. +1 (full R) — the LR-balance case
|
||||
readonly mute: boolean
|
||||
readonly solo: boolean
|
||||
readonly layout: ChannelLayout
|
||||
}
|
||||
readonly transport: {
|
||||
readonly tempo: number // BPM
|
||||
readonly timeSignature: TimeSignature
|
||||
readonly transportTimeSec: number
|
||||
readonly state: 'stopped' | 'playing' | 'paused' | 'recording'
|
||||
readonly ppqn: number
|
||||
}
|
||||
readonly listener?: { readonly position: Vec3; readonly orientation: Quat; readonly velocity: Vec3 }
|
||||
readonly extension: Readonly<Record<string, unknown>> // open extension point
|
||||
}
|
||||
```
|
||||
|
||||
### Project-level bus registry (engine-owned, chain references it)
|
||||
|
||||
```ts
|
||||
interface BusRegistry {
|
||||
get(name: string): SidechainBus | undefined
|
||||
list(): ReadonlyArray<string>
|
||||
publish(name: string, channels: number): SidechainPublisher // create or get
|
||||
subscribe(name: string, listener: (bus: SidechainBus | undefined) => void): UnsubscribeFn
|
||||
}
|
||||
|
||||
interface SidechainBus {
|
||||
readonly name: string
|
||||
readonly channels: number
|
||||
read(into: Float32Array[]): void // block-aligned read
|
||||
}
|
||||
|
||||
interface SidechainPublisher {
|
||||
write(samples: Float32Array[]): void // chain writes its post-fx output
|
||||
close(): void
|
||||
}
|
||||
```
|
||||
|
||||
The chain reads from `ctx.buses` via its `ChainContext`. The actual `BusRegistry` instance lives on the engine adapter (`audio-engine-browser` / `audio-engine-server`) so cross-track / cross-chain routing works across the whole project.
|
||||
|
||||
### Subpath exports (one per plugin + the chain + base)
|
||||
|
||||
```
|
||||
@suaveplan/audio-plugins-fx
|
||||
├── ./base AudioFxPlugin, PluginIo, PluginCapabilities, ParameterCurve, PresetSnapshot, AbstractAudioFxPlugin
|
||||
├── ./chain PluginChain, ChainContext, AmbientState, BusRegistry, ChainSnapshot, ChainEvent
|
||||
├── ./registry PluginRegistry (id → factory; for session restore)
|
||||
├── ./eq, ./filter, ./compressor, ./limiter, ./gate
|
||||
├── ./reverb, ./delay, ./chorus, ./phaser, ./flanger
|
||||
├── ./tremolo, ./auto-filter
|
||||
├── ./distortion, ./pitch-shifter
|
||||
├── ./synthesizer, ./sampler
|
||||
├── ./mixer, ./widener
|
||||
├── ./spatial-positional ← building block
|
||||
├── ./proximity-voice ← building block
|
||||
├── ./music-layer-mixer ← building block
|
||||
├── ./voice-transform ← building block
|
||||
├── ./microphone ← new from upstream
|
||||
├── ./glados ← new from upstream
|
||||
├── ./audio-player ← new from upstream
|
||||
└── ./presets/* lazy preset packs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New additions for genesis (beyond upstream)
|
||||
|
||||
1. **Universal `BlockProcessor` base** — every plugin runs headless. Browser-native rendering is optional.
|
||||
2. **`PluginChain` primitive** with chain-level bypass, snapshot/restore, hot-swap, events, PDC computation.
|
||||
3. **`ChainContext` + `AmbientState`** — read-only ambient propagation for track / transport / listener / sidechain.
|
||||
4. **`PluginIo` channel topology** — explicit input/output channel specs validated at chain insertion; supports upmix, downmix, channel-count change.
|
||||
5. **`PluginCapabilities` flags** — explicit feature detection; replaces opacity of optional methods.
|
||||
6. **Project-level `BusRegistry`** — first-class sidechain routing.
|
||||
7. **Plugin delay compensation** — every plugin reports `getLatencySamples()`; chains sum it; engines apply per-track compensation.
|
||||
8. **Dynamic latency** — `onLatencyChanged` event for lookahead limiters etc.
|
||||
9. **Transport-synced parameter automation** — `setParameterAt(transportTime)` for DAW automation lanes.
|
||||
10. **Async setup with `AbortSignal`** — reverb IRs, sampler files, worklet modules load via `setup(): Promise<void>` cancellable mid-load.
|
||||
11. **Zod-typed parameter schemas** — single source of truth; parameter list + preset shape derived from one schema.
|
||||
12. **Game-audio decomposed into building blocks** — `spatial-positional`, `proximity-voice`, `music-layer-mixer`, `voice-transform` — usable in `r3f-audio` and non-R3F games alike.
|
||||
13. **Three plugins ported from upstream** — `microphone`, `glados`, `audio-player`.
|
||||
14. **OTel instrumentation per-plugin and per-chain** — latency histograms, block counters, parameter-change counters, preset-apply counters.
|
||||
15. **Property-based tests** — gain plugin: 0 dB → identity, -∞ dB → silence; bypass → bit-identity; chain order invariance for commutative pairs.
|
||||
16. **Per-plugin and chain benchmark suite** — block throughput, chain composition overhead.
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
|
||||
|
||||
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/schema-registry`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events` published to Verdaccio.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`@suaveplan/audio-plugins-fx` sits in the FX tier. At the time of this change none of its dependents exist on genesis yet — they land in later changes in this same batch — so there is no existing dependent surface to test or migrate. Each dependent is created against this package's spec delta, not retrofitted.
|
||||
|
||||
- `@suaveplan/audio-engine-browser` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-browser`. It hosts one `PluginChain` per track via the chain's `process()` method, owns the project `BusRegistry`, and maps each plugin's `createAudioNode()` into its node graph where present.)
|
||||
- `@suaveplan/audio-engine-server` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-server`. It hosts the chain's `BlockProcessor` path per track and owns the project `BusRegistry`.)
|
||||
- `@suaveplan/audio-controllers` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-controllers`. It binds MIDI CCs to `plugin.parameterIndex.get(id)` lookups for live parameter control.)
|
||||
- `@suaveplan/audio-cli` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-cli`.)
|
||||
- `@suaveplan/r3f-audio` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: requires re-authoring per `openspec/changes/REACT_AUDIO_WAVE_REWRITE_COMPLETE.md`. The rewritten version composes the four `spatial-positional` / `proximity-voice` / `music-layer-mixer` / `voice-transform` building blocks from this package.)
|
||||
|
||||
Once landed, any subsequent change to this package's public surface MUST run `bunx turbo test --filter=...@suaveplan/audio-plugins-fx` per `openspec/AGENTS.md` §13 step 10.
|
||||
@@ -0,0 +1,667 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-plugins-fx (audio-plugins-fx)
|
||||
|
||||
**Package**: `@suaveplan/audio-plugins-fx`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity with guarded browser surface
|
||||
|
||||
The `@suaveplan/audio-plugins-fx` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `AudioWorkletNode`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`) on the `BlockProcessor` processing path. The only browser-touching surface SHALL be the optional `createAudioNode(context)` method and the `getWorkletProcessor()` source-string accessor; both SHALL be optional members guarded so that a plugin used purely as a `BlockProcessor` never reaches a platform global. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/schema-registry`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
|
||||
|
||||
#### Scenario: Built dist references no platform globals on the processing path
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd excluding the `createAudioNode` / worklet-source modules
|
||||
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `node:fs`, `node:path`, `process`, or `Buffer`
|
||||
|
||||
#### Scenario: Tests pass under both runtimes
|
||||
|
||||
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
|
||||
- **THEN** both runs pass with identical results and every plugin's `BlockProcessor` path runs without a browser global present
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist processing path references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioFxPlugin base interface
|
||||
|
||||
The package SHALL export an `AudioFxPlugin` interface that extends `BlockProcessor` from `@suaveplan/audio-core` and adds:
|
||||
|
||||
- a readonly `metadata: PluginMetadata`,
|
||||
- a readonly `schema: ZodType<unknown>` — the single source of truth for the plugin's options and preset shape,
|
||||
- a readonly `parameters: ReadonlyArray<PluginParameter>` (declaration order, for UI iteration) AND a readonly `parameterIndex: ReadonlyMap<string, PluginParameter>` (O(1) lookup),
|
||||
- a readonly `io: PluginIo` declaring its channel topology (see "Channel topology" requirement),
|
||||
- a readonly `capabilities: PluginCapabilities` declaring its feature flags (see "Capabilities" requirement),
|
||||
- parameter accessors `getParameter(id)`, `setParameter(id, value)`, `setParameterCurve(id, curve)`, and `setParameterAt(id, value, transportTimeSec)`,
|
||||
- a `getLatencySamples(): number` accessor and `onLatencyChanged(listener): UnsubscribeFn` event,
|
||||
- preset methods `getPreset()` and `applyPreset(preset)`,
|
||||
- the optional browser-native fast paths `createAudioNode?(ctx, chain)` and `getWorkletProcessor?()`,
|
||||
- an optional `setSidechain?(bus: SidechainBus | undefined)` consumer hook.
|
||||
|
||||
Every concrete plugin in this package SHALL implement `AudioFxPlugin`, and therefore SHALL implement the full `BlockProcessor` contract. A reference `AbstractAudioFxPlugin` base class SHALL provide parameter storage, curve / transport-time schedule evaluation, latency-event emission, capabilities defaults, and metadata wiring so concrete plugins implement only DSP.
|
||||
|
||||
#### Scenario: Concrete plugin satisfies the full contract
|
||||
|
||||
- **WHEN** any exported plugin instance is checked
|
||||
- **THEN** it exposes the full `AudioFxPlugin` surface (`metadata`, `schema`, `parameters`, `parameterIndex`, `io`, `capabilities`, the four parameter accessors, `getLatencySamples`, `onLatencyChanged`, `getPreset`, `applyPreset`) AND the full `BlockProcessor` surface (`name`, `setup`, `process`, `reset`, `dispose`)
|
||||
|
||||
#### Scenario: parameterIndex agrees with parameters
|
||||
|
||||
- **WHEN** any plugin instance is checked
|
||||
- **THEN** for every entry `p` in `parameters`, `parameterIndex.get(p.id) === p`, AND `parameterIndex.size === parameters.length`
|
||||
|
||||
#### Scenario: Browser members are optional and absent by default
|
||||
|
||||
- **WHEN** a plugin that ships no browser path is constructed
|
||||
- **THEN** `createAudioNode` and `getWorkletProcessor` are `undefined`, `capabilities.hasAudioNode === false`, `capabilities.hasWorklet === false`, and the plugin still processes blocks correctly
|
||||
|
||||
**Verified by:** `src/base/audio-fx-plugin.test.ts::"every exported plugin implements the full AudioFxPlugin contract"` AND `src/base/audio-fx-plugin.test.ts::"parameterIndex agrees with parameters for every plugin"` AND `src/base/audio-fx-plugin.test.ts::"browser members and capability flags are absent on universal-only plugins"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BlockProcessor processing contract for every plugin
|
||||
|
||||
Every plugin SHALL implement `process(input, output, ctx)` operating block-by-block on `Float32Array[]` without allocating per-block, SHALL implement `setup(config, chain, opts?): Promise<void>` to allocate state from `ProcessorConfig` (`sampleRate`, `channels`, `blockSize`) and capture the `ChainContext` reference and honour an optional `AbortSignal` from `opts.signal`, SHALL implement `reset()` to clear all internal DSP state (delay lines, filter histories, envelopes) without re-allocating, and SHALL implement `dispose()` to release retained buffers. `setup` SHALL be idempotent: calling it twice without `dispose()` between SHALL reuse allocated buffers and re-bind the `ChainContext`. Calling `process` before `setup` SHALL throw `AudioError` with code `AUDIO_FX.NOT_INITIALIZED`. An `input`/`output` channel-count mismatch against the configured `channels` SHALL throw `AudioError` with code `AUDIO_FX.CHANNEL_MISMATCH`. A `setup` call whose `opts.signal` is already aborted SHALL reject with `AudioError` code `AUDIO_FX.ABORTED` before any allocation; a signal aborted mid-setup SHALL abort any in-flight async loads (impulse responses, sample files, worklet modules) and reject with the same code.
|
||||
|
||||
#### Scenario: process before setup is rejected
|
||||
|
||||
- **WHEN** a caller invokes `process` on a freshly constructed plugin without calling `setup` first
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.NOT_INITIALIZED"`
|
||||
|
||||
#### Scenario: reset clears state without re-allocation
|
||||
|
||||
- **WHEN** a caller feeds a signal through a stateful plugin, calls `reset()`, then feeds silence
|
||||
- **THEN** the output is silence (no residual tail) and the plugin's buffers are the same instances as before `reset()`
|
||||
|
||||
**Verified by:** `src/base/audio-fx-plugin.test.ts::"process throws AUDIO_FX.NOT_INITIALIZED before setup"` AND `src/base/audio-fx-plugin.test.ts::"reset clears DSP state without re-allocating buffers"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parameter model and automation curves
|
||||
|
||||
Each plugin SHALL declare its `parameters` as `PluginParameter` records (`id`, `name`, `value`, `min`, `max`, `default`, optional `unit` ∈ `'db' | 'hz' | 'ms' | 'percent' | 'semitones' | 'samples' | 'ratio' | 'enum'`, optional `scale` ∈ `'linear' | 'log' | 'exponential'`, optional `automatable`). `setParameter(id, value)` SHALL clamp the value to `[min, max]` and take effect on the next `process` block. `setParameterCurve(id, curve)` SHALL accept a `ParameterCurve` (`type` ∈ `'linear' | 'exponential' | 'target'`, `value`, `duration` seconds, optional `startTime` wall-time seconds) and ramp the parameter sample-accurately across subsequent blocks using the block context's sample offset. `setParameterAt(id, value, transportTimeSec)` SHALL schedule the parameter to reach `value` at musical-time `transportTimeSec` (resolved against `ChainContext.ambient.transport.transportTimeSec`); past events SHALL be dropped on transport `reset()`/seek-rewind. `getParameter(id)` SHALL return the current value or `undefined` for an unknown id. Setting, curving, or scheduling an unknown parameter id SHALL throw `AudioError` with code `AUDIO_FX.UNKNOWN_PARAMETER`. Setting a curve or transport schedule on a parameter whose `automatable` is `false` SHALL throw `AudioError` with code `AUDIO_FX.PARAMETER_NOT_AUTOMATABLE`. `setParameterCurve` and `setParameterAt` for the same parameter SHALL share a single schedule queue; later schedules override earlier overlapping ones.
|
||||
|
||||
#### Scenario: setParameter clamps to the declared range
|
||||
|
||||
- **WHEN** a caller calls `setParameter` with a value above the parameter's `max`
|
||||
- **THEN** `getParameter` returns exactly `max` and the next processed block reflects the clamped value
|
||||
|
||||
#### Scenario: linear curve ramps sample-accurately across blocks
|
||||
|
||||
- **WHEN** a caller sets a `linear` curve from the current value to a target over a known duration
|
||||
- **THEN** the parameter's per-sample value at each block boundary matches the analytic ramp within floating-point tolerance
|
||||
|
||||
#### Scenario: unknown parameter id is rejected
|
||||
|
||||
- **WHEN** a caller calls `setParameter` with an id not present in `parameters`
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.UNKNOWN_PARAMETER"`
|
||||
|
||||
#### Scenario: setParameterAt lands on the configured transport beat
|
||||
|
||||
- **WHEN** a caller calls `setParameterAt(id, target, transportTimeSec)` while the chain's ambient `transportTimeSec` is in the past relative to `transportTimeSec`, then processes blocks until the transport advances past the scheduled time
|
||||
- **THEN** the parameter reaches `target` by the block whose start sample maps to `transportTimeSec` within sample-accurate tolerance
|
||||
|
||||
#### Scenario: past schedule is dropped on transport rewind
|
||||
|
||||
- **WHEN** a caller schedules a future event with `setParameterAt`, then the chain receives an `updateAmbient` that resets `transportTimeSec` to before the schedule's time, then `reset()` is called
|
||||
- **THEN** the schedule remains pending; if instead the schedule's time is now in the past after the rewind, `reset()` drops it
|
||||
|
||||
**Verified by:** `src/base/audio-fx-plugin.test.ts::"setParameter clamps to the declared min/max range"` AND `src/base/audio-fx-plugin.test.ts::"linear parameter curve ramps sample-accurately across blocks"` AND `src/base/audio-fx-plugin.test.ts::"setParameter throws AUDIO_FX.UNKNOWN_PARAMETER for an unknown id"` AND `src/base/audio-fx-plugin.test.ts::"setParameterAt lands on the configured transport time"` AND `src/base/audio-fx-plugin.test.ts::"reset drops past transport schedules and keeps future ones"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Versioned Zod-validated preset system
|
||||
|
||||
Every plugin SHALL register a versioned preset schema with `@suaveplan/schema-registry` under a stable key (e.g. `audio-fx:eq-preset`). `getPreset()` SHALL return a plain serializable object that validates against the registered schema. `applyPreset(preset)` SHALL validate the input against the registered schema before mutating any parameter; a validation failure SHALL throw `AudioError` with code `AUDIO_FX.INVALID_PRESET` carrying the Zod issue list in `context`. A preset whose `schemaVersion` is newer than the plugin supports SHALL throw `AudioError` with code `AUDIO_FX.PRESET_VERSION_UNSUPPORTED`. Preset packs (e.g. cathedral reverb impulse descriptors) SHALL be lazy-loaded subpath exports under `./presets/*`.
|
||||
|
||||
#### Scenario: getPreset round-trips through applyPreset
|
||||
|
||||
- **WHEN** a caller captures `p = plugin.getPreset()`, mutates a parameter, then calls `plugin.applyPreset(p)`
|
||||
- **THEN** every parameter is restored to its captured value and `getPreset()` deep-equals `p`
|
||||
|
||||
#### Scenario: malformed preset is rejected at the boundary
|
||||
|
||||
- **WHEN** a caller calls `applyPreset` with an object missing a required field
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_PRESET"` with the Zod issues in `context` and no parameter is mutated
|
||||
|
||||
#### Scenario: future preset version is rejected
|
||||
|
||||
- **WHEN** a caller calls `applyPreset` with a `schemaVersion` greater than the plugin supports
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.PRESET_VERSION_UNSUPPORTED"`
|
||||
|
||||
**Verified by:** `src/base/preset-system.test.ts::"getPreset round-trips through applyPreset"` AND `src/base/preset-system.test.ts::"applyPreset rejects malformed presets with AUDIO_FX.INVALID_PRESET"` AND `src/base/preset-system.test.ts::"applyPreset rejects newer schemaVersion with AUDIO_FX.PRESET_VERSION_UNSUPPORTED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EQ and Filter plugins
|
||||
|
||||
The `./eq` subpath SHALL export `EQPlugin`, an 8-band parametric equalizer whose `bands` are `EQBand` records (`frequency`, `gain`, `q`, `type`) supporting peaking, low-shelf, high-shelf, low-pass, and high-pass band types, with `setBand(index, freq, gain, q)` and the presets `flat`, `vocal`, `bass`, and `podcast`. The `./filter` subpath SHALL export `FilterPlugin` supporting biquad, IIR, and state-variable filter modes. Both plugins SHALL process via `BlockProcessor` using stable per-channel filter state, SHALL be numerically stable across the audible range at the configured sample rate, and SHALL each optionally expose `createAudioNode()` returning a `BiquadFilterNode`-backed graph in the browser.
|
||||
|
||||
#### Scenario: EQ flat preset is transparent
|
||||
|
||||
- **WHEN** `EQPlugin` is set to the `flat` preset and processes a full-band test signal
|
||||
- **THEN** the output equals the input within filter-precision tolerance
|
||||
|
||||
#### Scenario: EQ band boost raises the targeted frequency
|
||||
|
||||
- **WHEN** `setBand` applies a peaking boost at a known frequency and a sine at that frequency is processed
|
||||
- **THEN** the output RMS at that frequency is increased by approximately the configured gain
|
||||
|
||||
#### Scenario: state-variable filter mode is stable
|
||||
|
||||
- **WHEN** `FilterPlugin` runs in state-variable mode over a long noise signal at maximum resonance below self-oscillation
|
||||
- **THEN** no output sample is non-finite and the signal does not diverge
|
||||
|
||||
**Verified by:** `src/plugins/eq/eq-plugin.test.ts::"flat preset is transparent"` AND `src/plugins/eq/eq-plugin.test.ts::"band boost raises RMS at the targeted frequency"` AND `src/plugins/filter/filter-plugin.test.ts::"state-variable mode stays finite and bounded at high resonance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dynamics plugins — Compressor, Limiter, Gate
|
||||
|
||||
The `./compressor` subpath SHALL export `CompressorPlugin` with `threshold`, `knee`, `ratio`, `attack`, and `release` parameters, a soft-knee transfer curve, an optional sidechain source, and a mid/side mode. The `./limiter` subpath SHALL export `LimiterPlugin`, a brick-wall limiter that guarantees the output peak never exceeds its ceiling. The `./gate` subpath SHALL export `GatePlugin`, a noise gate with threshold, attack, hold, and release. All three SHALL implement `BlockProcessor`, SHALL compute gain reduction from an envelope follower without per-block allocation, and SHALL expose gain-reduction telemetry through their parameter surface.
|
||||
|
||||
#### Scenario: compressor attenuates signal above threshold
|
||||
|
||||
- **WHEN** `CompressorPlugin` with a 4:1 ratio processes a signal sustained above its threshold
|
||||
- **THEN** the steady-state output level above threshold is reduced according to the ratio within tolerance
|
||||
|
||||
#### Scenario: limiter output never exceeds the ceiling
|
||||
|
||||
- **WHEN** `LimiterPlugin` with a ceiling processes a signal whose peaks exceed that ceiling
|
||||
- **THEN** no output sample's absolute value exceeds the ceiling
|
||||
|
||||
#### Scenario: gate silences signal below threshold
|
||||
|
||||
- **WHEN** `GatePlugin` processes a signal that falls below its threshold for longer than the hold time
|
||||
- **THEN** the corresponding output region is attenuated to (or toward) silence per the release envelope
|
||||
|
||||
**Verified by:** `src/plugins/compressor/compressor-plugin.test.ts::"attenuates steady-state signal above threshold per ratio"` AND `src/plugins/limiter/limiter-plugin.test.ts::"output peak never exceeds the configured ceiling"` AND `src/plugins/gate/gate-plugin.test.ts::"attenuates signal that falls below threshold past the hold time"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Time-based plugins — Reverb, Delay, Chorus, Phaser, Flanger
|
||||
|
||||
The `./reverb` subpath SHALL export `ReverbPlugin` with an impulse-response convolver, an algorithmic fallback, `decay`/`preDelay`/`wet`/`dry` parameters, a `loadImpulseResponse(url)` method, and the presets `room`, `hall`, `plate`, `cathedral`. The `./delay` subpath SHALL export `DelayPlugin` with `delayTime`/`feedback`/`wet` parameters, ping-pong mode, and transport-BPM sync. The `./chorus`, `./phaser`, and `./flanger` subpaths SHALL export `ChorusPlugin` (modulated-delay chorus), `PhaserPlugin` (all-pass phaser), and `FlangerPlugin` (comb-filter flanger). All SHALL implement `BlockProcessor` using internally owned delay lines, SHALL bound feedback so the output cannot diverge, and SHALL keep `wet === 0` bit-transparent to the dry path.
|
||||
|
||||
#### Scenario: delay produces a feedback echo at the configured time
|
||||
|
||||
- **WHEN** `DelayPlugin` processes an impulse with a known `delayTime` and non-zero `feedback`
|
||||
- **THEN** the output contains decaying repeats spaced by `delayTime` and the signal does not diverge
|
||||
|
||||
#### Scenario: fully-dry mix is transparent
|
||||
|
||||
- **WHEN** any time-based plugin is set to `wet = 0` and processes a signal
|
||||
- **THEN** the output equals the input sample-for-sample
|
||||
|
||||
#### Scenario: reverb algorithmic fallback runs without an impulse response
|
||||
|
||||
- **WHEN** `ReverbPlugin` processes a block before `loadImpulseResponse` has been called
|
||||
- **THEN** it renders the algorithmic reverb tail without throwing
|
||||
|
||||
**Verified by:** `src/plugins/delay/delay-plugin.test.ts::"produces decaying echoes spaced by delayTime"` AND `src/plugins/reverb/reverb-plugin.test.ts::"wet=0 is bit-transparent to the dry signal"` AND `src/plugins/reverb/reverb-plugin.test.ts::"algorithmic fallback renders without a loaded impulse response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Modulation plugins — Tremolo, AutoFilter
|
||||
|
||||
The `./tremolo` subpath SHALL export `TremoloPlugin`, an amplitude-modulation effect with rate, depth, and waveform parameters. The `./auto-filter` subpath SHALL export `AutoFilterPlugin`, an LFO-swept filter with rate, depth, base-frequency, and resonance parameters. Both SHALL implement `BlockProcessor` with a phase-continuous LFO that advances by exactly `blockSize` samples per block using the `BlockContext` sample offset, so modulation is seamless across block boundaries and unaffected by block size.
|
||||
|
||||
#### Scenario: tremolo modulation is phase-continuous across blocks
|
||||
|
||||
- **WHEN** `TremoloPlugin` processes a constant signal split into two block sizes covering the same span
|
||||
- **THEN** the concatenated outputs are sample-identical regardless of block size
|
||||
|
||||
#### Scenario: zero depth is transparent
|
||||
|
||||
- **WHEN** `TremoloPlugin` or `AutoFilterPlugin` is set to `depth = 0` and processes a signal
|
||||
- **THEN** the output equals the input (tremolo) or is unmodulated by the LFO (auto-filter)
|
||||
|
||||
**Verified by:** `src/plugins/tremolo/tremolo-plugin.test.ts::"LFO is phase-continuous and block-size independent"` AND `src/plugins/auto-filter/auto-filter-plugin.test.ts::"zero depth leaves the signal unmodulated"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Distortion plugin
|
||||
|
||||
The `./distortion` subpath SHALL export `DistortionPlugin` supporting waveshaper, bitcrusher, and overdrive modes selected by parameter. It SHALL implement `BlockProcessor`, SHALL apply its transfer function per sample without allocation, SHALL keep the output bounded for any bounded input, and SHALL provide an output-gain parameter to compensate for level changes introduced by shaping.
|
||||
|
||||
#### Scenario: waveshaper clips a hot signal without producing non-finite samples
|
||||
|
||||
- **WHEN** `DistortionPlugin` in waveshaper mode processes an over-unity signal
|
||||
- **THEN** every output sample is finite and bounded within the transfer function's range
|
||||
|
||||
#### Scenario: bitcrusher reduces effective resolution
|
||||
|
||||
- **WHEN** `DistortionPlugin` in bitcrusher mode processes a ramp with a low bit-depth parameter
|
||||
- **THEN** the output takes only the expected quantised set of values
|
||||
|
||||
**Verified by:** `src/plugins/distortion/distortion-plugin.test.ts::"waveshaper output stays finite and bounded for an over-unity input"` AND `src/plugins/distortion/distortion-plugin.test.ts::"bitcrusher quantises the signal to the configured bit depth"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Pitch shifter plugin
|
||||
|
||||
The `./pitch-shifter` subpath SHALL export `PitchShifterPlugin`, a phase-locked vocoder pitch shifter exposing a `semitones` parameter that shifts pitch without changing duration. Pitch *shifting* lives in this package as a plugin — it is not a core `audio-core` op and is distinct from the pitch *detection* in `@suaveplan/audio-analysis-pitch`. The plugin SHALL implement `BlockProcessor` for universal use, SHALL maintain its FFT/overlap-add state across blocks via `reset()`-clearable buffers, and SHALL expose `getWorkletProcessor()` returning AudioWorklet processor source for browser-native rendering.
|
||||
|
||||
#### Scenario: zero-semitone shift is transparent within vocoder tolerance
|
||||
|
||||
- **WHEN** `PitchShifterPlugin` with `semitones = 0` processes a signal
|
||||
- **THEN** the output preserves the input's pitch and duration within phase-vocoder reconstruction tolerance
|
||||
|
||||
#### Scenario: octave-up shift doubles the fundamental
|
||||
|
||||
- **WHEN** `PitchShifterPlugin` with `semitones = 12` processes a sine of known frequency
|
||||
- **THEN** the dominant output frequency is approximately double the input frequency and the output length equals the input length
|
||||
|
||||
**Verified by:** `src/plugins/pitch-shifter/pitch-shifter-plugin.test.ts::"zero-semitone shift preserves pitch and duration"` AND `src/plugins/pitch-shifter/pitch-shifter-plugin.test.ts::"twelve-semitone shift doubles the fundamental without changing duration"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Synthesizer plugin
|
||||
|
||||
The `./synthesizer` subpath SHALL export `SynthesizerPlugin`, a polyphonic synthesizer with `triggerAttack(note, velocity?)`, `triggerRelease(note)`, and `triggerAttackRelease(note, duration)`, an `oscillator` (sine/square/sawtooth/triangle), an ADSR `envelope`, a `filter`, a `polyphony` voice count, and portamento. It SHALL implement `BlockProcessor`, rendering active voices into the output block from a fixed-size voice pool with deterministic voice stealing when `polyphony` is exceeded. Triggering with a MIDI note outside `0..127` SHALL throw `AudioError` with code `AUDIO_FX.INVALID_NOTE`.
|
||||
|
||||
#### Scenario: triggerAttack produces a tone at the note's frequency
|
||||
|
||||
- **WHEN** a caller calls `triggerAttack` for a known MIDI note and processes blocks
|
||||
- **THEN** the output contains energy at that note's fundamental frequency and the ADSR attack stage is audible
|
||||
|
||||
#### Scenario: voice stealing is deterministic past polyphony
|
||||
|
||||
- **WHEN** more notes than `polyphony` are triggered
|
||||
- **THEN** the oldest voice is reused and the active voice count never exceeds `polyphony`
|
||||
|
||||
#### Scenario: out-of-range note is rejected
|
||||
|
||||
- **WHEN** a caller calls `triggerAttack(128)`
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_NOTE"`
|
||||
|
||||
**Verified by:** `src/plugins/synthesizer/synthesizer-plugin.test.ts::"triggerAttack renders a tone at the note frequency"` AND `src/plugins/synthesizer/synthesizer-plugin.test.ts::"voice stealing keeps active voices within polyphony"` AND `src/plugins/synthesizer/synthesizer-plugin.test.ts::"triggerAttack rejects out-of-range notes with AUDIO_FX.INVALID_NOTE"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Sampler plugin
|
||||
|
||||
The `./sampler` subpath SHALL export `SamplerPlugin` with `loadSample(note, url)`, `triggerAttack(note, velocity?)`, and `triggerRelease(note)`. It SHALL implement `BlockProcessor`, playing loaded samples pitched per the triggered note from a base sample, mixing concurrent voices into the output block. Triggering a note with no loaded sample SHALL throw `AudioError` with code `AUDIO_FX.SAMPLE_NOT_LOADED`. Sample loading SHALL accept an `AbortSignal` and reject with `AUDIO_FX.ABORTED` when the signal is aborted.
|
||||
|
||||
#### Scenario: triggered note plays the loaded sample
|
||||
|
||||
- **WHEN** a caller `loadSample` for a note then `triggerAttack` that note and processes blocks
|
||||
- **THEN** the output contains the loaded sample's signal mixed into the block
|
||||
|
||||
#### Scenario: triggering an unloaded note is rejected
|
||||
|
||||
- **WHEN** a caller calls `triggerAttack` for a note with no loaded sample
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.SAMPLE_NOT_LOADED"`
|
||||
|
||||
**Verified by:** `src/plugins/sampler/sampler-plugin.test.ts::"plays the loaded sample for a triggered note"` AND `src/plugins/sampler/sampler-plugin.test.ts::"triggerAttack rejects an unloaded note with AUDIO_FX.SAMPLE_NOT_LOADED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SpatialAudio and Mixer plugins
|
||||
|
||||
The `./spatial` subpath SHALL export `SpatialAudioPlugin` with `position`, `orientation`, `refDistance`, `maxDistance`, and `rolloffFactor`, applying amplitude panning plus distance attenuation universally and an HRTF panner via `createAudioNode()` in the browser. The `./mixer` subpath SHALL export `MixerPlugin` with `channels` (`MixerChannel[]`), `setChannelGain`, `setChannelPan`, and `setSend(channel, bus, amount)` for channel strips, sends/returns, a master bus, and VU metering. The `./widener` subpath SHALL export `StereoWidenerPlugin` for mid/side width control. All SHALL implement `BlockProcessor`; `MixerPlugin` SHALL sum its channels into the output block without clipping internally.
|
||||
|
||||
#### Scenario: spatial distance attenuation follows the rolloff model
|
||||
|
||||
- **WHEN** `SpatialAudioPlugin` processes a signal at increasing distances within `[refDistance, maxDistance]`
|
||||
- **THEN** the output level decreases monotonically per the configured `rolloffFactor`
|
||||
|
||||
#### Scenario: mixer sums channels with applied gain and pan
|
||||
|
||||
- **WHEN** `MixerPlugin` processes two channels with distinct gains and pans
|
||||
- **THEN** the output block equals the gain- and pan-weighted sum of the input channels
|
||||
|
||||
**Verified by:** `src/plugins/spatial/spatial-plugin.test.ts::"distance attenuation decreases monotonically per the rolloff model"` AND `src/plugins/mixer/mixer-plugin.test.ts::"sums channels with applied gain and pan"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Optional browser AudioNode rendering path
|
||||
|
||||
A plugin MAY implement `createAudioNode(context: BaseAudioContext): AudioNode` to return a Web Audio node graph equivalent to its `BlockProcessor` behaviour, and MAY implement `getWorkletProcessor(): string` returning AudioWorklet processor source. When present, `createAudioNode` SHALL NOT be invoked on the universal processing path and SHALL be the sole entry point that touches `BaseAudioContext`. A plugin SHALL function fully through `BlockProcessor` alone when these members are absent, so `@suaveplan/audio-engine-browser` can map `createAudioNode()` into its graph when available and fall back to the worklet-wrapped `BlockProcessor` otherwise.
|
||||
|
||||
#### Scenario: createAudioNode is never reached on the universal path
|
||||
|
||||
- **WHEN** a plugin that implements `createAudioNode` is exercised purely via `setup` + `process`
|
||||
- **THEN** `createAudioNode` is not invoked and no `BaseAudioContext` reference is constructed
|
||||
|
||||
#### Scenario: worklet processor source is a self-contained string
|
||||
|
||||
- **WHEN** a plugin that implements `getWorkletProcessor` returns its source
|
||||
- **THEN** the returned value is a non-empty string containing a `registerProcessor` call and no import of a Node-only module
|
||||
|
||||
**Verified by:** `src/base/audio-fx-plugin.test.ts::"createAudioNode is not invoked on the BlockProcessor path"` AND `src/plugins/pitch-shifter/pitch-shifter-plugin.test.ts::"getWorkletProcessor returns self-contained processor source"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
Every public options object — `PluginMetadata`, `PluginParameter`, `ParameterCurve`, `EQBand`, each plugin's constructor options, and every plugin preset shape — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its argument against the schema before use. A validation failure SHALL throw `AudioError` with code `AUDIO_FX.INVALID_OPTIONS` carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: invalid plugin options are rejected at the boundary
|
||||
|
||||
- **WHEN** a plugin is constructed with an out-of-range or mistyped option
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_OPTIONS"` with the Zod issues in `context`
|
||||
|
||||
#### Scenario: malformed parameter curve is rejected
|
||||
|
||||
- **WHEN** a caller calls `setParameterCurve` with a curve whose `type` is not one of `linear`/`exponential`/`target`
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_OPTIONS"`
|
||||
|
||||
**Verified by:** `src/base/audio-fx-plugin.test.ts::"plugin constructor rejects invalid options with AUDIO_FX.INVALID_OPTIONS"` AND `src/base/audio-fx-plugin.test.ts::"setParameterCurve rejects a malformed curve"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL declare its codes via `defineCodes("AUDIO_FX", [...])` from `@suaveplan/error`, producing the dotted code strings `AUDIO_FX.NOT_INITIALIZED`, `AUDIO_FX.CHANNEL_MISMATCH`, `AUDIO_FX.UNKNOWN_PARAMETER`, `AUDIO_FX.PARAMETER_NOT_AUTOMATABLE`, `AUDIO_FX.INVALID_PRESET`, `AUDIO_FX.PRESET_VERSION_UNSUPPORTED`, `AUDIO_FX.INVALID_NOTE`, `AUDIO_FX.SAMPLE_NOT_LOADED`, `AUDIO_FX.INVALID_OPTIONS`, `AUDIO_FX.ABORTED`, `AUDIO_FX.CHAIN_IO_INCOMPATIBLE`, `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`, `AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID`, `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID`, `AUDIO_FX.BUS_NOT_FOUND`, `AUDIO_FX.BUS_CHANNEL_MISMATCH`, and `AUDIO_FX.BUS_CYCLE_DETECTED`. The package SHALL export `AudioFxErrorCode`, `AUDIO_FX_ERROR_METADATA`, `registerAudioFxErrorCodes()`, and an `AudioError` class extending `SuaveplanError`. Every error thrown by the package SHALL be an `AudioError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
|
||||
|
||||
#### Scenario: every registered code is thrown by a call site
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `AUDIO_FX_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_FX code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument each plugin's `process` path via `@suaveplan/opentelemetry`: a span `audio_fx.process` tagged with the plugin id, a counter `audio_fx_blocks_total{plugin}`, and a histogram `audio_fx_process_duration_ms{plugin}` for per-plugin latency, plus a counter `audio_fx_preset_applied_total{plugin}`. 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** a plugin is imported and used with no OTel exporter registered
|
||||
- **THEN** no error is thrown and no global state is mutated at import time
|
||||
|
||||
#### Scenario: process records a latency histogram when an exporter is registered
|
||||
|
||||
- **WHEN** a plugin processes a block against a real in-memory `MeterProvider`
|
||||
- **THEN** `audio_fx_blocks_total{plugin}` increments and `audio_fx_process_duration_ms{plugin}` records one observation
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"importing a plugin mutates no global state"` AND `src/base/otel.test.ts::"process records a span and latency histogram when an exporter is registered"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation on async plugin operations
|
||||
|
||||
Asynchronous plugin operations that load external resources — `ReverbPlugin.loadImpulseResponse(url)` and `SamplerPlugin.loadSample(note, url)` — SHALL accept an `AbortSignal` via their options. An already-aborted signal SHALL cause the method to throw `AudioError` with code `AUDIO_FX.ABORTED` before any fetch work; a signal aborted mid-load SHALL terminate the load with the same error and leave the plugin's loaded state unchanged.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits the load
|
||||
|
||||
- **WHEN** `loadImpulseResponse` or `loadSample` is called with an already-aborted signal
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.ABORTED"` before any fetch is issued
|
||||
|
||||
#### Scenario: mid-load abort leaves loaded state unchanged
|
||||
|
||||
- **WHEN** a signal is aborted while a sample load is in flight
|
||||
- **THEN** the load rejects with `AUDIO_FX.ABORTED` and the plugin's previously loaded samples are intact
|
||||
|
||||
**Verified by:** `src/plugins/reverb/reverb-plugin.test.ts::"loadImpulseResponse throws AUDIO_FX.ABORTED for a pre-aborted signal"` AND `src/plugins/sampler/sampler-plugin.test.ts::"mid-load abort leaves previously loaded samples intact"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per plugin
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per plugin (`./eq`, `./reverb`, `./delay`, `./compressor`, `./pitch-shifter`, `./synthesizer`, `./sampler`, `./mixer`, `./spatial`, `./filter`, `./distortion`, `./chorus`, `./tremolo`, `./auto-filter`, `./gate`, `./limiter`, `./widener`, `./phaser`, `./flanger`) plus the `./base` entry, the lazy `./presets/*` packs, and the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false` so importing one plugin does not pull in any other.
|
||||
|
||||
#### Scenario: each plugin is independently importable and tree-shakeable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-plugins-fx/eq`
|
||||
- **THEN** the import resolves to the EQ plugin module without pulling in `./synthesizer` or any other plugin
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every plugin has a subpath export entry"` AND `tests/bundle-size/eq.size.test.ts::"importing ./eq does not bundle other plugins"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Channel topology — PluginIo contract
|
||||
|
||||
Every plugin SHALL declare a readonly `io: PluginIo = { inputs: ChannelSpec[], outputs: ChannelSpec[] }`. Each `ChannelSpec` has `{ count: number | 'preserve' | 'any', layout?: ChannelLayout }` where `ChannelLayout ∈ 'mono' | 'stereo' | 'mid-side' | '5.1' | '7.1' | 'ambisonic-1' | 'arbitrary'`. `'preserve'` declares "same channel count as input"; `'any'` declares "accepts whatever channel count is offered". The package SHALL export a `validateChainIo(predecessorOutputs, nextInputs): { ok: true } | { ok: false; reason: string }` helper used by `PluginChain` at insertion time. A plugin whose first input has `count === 0` is a **source** plugin (e.g. `audio-player`, `synthesizer`, `sampler`); a chain SHALL place exactly one source plugin at index 0 or have no source plugin (in which case the chain's input is its first plugin's input).
|
||||
|
||||
#### Scenario: incompatible IO is rejected at chain insertion
|
||||
|
||||
- **WHEN** a chain whose effective output is 2 channels has a plugin inserted whose `io.inputs[0]` is `{ count: 1 }` and not `'any'`
|
||||
- **THEN** `chain.add()` throws `AudioError` whose `code === "AUDIO_FX.CHAIN_IO_INCOMPATIBLE"` and the error `context` names both the predecessor's output channel count and the rejected plugin's input channel count
|
||||
|
||||
#### Scenario: 'preserve' propagates effective output channel count
|
||||
|
||||
- **WHEN** a chain containing a `{ count: 2 }` source followed by three `{ count: 'preserve' }` plugins is asked for `getEffectiveOutputChannels()`
|
||||
- **THEN** the result is `2`
|
||||
|
||||
**Verified by:** `src/base/plugin-io.test.ts::"validateChainIo rejects mismatched counts with AUDIO_FX.CHAIN_IO_INCOMPATIBLE"` AND `src/chain/plugin-chain.test.ts::"preserve propagates effective channel count along the chain"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Capabilities — explicit feature flags
|
||||
|
||||
Every plugin SHALL declare a readonly `capabilities: PluginCapabilities` with `{ hasAudioNode, hasWorklet, hasSidechain, sampleAccurate, stateful, asyncSetup, preservesChannelCount, latencySamples }` (all `readonly`). `hasAudioNode === true` SHALL imply `createAudioNode` is defined; `hasWorklet === true` SHALL imply `getWorkletProcessor` is defined; `hasSidechain === true` SHALL imply `setSidechain` is defined. `preservesChannelCount` SHALL equal the result of computing IO equality from the plugin's declared `io`. `latencySamples` SHALL be the plugin's initial latency at construction; runtime latency changes flow through `getLatencySamples()` and the `onLatencyChanged` event.
|
||||
|
||||
#### Scenario: capability flag and method presence agree
|
||||
|
||||
- **WHEN** every exported plugin is checked
|
||||
- **THEN** for each cap → method pair (`hasAudioNode` ↔ `createAudioNode`, `hasWorklet` ↔ `getWorkletProcessor`, `hasSidechain` ↔ `setSidechain`), the flag value matches whether the method is `typeof === 'function'`
|
||||
|
||||
**Verified by:** `src/base/capabilities.test.ts::"capability flags match method presence for every plugin"` AND `src/base/capabilities.test.ts::"preservesChannelCount derives from declared io"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin delay compensation (PDC)
|
||||
|
||||
Every plugin SHALL implement `getLatencySamples(): number` returning its current rendering latency in samples (0 for zero-latency plugins). Plugins whose latency can change at runtime (e.g. lookahead limiter with variable lookahead) SHALL emit a `latency-changed` notification via `onLatencyChanged(listener: (samples: number) => void): UnsubscribeFn`; the `AbstractAudioFxPlugin` base class SHALL provide a protected `notifyLatencyChanged(samples: number)` helper. `PluginChain.getEffectiveLatencySamples()` SHALL return the sum of `getLatencySamples()` across all non-bypassed plugins in the chain. The chain SHALL subscribe to each plugin's `onLatencyChanged` and emit its own `latency-changed` chain event whenever the effective total changes. The engine adapter SHALL use the chain-level effective latency to apply per-chain delay compensation across tracks so that mixing tracks of different effective latencies stays phase-aligned.
|
||||
|
||||
#### Scenario: chain effective latency sums non-bypassed plugins
|
||||
|
||||
- **WHEN** a chain contains three plugins reporting `[100, 0, 50]` samples and the middle is `bypass()`d
|
||||
- **THEN** `chain.getEffectiveLatencySamples() === 150`; after `bypass(0, true)` it returns `50`; after un-bypassing all it returns `150`
|
||||
|
||||
#### Scenario: dynamic latency change propagates as chain event
|
||||
|
||||
- **WHEN** a plugin's internal latency changes from 0 to 64 samples and it calls `notifyLatencyChanged(64)`
|
||||
- **THEN** every chain listener of `latency-changed` is invoked once with the new chain-level effective latency
|
||||
|
||||
**Verified by:** `src/chain/pdc.test.ts::"effective latency sums non-bypassed plugins"` AND `src/chain/pdc.test.ts::"dynamic latency change propagates as a chain latency-changed event"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ChainContext and AmbientState propagation
|
||||
|
||||
The package SHALL export `ChainContext`, `AmbientState`, `PluginHandle`, and `BusRegistry` types from `./chain`. `PluginChain` SHALL hand every plugin a `ChainContext` instance at `setup()`; the instance reference SHALL be stable for the chain's lifetime so plugins may capture it. `ChainContext` SHALL expose:
|
||||
|
||||
- `chain: ChainHandle` — read-only view of the chain (id, name, length, configured `channels`, `sampleRate`, `blockSize`),
|
||||
- `position: { index, total }` — the plugin's slot index and total chain length,
|
||||
- `predecessors: ReadonlyArray<PluginHandle>` and `successors: ReadonlyArray<PluginHandle>` — read-only handles to peers (id, metadata, io, capabilities, bypassed, `getParameter`, `getLatencySamples`); peers SHALL NOT be mutable through these handles,
|
||||
- `ambient: AmbientState` — the engine-pushed read-only view of `{ track, transport, listener?, extension }`,
|
||||
- `buses: BusRegistry` — the project-level sidechain bus registry.
|
||||
|
||||
`AmbientState.track` SHALL carry `{ id, gainDb, pan, mute, solo, layout }`; `AmbientState.transport` SHALL carry `{ tempo, timeSignature, transportTimeSec, state, ppqn }`; `AmbientState.listener?` SHALL carry `{ position: Vec3, orientation: Quat, velocity: Vec3 }` when 3D context is available; `AmbientState.extension` is an open `Readonly<Record<string, unknown>>` for domain-specific data (game state, custom track tags, etc.). `PluginChain.updateAmbient(patch)` SHALL merge the patch into ambient state and emit an `ambient-changed` chain event; plugins SHALL receive the change on the next `process()` call (no in-flight block mutation).
|
||||
|
||||
#### Scenario: plugins read predecessor parameters during process
|
||||
|
||||
- **WHEN** a chain `[gain(gain=6dB), tracker]` is processed, where `tracker` reads `ctx.predecessors[0].getParameter('gain')` during `process`
|
||||
- **THEN** `tracker` observes `6` and reflects it in its output
|
||||
|
||||
#### Scenario: ambient updates land on the next block
|
||||
|
||||
- **WHEN** `chain.updateAmbient({ transport: { ...prev, tempo: 140 } })` is called between blocks and the next block is processed
|
||||
- **THEN** every plugin's `process` observes `ctx.ambient.transport.tempo === 140` AND the chain emitted one `ambient-changed` event
|
||||
|
||||
#### Scenario: peer handles are read-only
|
||||
|
||||
- **WHEN** any plugin attempts to mutate a value via a `PluginHandle` returned through `predecessors` or `successors`
|
||||
- **THEN** no mutation method exists on `PluginHandle` and the TypeScript type forbids the call at compile time
|
||||
|
||||
**Verified by:** `src/chain/chain-context.test.ts::"plugins read predecessor parameters during process"` AND `src/chain/chain-context.test.ts::"updateAmbient lands on the next process block and emits ambient-changed"` AND `src/chain/chain-context.test.ts::"PluginHandle exposes no mutation methods"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PluginChain primitive — composition, lifecycle, snapshot, events
|
||||
|
||||
The package SHALL export a `PluginChain` class from `./chain`. Composition methods:
|
||||
|
||||
- `add(plugin, at?: number)` — validates IO via `validateChainIo` against the predecessor's effective output and rejects with `AUDIO_FX.CHAIN_IO_INCOMPATIBLE` on mismatch; rejects with `AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID` if a plugin with the same id is already in the chain; rejects with `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID` if the inserted plugin is a source plugin (per the "Channel topology" requirement) and `at !== 0` or another source already exists at index 0. Emits `plugin-added`.
|
||||
- `remove(idOrIndex)` — disposes and removes; emits `plugin-removed`. `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` on missing target.
|
||||
- `move(from, to)` — reorders; revalidates IO across the whole chain; emits `plugin-moved`.
|
||||
- `replace(idOrIndex, plugin, opts?: { crossfadeMs?: number })` — atomic swap; if `crossfadeMs > 0` the chain SHALL crossfade the previous plugin's output with the new plugin's output across that duration without dropping samples; emits `plugin-replaced`.
|
||||
- `bypass(idOrIndex, bypassed: boolean)` — toggles the chain-level bypass wrapper; emits `bypass-changed`.
|
||||
|
||||
Lifecycle methods:
|
||||
|
||||
- `setup(config, ambient, opts?): Promise<void>` — calls each plugin's `setup` in parallel via `Promise.all`, propagating `opts.signal` to every plugin. Construction of the `ChainContext` for each plugin SHALL pin its `position` index and predecessor / successor handles.
|
||||
- `process(input, output, ctx)` — walks plugins in order, threading per-slot scratch buffers; bypassed slots pass input through verbatim without allocation.
|
||||
- `reset()` — calls every plugin's `reset()` in order; drops past parameter schedules per the "Parameter model" requirement.
|
||||
- `dispose()` — calls every plugin's `dispose()`; unsubscribes all listeners.
|
||||
|
||||
Inspection methods:
|
||||
|
||||
- `plugins: ReadonlyArray<AudioFxPlugin>` — current ordered plugin list.
|
||||
- `findById(id)` — `{ plugin, index } | undefined`.
|
||||
- `getEffectiveLatencySamples()` and `getEffectiveOutputChannels()` — derived from current slot state.
|
||||
- `snapshot(): ChainSnapshot` — returns a Zod-validated plain object capturing: `{ id, name, channels, sampleRate, blockSize, plugins: Array<{ pluginType: string, instanceId: string, bypassed: boolean, preset: PresetSnapshot }> }`.
|
||||
- `restore(s, registry: PluginRegistry): Promise<void>` — rebuilds the chain from a snapshot using a `PluginRegistry` to instantiate plugins by `pluginType`; validates the snapshot against the Zod schema first; rejects with `AUDIO_FX.INVALID_PRESET` on validation failure.
|
||||
|
||||
Event surface via `on(event, listener): UnsubscribeFn` for: `'plugin-added' | 'plugin-removed' | 'plugin-moved' | 'plugin-replaced' | 'parameter-changed' | 'preset-applied' | 'bypass-changed' | 'ambient-changed' | 'latency-changed'`.
|
||||
|
||||
#### Scenario: chain process equals serial plugin process
|
||||
|
||||
- **WHEN** a chain `[a, b, c]` of three non-bypassed plugins processes a block
|
||||
- **THEN** the output is bit-equal to running `a.process` then `b.process` (on a's output) then `c.process` (on b's output) on the same input
|
||||
|
||||
#### Scenario: bypassed slot reuses the input buffer
|
||||
|
||||
- **WHEN** a chain processes a block with the middle plugin bypassed
|
||||
- **THEN** the middle plugin's `process` is NOT called AND the buffer at that slot is the same `Float32Array` instance as the predecessor's output (no allocation)
|
||||
|
||||
#### Scenario: snapshot then restore reproduces the chain
|
||||
|
||||
- **WHEN** a chain with parameters and bypass states is `snapshot()`-ed, a fresh chain is constructed, and `restore(s, registry)` is called
|
||||
- **THEN** the restored chain has the same plugin order, parameters, bypass states, and `getEffectiveLatencySamples()` as the original
|
||||
|
||||
#### Scenario: replace with crossfade does not drop samples
|
||||
|
||||
- **WHEN** `replace(idx, newPlugin, { crossfadeMs: 50 })` is called during steady processing
|
||||
- **THEN** sample count produced before and after replace equals input sample count exactly AND no sample is non-finite during the crossfade window
|
||||
|
||||
**Verified by:** `src/chain/plugin-chain.test.ts::"process equals serial plugin process for a non-bypassed chain"` AND `src/chain/plugin-chain.test.ts::"bypassed slot reuses the predecessor output buffer without allocation"` AND `src/chain/chain-snapshot.test.ts::"snapshot then restore reproduces order, parameters, and effective latency"` AND `src/chain/plugin-chain.test.ts::"replace with crossfade preserves sample count and stays finite"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Project-level sidechain BusRegistry
|
||||
|
||||
The package SHALL define the `BusRegistry` interface in `./chain` with methods `get(name): SidechainBus | undefined`, `list(): ReadonlyArray<string>`, `publish(name, channels): SidechainPublisher`, and `subscribe(name, listener): UnsubscribeFn`. A `SidechainBus` SHALL expose `name`, `channels`, and `read(into: Float32Array[]): void` (block-aligned read into the caller's buffer). A `SidechainPublisher` SHALL expose `write(samples: Float32Array[]): void` and `close(): void`. The package SHALL also export a `DefaultBusRegistry` reference implementation used by `PluginChain` in standalone (non-engine) contexts and by tests. Engine adapters MAY supply their own implementation that integrates with their internal topology ordering. `read` invoked on a bus whose channel count does not match the caller's buffer length SHALL throw `AudioError` code `AUDIO_FX.BUS_CHANNEL_MISMATCH`. A bus graph that would form a cycle (chain A reads bus written by chain B which reads bus written by chain A) SHALL be rejected by the engine with `AUDIO_FX.BUS_CYCLE_DETECTED`; the chain itself does not detect cycles (it only sees its own end of the bus).
|
||||
|
||||
#### Scenario: publish + read round-trips a buffer
|
||||
|
||||
- **WHEN** a publisher writes a known buffer to `'kick'` and a consumer calls `bus.read(scratch)` after the write
|
||||
- **THEN** `scratch` contains the published samples and `bus.channels` matches the publisher's declared channel count
|
||||
|
||||
#### Scenario: bus channel mismatch is rejected at read
|
||||
|
||||
- **WHEN** a 2-channel bus is read into a 1-channel scratch buffer
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.BUS_CHANNEL_MISMATCH"`
|
||||
|
||||
#### Scenario: missing bus returns undefined, not throws
|
||||
|
||||
- **WHEN** `ctx.buses.get('nonexistent')` is called
|
||||
- **THEN** the result is `undefined` and no error is thrown (so consumers can branch on presence)
|
||||
|
||||
**Verified by:** `src/chain/bus-registry.test.ts::"publish and read round-trip a buffer"` AND `src/chain/bus-registry.test.ts::"read rejects channel mismatch with AUDIO_FX.BUS_CHANNEL_MISMATCH"` AND `src/chain/bus-registry.test.ts::"get returns undefined for a missing bus"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Microphone plugin
|
||||
|
||||
The `./microphone` subpath SHALL export `MicrophonePlugin`, a complete mic processing chain modeling the upstream 494-line plugin. It SHALL expose parameter groups for frequency response (low/mid/high-shelf gains, presence band), resonance (peak frequency + Q), distortion (drive + mix), noise generation (white, pink, hum 50/60 Hz, hiss with level), dynamics (compressor threshold/ratio/attack/release + gate threshold/hold/release), character (wow rate/depth, flutter rate/depth, tape saturation amount, vintage warmth), and output gain. It SHALL implement `BlockProcessor`, SHALL be stateful (envelope followers, wow/flutter delay lines, noise generators), SHALL preserve channel count, SHALL declare `capabilities.hasAudioNode === false` and `capabilities.hasWorklet === true` (the worklet processor is the browser fast path), and SHALL register a versioned preset schema with the `vintage`, `radio`, `podcast`, `telephone`, and `clean` presets.
|
||||
|
||||
#### Scenario: gate silences signal below threshold
|
||||
|
||||
- **WHEN** `MicrophonePlugin` with gate enabled and threshold -40 dBFS processes a sustained -60 dBFS signal past the hold time
|
||||
- **THEN** the corresponding output region is attenuated toward silence per the release envelope
|
||||
|
||||
#### Scenario: telephone preset rolls off below 300 Hz and above 3.4 kHz
|
||||
|
||||
- **WHEN** `MicrophonePlugin` with the `telephone` preset processes a full-band signal
|
||||
- **THEN** the output RMS below 300 Hz and above 3.4 kHz is at least 20 dB lower than the unfiltered signal
|
||||
|
||||
**Verified by:** `src/plugins/microphone/microphone.test.ts::"gate silences signal below threshold past hold time"` AND `src/plugins/microphone/microphone.test.ts::"telephone preset rolls off below 300 Hz and above 3.4 kHz"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: GLaDOS character vocoder plugin
|
||||
|
||||
The `./glados` subpath SHALL export `GLaDOSPlugin`, a character vocoder voice transformation. It SHALL implement `BlockProcessor`, SHALL combine a phase-vocoder pitch shift, formant shifting, a ring modulator, a band-pass formant filter, and a configurable bit-crusher, exposing parameters `pitchShiftSemitones`, `formantShift`, `ringModFrequency`, `ringModDepth`, `formantQ`, `crushBitDepth`, and `wet`. It SHALL declare `capabilities.hasWorklet === true` and ship a worklet processor; the universal `BlockProcessor` path SHALL produce identical output to the worklet path within FFT reconstruction tolerance. It SHALL register a versioned preset schema with the `glados-default`, `glados-cake`, `glados-deception`, and `wheatley` presets.
|
||||
|
||||
#### Scenario: zero-effect preset is transparent within vocoder tolerance
|
||||
|
||||
- **WHEN** `GLaDOSPlugin` with `wet=0` processes a voice signal
|
||||
- **THEN** the output equals the input sample-for-sample
|
||||
|
||||
#### Scenario: worklet path matches universal path
|
||||
|
||||
- **WHEN** the same input is processed both through the universal `BlockProcessor` and through the worklet
|
||||
- **THEN** outputs match within phase-vocoder reconstruction tolerance
|
||||
|
||||
**Verified by:** `src/plugins/glados/glados.test.ts::"wet=0 is sample-for-sample transparent"` AND `src/plugins/glados/glados.test.ts::"universal and worklet paths produce equivalent output within tolerance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Track-style AudioPlayer source plugin
|
||||
|
||||
The `./audio-player` subpath SHALL export `AudioPlayerPlugin`, a track-style audio playback **source** plugin (`io.inputs[0].count === 0`). It SHALL expose `load(source: string | ArrayBuffer | AudioBuffer, opts?: { signal?: AbortSignal })`, transport-style controls `play(atSec?)`, `pause()`, `stop()`, `seek(timeSec)`, and the accessors `getCurrentTime(): number`, `getDuration(): number`, `isPlaying(): boolean`, `isLoaded(): boolean`. It SHALL implement `BlockProcessor`, rendering the loaded buffer into the output block from its current read position, respecting `playbackRate` and `loop` parameters. It SHALL emit a `'ended'` event when playback reaches the end and `loop` is false. Calling `play` before `load` SHALL throw `AudioError` with code `AUDIO_FX.SAMPLE_NOT_LOADED`. The plugin is distinct from `@suaveplan/audio-sprite-player`: sprite-player triggers short cues from a sprite map; audio-player is a single-track playback engine.
|
||||
|
||||
#### Scenario: play before load is rejected
|
||||
|
||||
- **WHEN** a caller constructs `AudioPlayerPlugin`, calls `play()` without first loading a source
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.SAMPLE_NOT_LOADED"`
|
||||
|
||||
#### Scenario: loaded source plays out into the chain
|
||||
|
||||
- **WHEN** a caller loads a known buffer, calls `play(0)`, and processes enough blocks to span the buffer's duration
|
||||
- **THEN** the concatenated output equals the loaded buffer within sample-rate-conversion tolerance and `'ended'` fires after the last sample
|
||||
|
||||
**Verified by:** `src/plugins/audio-player/audio-player.test.ts::"play before load throws AUDIO_FX.SAMPLE_NOT_LOADED"` AND `src/plugins/audio-player/audio-player.test.ts::"loaded source plays out into the chain and ended fires"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Game-audio building blocks — spatial-positional, proximity-voice, music-layer-mixer, voice-transform
|
||||
|
||||
The `./spatial-positional`, `./proximity-voice`, `./music-layer-mixer`, and `./voice-transform` subpaths SHALL each export an `AudioFxPlugin` implementing `BlockProcessor`, with `r3f-audio` consuming them as composition primitives and non-R3F games consuming them directly. None of them SHALL depend on `react`, `@react-three/fiber`, or `three`.
|
||||
|
||||
- `SpatialPositionalPlugin` SHALL apply distance attenuation per `rolloffFactor`, amplitude panning in the universal path, and HRTF panning via `createAudioNode()` returning a `PannerNode` in the browser. It SHALL read `ChainContext.ambient.listener` for `position` and `orientation`; if `ambient.listener` is `undefined` it SHALL fall back to the plugin's own `listenerPosition` parameter.
|
||||
- `ProximityVoicePlugin` SHALL attenuate voice signals based on distance from `ambient.listener.position` to the plugin's `voicePosition` parameter, expose a VAD threshold below which output is muted, and optionally blend a spatial pan from the listener orientation. It SHALL preserve channel count.
|
||||
- `MusicLayerMixerPlugin` SHALL cross-fade N music layers (each a loaded `AudioBuffer` registered via `loadLayer(id, buffer)`) based on a per-layer weight derived from `ambient.extension.gameState` (a `Readonly<Record<string, number>>` mapping a game-state axis name to a 0..1 value) and the plugin's `layerWeights` parameter map; transitions SHALL crossfade smoothly over the configured `transitionMs`. It SHALL be a **source plugin** (`io.inputs[0].count === 0`).
|
||||
- `VoiceTransformPlugin` SHALL apply combinable pitch shift, formant shift, ring modulation, distortion, and reverb to voice signals with a preset system covering `child`, `giant`, `robot`, `monster`, and `echo` characters. `GLaDOSPlugin` SHALL be implementable as a specialization or wrapper of `VoiceTransformPlugin` (the spec does not mandate which).
|
||||
|
||||
#### Scenario: spatial-positional honours ambient listener over its own parameter
|
||||
|
||||
- **WHEN** `SpatialPositionalPlugin` is processed with `ChainContext.ambient.listener.position = {x: 10, y: 0, z: 0}` AND its own `listenerPosition` parameter set to `{x: 0, y: 0, z: 0}`
|
||||
- **THEN** the rendered distance attenuation uses the ambient listener position, not the parameter fallback
|
||||
|
||||
#### Scenario: music-layer-mixer crossfades on game-state change
|
||||
|
||||
- **WHEN** two loaded layers `combat` and `exploration` are weighted via `ambient.extension.gameState = { combat: 0, exploration: 1 }`, then updated to `{ combat: 1, exploration: 0 }` with `transitionMs = 500`
|
||||
- **THEN** the output blend transitions monotonically from the exploration layer to the combat layer over 500 ms with no audible click
|
||||
|
||||
**Verified by:** `src/plugins/spatial-positional/spatial-positional.test.ts::"honours ambient listener over the parameter fallback"` AND `src/plugins/music-layer-mixer/music-layer-mixer.test.ts::"crossfades layers monotonically on game-state change"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin registry for session restore
|
||||
|
||||
The `./registry` subpath SHALL export a `PluginRegistry` class with `register(pluginType: string, factory: () => AudioFxPlugin)`, `has(pluginType): boolean`, `create(pluginType): AudioFxPlugin`, and `list(): ReadonlyArray<string>`. The registry SHALL be consumed by `PluginChain.restore(snapshot, registry)` to instantiate plugins from their serialized `pluginType` strings. Every first-party plugin in this package SHALL self-register a default factory under its `metadata.id`; consumers may override or omit registrations to control which plugins are available for session restore. Restoring a snapshot whose `pluginType` is not in the registry SHALL reject with `AudioError` code `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` carrying the missing type in `context`.
|
||||
|
||||
#### Scenario: restore uses the registry to instantiate plugins
|
||||
|
||||
- **WHEN** a snapshot containing `pluginType: "eq"` is restored against a registry that has the EQ factory registered
|
||||
- **THEN** the chain contains an `EQPlugin` instance with the snapshot's parameters applied
|
||||
|
||||
#### Scenario: missing plugin type is rejected
|
||||
|
||||
- **WHEN** a snapshot containing an unregistered `pluginType` is restored
|
||||
- **THEN** it rejects with `AudioError` whose `code === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND"` and `context.pluginType` is the missing type
|
||||
|
||||
**Verified by:** `src/registry/plugin-registry.test.ts::"restore uses the registry to instantiate plugins by pluginType"` AND `src/registry/plugin-registry.test.ts::"restore rejects unregistered pluginType with AUDIO_FX.CHAIN_SLOT_NOT_FOUND"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
173
openspec/archive/2026-05-17-enterprise-audio-plugins-fx/tasks.md
Normal file
173
openspec/archive/2026-05-17-enterprise-audio-plugins-fx/tasks.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Tasks: enterprise-audio-plugins-fx
|
||||
|
||||
**Change ID**: enterprise-audio-plugins-fx
|
||||
**Package**: `@suaveplan/audio-plugins-fx`
|
||||
**Target path**: `packages/audio/audio-plugins-fx`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/schema-registry`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events`
|
||||
|
||||
> No behavior change is permitted to ship without a corresponding spec scenario AND a `**Verified by:**` test. Every task below must reflect work actually done.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] 1.1 Create directory tree per `design.md` §13. All feature subdirs (`base/`, `chain/`, `registry/`, `plugins/<name>/`, `presets/<name>/`, `__benchmarks__/`, `tests/`, `tests/bundle-size/`).
|
||||
- [ ] 1.2 Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, deps `@suaveplan/audio-core workspace:^`, `@suaveplan/audio-transport workspace:^`, `@suaveplan/schema-registry workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/opentelemetry workspace:^`, `@suaveplan/events workspace:^`, `zod catalog:forms`, `tslib catalog:build`.
|
||||
- [ ] 1.3 Write a full `exports` map: `.`, `./base`, `./chain`, `./registry`, and one entry per plugin path: `./eq`, `./filter`, `./compressor`, `./limiter`, `./gate`, `./reverb`, `./delay`, `./chorus`, `./phaser`, `./flanger`, `./tremolo`, `./auto-filter`, `./distortion`, `./pitch-shifter`, `./synthesizer`, `./sampler`, `./mixer`, `./widener`, `./spatial-positional`, `./proximity-voice`, `./music-layer-mixer`, `./voice-transform`, `./microphone`, `./glados`, `./audio-player`. Plus `./presets/*` lazy.
|
||||
- [ ] 1.4 Write `tsconfig.json`, `vitest.config.ts` via `createTestConfig()`, lint/typecheck/test/test:coverage/build/docs:check scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Error codes + Zod schemas
|
||||
|
||||
- [ ] 2.1 Write `src/error-codes.ts` — `defineCodes("AUDIO_FX", [...])` for every code listed in the "Structured error codes" requirement (17 codes total). Export `AudioFxErrorCode`, `AUDIO_FX_ERROR_METADATA`, `registerAudioFxErrorCodes()`, and `AudioError` class.
|
||||
- [ ] 2.2 Write `src/error-codes.test.ts` — every registered code reachable from a real call site (per Requirement "Structured error codes").
|
||||
- [ ] 2.3 Write `src/error-codes.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Base contract (`src/base/`)
|
||||
|
||||
- [ ] 3.1 Write `src/base/plugin-io.ts` — `PluginIo`, `ChannelSpec`, `ChannelLayout`, `validateChainIo()` helper. + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 3.2 Write `src/base/capabilities.ts` — `PluginCapabilities` interface + `deriveCapabilities()` helper that cross-checks method presence. + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 3.3 Write `src/base/parameter-schedule.ts` — curve + transport-time schedule evaluator, sample-accurate; shared queue per parameter id. + `.test.ts`, `.md`.
|
||||
- [ ] 3.4 Write `src/base/preset.ts` — `PresetSnapshot` type, Zod-validated `getPreset`/`applyPreset` reference implementation. + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 3.5 Write `src/base/audio-fx-plugin.ts` — `AudioFxPlugin` interface, `AbstractAudioFxPlugin` reference class (parameter storage, schedule evaluation, latency emission, capabilities defaults). + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 3.6 Write `src/base/index.ts` barrel (explicit named re-exports only).
|
||||
|
||||
---
|
||||
|
||||
## 4. Chain primitive (`src/chain/`)
|
||||
|
||||
- [ ] 4.1 Write `src/chain/ambient-state.ts` — `AmbientState` type, `defaultAmbientState()`, immutable patch-merge reducer. + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 4.2 Write `src/chain/bus-registry.ts` — `BusRegistry` interface, `SidechainBus`, `SidechainPublisher`, `DefaultBusRegistry` reference implementation. + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 4.3 Write `src/chain/chain-context.ts` — `ChainContext` factory; produces stable per-plugin context with `PluginHandle` peer views. + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 4.4 Write `src/chain/pdc.ts` — PDC summing helper + per-plugin `onLatencyChanged` subscription manager. + `.test.ts`, `.md`.
|
||||
- [ ] 4.5 Write `src/chain/bypass-wrapper.ts` — chain-level bypass implementation; passthrough buffer reuse without allocation. + `.test.ts`, `.md`.
|
||||
- [ ] 4.6 Write `src/chain/chain-snapshot.ts` — Zod-validated `ChainSnapshot` shape, `snapshot`/`restore` against a `PluginRegistry`. + `.test.ts`, `.md`.
|
||||
- [ ] 4.7 Write `src/chain/plugin-chain.ts` — `PluginChain` class implementing composition (`add`/`remove`/`move`/`replace` with crossfade/`bypass`), lifecycle (`setup`/`process`/`reset`/`dispose`), inspection (`plugins`/`findById`/`getEffectiveLatencySamples`/`getEffectiveOutputChannels`), event surface. ≤300 lines (split helpers as needed). + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 4.8 Write `src/chain/index.ts` barrel.
|
||||
|
||||
---
|
||||
|
||||
## 5. Registry (`src/registry/`)
|
||||
|
||||
- [ ] 5.1 Write `src/registry/plugin-registry.ts` — `PluginRegistry` class; `register`/`has`/`create`/`list`. + `.types.ts`, `.test.ts`, `.md`.
|
||||
- [ ] 5.2 Each first-party plugin's barrel SHALL self-register its default factory at module load — but registration SHALL be opt-in: consumers import `@suaveplan/audio-plugins-fx/registry/auto-register` to get all factories pre-registered, otherwise nothing auto-registers (preserves `sideEffects: false`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Tier-1 DSP plugins (universal)
|
||||
|
||||
- [ ] 6.1 `EQPlugin` — 8-band parametric, shelves, HP/LP, presets `flat`/`vocal`/`bass`/`podcast`. `createAudioNode` returns BiquadFilterNode chain. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 6.2 `FilterPlugin` — biquad / IIR / state-variable modes. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 6.3 `CompressorPlugin` — threshold/knee/ratio/attack/release, soft-knee, sidechain (via `setSidechain`), mid/side mode (reads `ChainContext.ambient.track.layout`). `createAudioNode` returns `DynamicsCompressorNode`. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 6.4 `LimiterPlugin` — brick-wall, lookahead with `onLatencyChanged` for dynamic latency. `BlockProcessor` only. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 6.5 `GatePlugin` — threshold/attack/hold/release. + tests + `.md` + `.schemas.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tier-2 time-based plugins
|
||||
|
||||
- [ ] 7.1 `ReverbPlugin` — IR convolver + algorithmic fallback, `loadImpulseResponse(url, { signal })` with AbortSignal, presets `room`/`hall`/`plate`/`cathedral`. `wet` parameter. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 7.2 `DelayPlugin` — feedback / ping-pong / BPM-sync (reads `ChainContext.ambient.transport.tempo`). + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 7.3 `ChorusPlugin`, `PhaserPlugin`, `FlangerPlugin` — each implementing the time-based requirement. + tests + `.md` + `.schemas.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tier-3 modulation + distortion + pitch + voice
|
||||
|
||||
- [ ] 8.1 `TremoloPlugin`, `AutoFilterPlugin` — phase-continuous LFOs. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 8.2 `DistortionPlugin` — waveshaper / bitcrusher / overdrive modes. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 8.3 `PitchShifterPlugin` — phase-vocoder, worklet processor, `getLatencySamples()` reports FFT-frame latency. + tests + `.md` + `.schemas.ts` + worklet file.
|
||||
|
||||
---
|
||||
|
||||
## 9. Synth + sampler + mixer + widener
|
||||
|
||||
- [ ] 9.1 `SynthesizerPlugin` — oscillator / ADSR / voice pool / portamento / `triggerAttack`/`triggerRelease`/`triggerAttackRelease`. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 9.2 `SamplerPlugin` — `loadSample(note, url, { signal })`, polyphonic playback. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 9.3 `MixerPlugin` — channel strips, sends/returns, master VU, per-channel EQ. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 9.4 `StereoWidenerPlugin` — mid/side width. + tests + `.md` + `.schemas.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Game-audio building blocks
|
||||
|
||||
- [ ] 10.1 `SpatialPositionalPlugin` — distance attenuation, amplitude panning, HRTF via `createAudioNode` (PannerNode). Reads `ChainContext.ambient.listener` with parameter fallback. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 10.2 `ProximityVoicePlugin` — distance-based attenuation, VAD, optional spatial blend. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 10.3 `MusicLayerMixerPlugin` — source plugin (`io.inputs[0].count === 0`), `loadLayer(id, buffer)`, weights from `ambient.extension.gameState`, crossfade. + tests + `.md` + `.schemas.ts`.
|
||||
- [ ] 10.4 `VoiceTransformPlugin` — pitch + formant + ring-mod + distortion + reverb combo, presets `child`/`giant`/`robot`/`monster`/`echo`. + tests + `.md` + `.schemas.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Upstream-port plugins
|
||||
|
||||
- [ ] 11.1 `MicrophonePlugin` — full mic processing chain (frequency, resonance, distortion, noise, dynamics, character, output gain) with worklet for the browser path. Presets `vintage`/`radio`/`podcast`/`telephone`/`clean`. + tests + `.md` + `.schemas.ts` + worklet file.
|
||||
- [ ] 11.2 `GLaDOSPlugin` — character vocoder built on phase-vocoder + formant + ring-mod + band-pass + bit-crusher. Presets `glados-default`/`glados-cake`/`glados-deception`/`wheatley`. + tests + `.md` + `.schemas.ts` + worklet file.
|
||||
- [ ] 11.3 `AudioPlayerPlugin` — track-style source plugin, `load`/`play`/`pause`/`stop`/`seek`/`getCurrentTime`/`getDuration`, `loop` + `playbackRate` parameters, `'ended'` event. + tests + `.md` + `.schemas.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Presets
|
||||
|
||||
- [ ] 12.1 `src/presets/reverb/cathedrals.ts`, `rooms.ts`, `plates.ts` — IR descriptor packs (lazy subpath imports).
|
||||
- [ ] 12.2 `src/presets/synth/leads.ts`, `pads.ts`, `basses.ts`.
|
||||
- [ ] 12.3 `src/presets/glados/defaults.ts`.
|
||||
- [ ] 12.4 Register every preset schema with `@suaveplan/schema-registry`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Benchmarks
|
||||
|
||||
- [ ] 13.1 `src/__benchmarks__/eq.bench.ts` — 1 s stereo through 8-band EQ.
|
||||
- [ ] 13.2 `src/__benchmarks__/reverb.bench.ts` — 1 s through 10 s IR + algorithmic.
|
||||
- [ ] 13.3 `src/__benchmarks__/synth.bench.ts` — render 32-voice polyphonic synth.
|
||||
- [ ] 13.4 `src/__benchmarks__/chain.bench.ts` — 8-plugin serial chain vs raw `BlockProcessor` loop; chain overhead ≤ 5 %.
|
||||
|
||||
---
|
||||
|
||||
## 14. Integration + purity + bundle-size tests
|
||||
|
||||
- [ ] 14.1 `tests/integration.test.ts` — end-to-end chain with ambient + bus: load tracks A and B, A taps `kick` bus, B's compressor side-chains from `kick`, verify ducking; verify chain snapshot round-trip; verify PDC alignment.
|
||||
- [ ] 14.2 `tests/purity.test.ts` — built `dist/` references no browser/Node globals on the processing path; no global state mutation at import time.
|
||||
- [ ] 14.3 `tests/exports.test.ts` — every subpath export resolves; every feature barrel uses explicit named re-exports (no `export *`); the parameterIndex / parameters cross-check across every plugin.
|
||||
- [ ] 14.4 `tests/bundle-size/eq.size.test.ts`, `reverb.size.test.ts`, `synth.size.test.ts`, `chain.size.test.ts` — assert tree-shaken bundles do not include unrelated plugins.
|
||||
- [ ] 14.5 `tests/property.test.ts` (fast-check) — gain plugin: 0 dB ⇒ identity, -∞ dB ⇒ silence; bypass ⇒ bit-identity; chain order invariance for known commutative pairs.
|
||||
|
||||
---
|
||||
|
||||
## 15. Verify
|
||||
|
||||
- [ ] 15.1 `bunx turbo lint typecheck test --filter=@suaveplan/audio-plugins-fx` — green.
|
||||
- [ ] 15.2 `bunx turbo test:coverage --filter=@suaveplan/audio-plugins-fx` — 100/100/100/100.
|
||||
- [ ] 15.3 `bunx turbo build --filter=@suaveplan/audio-plugins-fx` — green.
|
||||
- [ ] 15.4 Banned-pattern scan: `grep -rnE "@deprecated|TODO|FIXME|ts-ignore|ts-expect-error|\\.skip|\\.only|console\\.log|\\bany\\b" packages/audio/audio-plugins-fx/src/ --include="*.ts"` — empty (only false-positive matches on word `any` allowed in comments).
|
||||
- [ ] 15.5 Runner-import scan: `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/audio/audio-plugins-fx/src/ packages/audio/audio-plugins-fx/tests/ --include="*.ts"` — empty.
|
||||
- [ ] 15.6 File-size sweep: every src file ≤ 300 lines, every test file ≤ 500 lines.
|
||||
- [ ] 15.7 Missing `.md` scan empty.
|
||||
- [ ] 15.8 `README.md` at package root with quick-start, chain example, ambient-state example, sidechain example, and links to every co-located `.md`.
|
||||
|
||||
---
|
||||
|
||||
## 16. Spec reconciliation (pre-archive)
|
||||
|
||||
- [ ] 16.1 Walk every `### Requirement` and `#### Scenario` in `specs/audio-plugins-fx/spec.md`; grep `src/` and confirm each named class, function, error code, event, and parameter exists.
|
||||
- [ ] 16.2 Walk every `**Verified by:**` line; confirm the named test file exists AND the test name matches verbatim.
|
||||
- [ ] 16.3 Confirm every entry in `AUDIO_FX_ERROR_METADATA` is reachable from a real call site exercised by a test.
|
||||
|
||||
---
|
||||
|
||||
## 17. Root docs
|
||||
|
||||
- [ ] 17.1 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
- [ ] 17.2 Flip `STATUS.md` row `@suaveplan/audio-plugins-fx` from `–` to `✅`.
|
||||
- [ ] 17.3 Update `SUMMARY.md` paragraph to reflect the shipped surface.
|
||||
- [ ] 17.4 `bunx markdownlint-cli2 --fix` over touched files; second pass without `--fix` must exit 0.
|
||||
|
||||
---
|
||||
|
||||
## 18. Archive
|
||||
|
||||
- [ ] 18.1 Fold `specs/audio-plugins-fx/spec.md` into `openspec/specs/audio-plugins-fx/spec.md`.
|
||||
- [ ] 18.2 Move `openspec/changes/enterprise-audio-plugins-fx/` → `openspec/archive/YYYY-MM-DD-enterprise-audio-plugins-fx/`.
|
||||
Reference in New Issue
Block a user