Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-delay/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-delay/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-delay - delay plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
delay plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [delay](./src/delay/delay.md) — `DelayPlugin` is the universal stereo delay effect. Per-channel circular delay lines render decaying echoes; bounded fe…
|
||||
- [delay](./src/delay/delay.md) — `DelayPlugin` is the universal stereo delay effect. Per-channel circular delay lines render decaying echoes; bounded fe…
|
||||
- [schemas](./src/schemas/schemas.md) — The `delay.schemas` module owns the Zod boundary guards for `DelayPlugin` — construction options and the plugin-specifi…
|
||||
- [schemas](./src/schemas/schemas.md) — The `delay.schemas` module owns the Zod boundary guards for `DelayPlugin` — construction options and the plugin-specifi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./delay` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-delay/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-delay/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-delay",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "delay plugin for the @suaveplan/audio-fx-plugin-* family.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "biome check --max-diagnostics 500 src/",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-fx-plugin-base": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Construction options for `DelayPlugin`. */
|
||||
export interface DelayPluginOptions {
|
||||
readonly initialDelayMs?: number;
|
||||
readonly initialFeedback?: number;
|
||||
readonly initialWet?: number;
|
||||
readonly initialDry?: number;
|
||||
readonly initialPingPong?: boolean;
|
||||
readonly initialBpmSync?: boolean;
|
||||
readonly initialSyncBeats?: number;
|
||||
}
|
||||
|
||||
/** Plugin-specific preset payload. */
|
||||
export interface DelayPresetData {
|
||||
readonly pingPong: boolean;
|
||||
readonly bpmSync: boolean;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# `delay`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DelayPlugin` is the universal stereo delay effect. Per-channel circular delay lines render decaying echoes; bounded feedback (clamped at `0.95`) prevents the recursion from diverging regardless of automation; an optional ping-pong mode cross-feeds the two channels for that classic alternating-side bounce; and a transport-BPM sync mode derives the delay length from the live `tempo` on the chain's ambient state so a 1/4-note delay always stays tempo-locked.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically inside the Web Audio engine, the server renderer, and the worklet wrapper. Delay state is per-channel and cleared in place by `reset()`. With `wet === 0` the plugin short-circuits to `dry * input`, satisfying the spec's "fully-dry mix is transparent" guarantee.
|
||||
|
||||
## Features
|
||||
|
||||
- Up-to-2 s per-channel delay line, sized at `setup` and never re-allocated.
|
||||
- Seven parameters: `delayMs`, `feedback` (0–0.95), `wet`, `dry`, `pingPong`, `bpmSync`, `syncBeats`.
|
||||
- Ping-pong mode (stereo only): the L delay reads R's feedback and vice versa for an alternating-side bounce; mono input silently falls back to regular delay.
|
||||
- BPM sync: when `bpmSync >= 0.5`, delay length = `60_000 · syncBeats / tempo` ms, with `tempo` read from `ChainContext.ambient.transport`.
|
||||
- `wet === 0` early-return path: output equals `dry * input` exactly, bit-transparent when `dry === 1`.
|
||||
- Bounded feedback so the IIR recursion cannot blow up.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DelayPlugin } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
const delay = new DelayPlugin({
|
||||
initialDelayMs: 250,
|
||||
initialFeedback: 0.4,
|
||||
initialWet: 0.3,
|
||||
initialDry: 0.7,
|
||||
});
|
||||
chain.add(delay);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DelayPlugin } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
// Tempo-locked 1/4-note ping-pong delay for stereo guitar.
|
||||
const delay = new DelayPlugin({
|
||||
initialBpmSync: true,
|
||||
initialSyncBeats: 1,
|
||||
initialFeedback: 0.6,
|
||||
initialPingPong: true,
|
||||
initialWet: 0.5,
|
||||
initialDry: 0.7,
|
||||
});
|
||||
await delay.setup({ sampleRate: 48_000, channels: 2, blockSize: 512 }, ctx);
|
||||
|
||||
// Sweep the delay length from 1/4 → 1/8 over four bars via automation.
|
||||
delay.setParameterCurve("syncBeats", { type: "linear", value: 0.5, duration: 8 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DelayPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new DelayPlugin(options?: DelayPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessors for `delayMs`, `feedback`, `wet`, `dry`, `pingPong`, `bpmSync`, `syncBeats`.
|
||||
- `DelayPluginOptions` — initial parameter values and mode flags.
|
||||
- `DelayPresetData` — `{ pingPong, bpmSync }`; the continuous parameters round-trip via the universal `parameters` map.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The non-ping-pong path is the standard "comb" structure: `line[w] = input + feedback · line[r]; out = dry · input + wet · line[r]`. The read index `r` is `(w + lineSize − delaySamples) % lineSize`, computed once per sample because `delaySamples` may move with automation. The line size is fixed at the maximum allowed (`2 s · sampleRate`) so changing `delayMs` between blocks costs nothing.
|
||||
|
||||
Ping-pong cross-feeds the feedbacks: `lineL[w] = inL + feedback · lineR[r]` and symmetrically for R. The dry path stays in-channel. This is the classic Eventide-style ping-pong topology; an impulse on L produces a bounce L → R → L → R with each bounce attenuated by `feedback`.
|
||||
|
||||
BPM sync uses `60_000 / tempo · syncBeats` to convert beats to milliseconds. The plugin reads `tempo` from `ChainContext.ambient.transport.tempo` each block, so a tempo automation lane updates the delay length continuously without per-tempo recompute on the plugin side.
|
||||
|
||||
Feedback is clamped to `0.95` by the parameter declaration. Higher values are mathematically unstable for an IIR delay; the clamp keeps the plugin safe under any automation lane the user can write.
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import {
|
||||
applyAmbientPatch,
|
||||
defaultAmbientState,
|
||||
} from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DelayPlugin } from "./delay.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 2,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buffers(
|
||||
size: number,
|
||||
channels = 1
|
||||
): { input: Float32Array[]; output: Float32Array[] } {
|
||||
const input: Float32Array[] = [];
|
||||
const output: Float32Array[] = [];
|
||||
for (let c = 0; c < channels; c++) {
|
||||
input.push(new Float32Array(size));
|
||||
output.push(new Float32Array(size));
|
||||
}
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 1, tempo = 120): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "delay-test",
|
||||
name: "delay-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 256,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: applyAmbientPatch(defaultAmbientState(), {
|
||||
transport: { tempo },
|
||||
}),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("DelayPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const d = new DelayPlugin();
|
||||
expect(d.metadata.id).toBe("delay");
|
||||
expect(d.parameters.map((p) => p.id)).toEqual([
|
||||
"delayMs",
|
||||
"feedback",
|
||||
"wet",
|
||||
"dry",
|
||||
"pingPong",
|
||||
"bpmSync",
|
||||
"syncBeats",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new DelayPlugin({ initialFeedback: 1.5 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DelayPlugin: produces decaying echoes spaced by delayMs", () => {
|
||||
it("impulse + 100ms delay + 0.5 feedback yields decaying repeats", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const d = new DelayPlugin({
|
||||
initialDelayMs: 100,
|
||||
initialFeedback: 0.5,
|
||||
initialWet: 1,
|
||||
initialDry: 0,
|
||||
});
|
||||
await d.setup(newConfig(SAMPLE_RATE, 1), newCtx(1));
|
||||
const { input, output } = buffers(SAMPLE_RATE, 1);
|
||||
input[0]![0] = 1;
|
||||
d.process(input, output, blockCtx());
|
||||
const delaySamples = Math.round((100 / 1000) * SAMPLE_RATE);
|
||||
// Output at sample N (where N = delaySamples) should be ~1.
|
||||
expect(output[0]![delaySamples]!).toBeCloseTo(1, 5);
|
||||
// At 2x delay it should be ~0.5 (feedback once)
|
||||
expect(output[0]![delaySamples * 2]!).toBeCloseTo(0.5, 5);
|
||||
// At 3x delay ~0.25
|
||||
expect(output[0]![delaySamples * 3]!).toBeCloseTo(0.25, 5);
|
||||
// Output stays bounded.
|
||||
for (let i = 0; i < SAMPLE_RATE; i++) {
|
||||
expect(Number.isFinite(output[0]![i]!)).toBe(true);
|
||||
expect(Math.abs(output[0]![i]!)).toBeLessThan(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DelayPlugin: wet=0 is bit-transparent to the dry signal", () => {
|
||||
it("output equals input when wet=0 and dry=1", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const d = new DelayPlugin({ initialWet: 0, initialDry: 1 });
|
||||
await d.setup(newConfig(256, 1), newCtx(1));
|
||||
const { input, output } = buffers(256, 1);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
input[0]![i] = Math.sin(i * 0.1) * 0.5;
|
||||
}
|
||||
d.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(output[0]![i]!).toBe(input[0]![i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DelayPlugin: ping-pong mode cross-feeds channels", () => {
|
||||
it("stereo input runs ping-pong without divergence", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const d = new DelayPlugin({
|
||||
initialDelayMs: 10,
|
||||
initialFeedback: 0.4,
|
||||
initialWet: 0.8,
|
||||
initialDry: 0.2,
|
||||
initialPingPong: true,
|
||||
});
|
||||
await d.setup(newConfig(4096, 2), newCtx(2));
|
||||
const { input, output } = buffers(4096, 2);
|
||||
input[0]![0] = 1; // impulse on left only
|
||||
d.process(input, output, blockCtx());
|
||||
let leftEnergy = 0;
|
||||
let rightEnergy = 0;
|
||||
for (let i = 0; i < 4096; i++) {
|
||||
leftEnergy += Math.abs(output[0]![i]!);
|
||||
rightEnergy += Math.abs(output[1]![i]!);
|
||||
}
|
||||
// Right channel picks up energy from cross-fed delay.
|
||||
expect(rightEnergy).toBeGreaterThan(0);
|
||||
expect(leftEnergy).toBeGreaterThan(0);
|
||||
for (let i = 0; i < 4096; i++) {
|
||||
expect(Number.isFinite(output[0]![i]!)).toBe(true);
|
||||
expect(Number.isFinite(output[1]![i]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("ping-pong flag is ignored on mono input", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const d = new DelayPlugin({
|
||||
initialPingPong: true,
|
||||
initialWet: 0.5,
|
||||
initialDry: 0.5,
|
||||
});
|
||||
await d.setup(newConfig(128, 1), newCtx(1));
|
||||
const { input, output } = buffers(128, 1);
|
||||
input[0]![0] = 1;
|
||||
d.process(input, output, blockCtx());
|
||||
// Just verify no throw + finite output.
|
||||
for (let i = 0; i < 128; i++) {
|
||||
expect(Number.isFinite(output[0]![i]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DelayPlugin: BPM sync derives delay from transport tempo", () => {
|
||||
it("syncBeats=1 at 120 BPM yields 500 ms delay", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const d = new DelayPlugin({
|
||||
initialBpmSync: true,
|
||||
initialSyncBeats: 1,
|
||||
initialFeedback: 0,
|
||||
initialWet: 1,
|
||||
initialDry: 0,
|
||||
});
|
||||
await d.setup(newConfig(SAMPLE_RATE, 1), newCtx(1, 120));
|
||||
const { input, output } = buffers(SAMPLE_RATE, 1);
|
||||
input[0]![0] = 1;
|
||||
d.process(input, output, blockCtx());
|
||||
// 60000 / 120 * 1 = 500 ms → 24000 samples @ 48k.
|
||||
const expectedSamples = Math.round((500 / 1000) * SAMPLE_RATE);
|
||||
expect(output[0]![expectedSamples]!).toBeCloseTo(1, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DelayPlugin: reset clears the delay line", () => {
|
||||
it("after a hot signal + reset, silence-in is silence-out", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const d = new DelayPlugin({
|
||||
initialDelayMs: 50,
|
||||
initialFeedback: 0.7,
|
||||
initialWet: 1,
|
||||
initialDry: 0,
|
||||
});
|
||||
await d.setup(newConfig(512, 1), newCtx(1));
|
||||
const { input, output } = buffers(512, 1);
|
||||
input[0]![0] = 1;
|
||||
d.process(input, output, blockCtx());
|
||||
d.reset();
|
||||
const silence = new Float32Array(512);
|
||||
const silenceOut = [new Float32Array(512)];
|
||||
d.process([silence], silenceOut, blockCtx());
|
||||
for (let i = 0; i < 512; i++) {
|
||||
expect(Math.abs(silenceOut[0]![i]!)).toBeLessThan(1e-9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DelayPlugin: preset round-trip", () => {
|
||||
it("preserves pingPong + bpmSync flags", { timeout: 5_000 }, () => {
|
||||
const d = new DelayPlugin({
|
||||
initialPingPong: true,
|
||||
initialBpmSync: true,
|
||||
});
|
||||
const snap = d.getPreset();
|
||||
const d2 = new DelayPlugin();
|
||||
d2.applyPreset(snap);
|
||||
expect(d2.getParameter("pingPong")).toBe(1);
|
||||
expect(d2.getParameter("bpmSync")).toBe(1);
|
||||
});
|
||||
|
||||
it("round-trips both modes off", { timeout: 5_000 }, () => {
|
||||
const d = new DelayPlugin();
|
||||
const snap = d.getPreset();
|
||||
const d2 = new DelayPlugin({
|
||||
initialPingPong: true,
|
||||
initialBpmSync: true,
|
||||
});
|
||||
d2.applyPreset(snap);
|
||||
expect(d2.getParameter("pingPong")).toBe(0);
|
||||
expect(d2.getParameter("bpmSync")).toBe(0);
|
||||
});
|
||||
});
|
||||
264
packages/media/audio-fx/audio-fx-plugin-delay/src/delay/delay.ts
Normal file
264
packages/media/audio-fx/audio-fx-plugin-delay/src/delay/delay.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type { ChainContext } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import type { DelayPluginOptions, DelayPresetData } from "../delay.types.js";
|
||||
import {
|
||||
DelayPluginOptionsSchema,
|
||||
DelayPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
const MAX_DELAY_MS = 2_000;
|
||||
const MAX_FEEDBACK = 0.95;
|
||||
|
||||
/**
|
||||
* Stereo delay with feedback, optional ping-pong cross-feed, and
|
||||
* transport-BPM sync. Per-channel circular delay lines; `reset()`
|
||||
* clears them in place. Feedback is clamped to `0.95` so the
|
||||
* recursion cannot diverge regardless of automation. With
|
||||
* `wet === 0` the plugin short-circuits to `dry * input` so a
|
||||
* `wet=0, dry=1` configuration is sample-for-sample transparent.
|
||||
*/
|
||||
export class DelayPlugin extends AbstractAudioFxPlugin {
|
||||
private delayLines: Float32Array[] = [];
|
||||
private writeIndices: number[] = [];
|
||||
|
||||
public constructor(options: DelayPluginOptions = {}) {
|
||||
const parsed = DelayPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.delay-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "delay",
|
||||
name: "Delay",
|
||||
version: "1.0.0",
|
||||
category: "delay",
|
||||
},
|
||||
schema: DelayPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "delayMs",
|
||||
name: "Delay",
|
||||
value: opts.initialDelayMs ?? 250,
|
||||
min: 1,
|
||||
max: MAX_DELAY_MS,
|
||||
default: 250,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "feedback",
|
||||
name: "Feedback",
|
||||
value: opts.initialFeedback ?? 0.4,
|
||||
min: 0,
|
||||
max: MAX_FEEDBACK,
|
||||
default: 0.4,
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "wet",
|
||||
name: "Wet",
|
||||
value: opts.initialWet ?? 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0.5,
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "dry",
|
||||
name: "Dry",
|
||||
value: opts.initialDry ?? 0.7,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0.7,
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "pingPong",
|
||||
name: "Ping-Pong",
|
||||
value: opts.initialPingPong ? 1 : 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
unit: "enum",
|
||||
automatable: false,
|
||||
},
|
||||
{
|
||||
id: "bpmSync",
|
||||
name: "BPM Sync",
|
||||
value: opts.initialBpmSync ? 1 : 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
unit: "enum",
|
||||
automatable: false,
|
||||
},
|
||||
{
|
||||
id: "syncBeats",
|
||||
name: "Sync Beats",
|
||||
value: opts.initialSyncBeats ?? 1,
|
||||
min: 0.0625,
|
||||
max: 8,
|
||||
default: 1,
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
const size = Math.max(
|
||||
1,
|
||||
Math.ceil((MAX_DELAY_MS / 1000) * config.sampleRate)
|
||||
);
|
||||
this.delayLines = [];
|
||||
this.writeIndices = [];
|
||||
for (let c = 0; c < config.channels; c++) {
|
||||
this.delayLines.push(new Float32Array(size));
|
||||
this.writeIndices.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
for (let c = 0; c < this.delayLines.length; c++) {
|
||||
this.delayLines[c]!.fill(0);
|
||||
this.writeIndices[c] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const dry = this.parameterValue("dry");
|
||||
const wet = this.parameterValue("wet");
|
||||
const feedback = this.parameterValue("feedback");
|
||||
const sr = this.config!.sampleRate;
|
||||
const blockSize = input[0]!.length;
|
||||
|
||||
if (wet <= 0) {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
const inCh = input[ch]!;
|
||||
const outCh = output[ch]!;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outCh[i] = dry * inCh[i]!;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = this.resolveDelayMs();
|
||||
const lineSize = this.delayLines[0]!.length;
|
||||
const delaySamples = Math.min(
|
||||
lineSize - 1,
|
||||
Math.max(1, Math.round((delayMs / 1000) * sr))
|
||||
);
|
||||
const pingPong =
|
||||
this.parameterValue("pingPong") >= 0.5 && input.length === 2;
|
||||
|
||||
if (pingPong) {
|
||||
this.processPingPong(
|
||||
input,
|
||||
output,
|
||||
blockSize,
|
||||
dry,
|
||||
wet,
|
||||
feedback,
|
||||
delaySamples,
|
||||
lineSize
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
const inCh = input[ch]!;
|
||||
const outCh = output[ch]!;
|
||||
const line = this.delayLines[ch]!;
|
||||
let widx = this.writeIndices[ch]!;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const ridx = (widx + lineSize - delaySamples) % lineSize;
|
||||
const delayed = line[ridx]!;
|
||||
line[widx] = inCh[i]! + delayed * feedback;
|
||||
widx = (widx + 1) % lineSize;
|
||||
outCh[i] = dry * inCh[i]! + wet * delayed;
|
||||
}
|
||||
this.writeIndices[ch] = widx;
|
||||
}
|
||||
}
|
||||
|
||||
private processPingPong(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
blockSize: number,
|
||||
dry: number,
|
||||
wet: number,
|
||||
feedback: number,
|
||||
delaySamples: number,
|
||||
lineSize: number
|
||||
): void {
|
||||
const inL = input[0]!;
|
||||
const inR = input[1]!;
|
||||
const outL = output[0]!;
|
||||
const outR = output[1]!;
|
||||
const lineL = this.delayLines[0]!;
|
||||
const lineR = this.delayLines[1]!;
|
||||
let wL = this.writeIndices[0]!;
|
||||
let wR = this.writeIndices[1]!;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const rL = (wL + lineSize - delaySamples) % lineSize;
|
||||
const rR = (wR + lineSize - delaySamples) % lineSize;
|
||||
const delayedL = lineL[rL]!;
|
||||
const delayedR = lineR[rR]!;
|
||||
// Cross-feed: L's delay receives R's feedback, R's receives L's.
|
||||
lineL[wL] = inL[i]! + delayedR * feedback;
|
||||
lineR[wR] = inR[i]! + delayedL * feedback;
|
||||
wL = (wL + 1) % lineSize;
|
||||
wR = (wR + 1) % lineSize;
|
||||
outL[i] = dry * inL[i]! + wet * delayedL;
|
||||
outR[i] = dry * inR[i]! + wet * delayedR;
|
||||
}
|
||||
this.writeIndices[0] = wL;
|
||||
this.writeIndices[1] = wR;
|
||||
}
|
||||
|
||||
private resolveDelayMs(): number {
|
||||
const bpmSync = this.parameterValue("bpmSync") >= 0.5;
|
||||
if (!bpmSync) {
|
||||
return this.parameterValue("delayMs");
|
||||
}
|
||||
const tempo = this.chainContext!.ambient.transport.tempo;
|
||||
const beats = this.parameterValue("syncBeats");
|
||||
return (60_000 * beats) / tempo;
|
||||
}
|
||||
|
||||
protected override getPresetData(): DelayPresetData {
|
||||
return {
|
||||
pingPong: this.getParameter("pingPong") >= 0.5,
|
||||
bpmSync: this.getParameter("bpmSync") >= 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
protected override applyPresetData(data: unknown): void {
|
||||
const { pingPong, bpmSync } = data as DelayPresetData;
|
||||
this.setParameter("pingPong", pingPong ? 1 : 0);
|
||||
this.setParameter("bpmSync", bpmSync ? 1 : 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DelayPlugin } from "./delay.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export { DelayPlugin } from "./delay/index.js";
|
||||
export type {
|
||||
DelayPluginOptions,
|
||||
DelayPresetData,
|
||||
} from "./delay.types.js";
|
||||
export {
|
||||
DelayPluginOptionsSchema,
|
||||
DelayPresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DelayPluginOptionsSchema,
|
||||
DelayPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `delay.schemas` module owns the Zod boundary guards for `DelayPlugin` — construction options and the plugin-specific preset payload. Every public input runs through these schemas before the plugin touches any state; failures surface as `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`.
|
||||
|
||||
Keeping the schemas in a sibling folder follows the package-wide convention: hosts can import the schemas directly, run their own `safeParse` against UI input, and surface field-level errors before constructing a `DelayPlugin`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DelayPluginOptionsSchema` — every field optional, ranged to match the plugin's parameter declarations: `delayMs ∈ [1, 2000]`, `feedback ∈ [0, 0.95]`, `wet`/`dry ∈ [0, 1]`, `syncBeats ∈ [1/16, 8]`.
|
||||
- `DelayPresetDataSchema` — required `pingPong` and `bpmSync` booleans. Continuous parameters (delayMs, feedback, wet, dry, syncBeats) ride the universal `parameters` map in the `PresetSnapshot` envelope.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DelayPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
const parsed = DelayPluginOptionsSchema.safeParse(formValues);
|
||||
if (!parsed.success) {
|
||||
return showFieldErrors(parsed.error.issues);
|
||||
}
|
||||
const delay = new DelayPlugin(parsed.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DelayPluginOptionsSchema,
|
||||
DelayPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
// Server-side validation of an untrusted preset payload from cloud
|
||||
// sync before applyPreset. The plugin would catch the same issue,
|
||||
// but the host wants a friendlier error before a plugin instance is
|
||||
// constructed.
|
||||
const envelope = parseChainSnapshot(payload);
|
||||
for (const entry of envelope.plugins.filter((p) => p.pluginType === "delay")) {
|
||||
const result = DelayPresetDataSchema.safeParse(entry.preset.data);
|
||||
if (!result.success) {
|
||||
return reject("Delay preset is corrupt.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DelayPluginOptionsSchema` — `z.ZodObject` matching `DelayPluginOptions`. All fields optional and ranged.
|
||||
- `DelayPresetDataSchema` — `z.ZodObject` matching `DelayPresetData`. Both fields required.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Numeric fields use `.finite()` so `NaN` and `±Infinity` are rejected at the boundary; the per-block delay-line writes would otherwise propagate non-finite values into every subsequent block, and `feedback` close to `1` already lives at the edge of numerical stability.
|
||||
|
||||
`feedback` is `.max(0.95)` rather than the maximum the IIR can theoretically sustain (`1.0 − ε`) because at `0.95` the signal still has substantial perceived decay over many seconds; allowing values closer to `1` invites runaway under any automation lane that drifts.
|
||||
|
||||
`delayMs` is `.max(2000)` to match the line-size allocation in `delay.ts`; values beyond that would be silently clamped at the play-side anyway, so rejecting them at the boundary surfaces the limit clearly.
|
||||
|
||||
`syncBeats` ranges from `0.0625` (1/16 note) to `8` (two whole notes) so a tempo-locked delay covers everything from a tight slap-back to a long ambient wash.
|
||||
@@ -0,0 +1,16 @@
|
||||
import z from "zod";
|
||||
|
||||
export const DelayPluginOptionsSchema = z.object({
|
||||
initialDelayMs: z.number().finite().min(1).max(2_000).optional(),
|
||||
initialFeedback: z.number().finite().min(0).max(0.95).optional(),
|
||||
initialWet: z.number().finite().min(0).max(1).optional(),
|
||||
initialDry: z.number().finite().min(0).max(1).optional(),
|
||||
initialPingPong: z.boolean().optional(),
|
||||
initialBpmSync: z.boolean().optional(),
|
||||
initialSyncBeats: z.number().finite().min(0.0625).max(8).optional(),
|
||||
});
|
||||
|
||||
export const DelayPresetDataSchema = z.object({
|
||||
pingPong: z.boolean(),
|
||||
bpmSync: z.boolean(),
|
||||
});
|
||||
12
packages/media/audio-fx/audio-fx-plugin-delay/tsconfig.json
Normal file
12
packages/media/audio-fx/audio-fx-plugin-delay/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
38
packages/media/audio-fx/audio-fx-plugin-delay/vite.config.ts
Normal file
38
packages/media/audio-fx/audio-fx-plugin-delay/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-delay",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: { index: "src/index.ts" },
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user