Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-flanger/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-flanger/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-flanger - flanger plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
flanger 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
|
||||
|
||||
- [flanger](./src/flanger/flanger.md) — `FlangerPlugin` is the universal comb-filter flanger. A short LFO-modulated delay line (sub-10 ms) is summed with the d…
|
||||
- [flanger](./src/flanger/flanger.md) — `FlangerPlugin` is the universal comb-filter flanger. A short LFO-modulated delay line (sub-10 ms) is summed with the d…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `FlangerPlugin`. These run on the public bou…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `FlangerPlugin`. These run on the public bou…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./flanger` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-flanger/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-flanger/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-flanger",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "flanger 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,50 @@
|
||||
# `flanger.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `FlangerPlugin`'s construction options. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. Consumers can import the type for typed form state or storybook controls without pulling in the plugin's runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- `FlangerPluginOptions` — all fields optional so the default constructor (`new FlangerPlugin()`) is valid.
|
||||
- `readonly` properties — emphasises that the options object is consumed once at construction and never mutated.
|
||||
- Numeric ranges live in the Zod schema (single source of truth) rather than in TypeScript types.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { FlangerPluginOptions } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const opts: FlangerPluginOptions = { initialDepthMs: 2 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FlangerPluginOptions } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const presets: Record<string, FlangerPluginOptions> = {
|
||||
subtle: { initialDepthMs: 0.5, initialFeedback: 0.3, initialWet: 0.3 },
|
||||
classic: { initialDepthMs: 2, initialFeedback: 0.5, initialWet: 0.5 },
|
||||
jet: { initialDepthMs: 3, initialFeedback: 0.9, initialWet: 0.7 },
|
||||
};
|
||||
|
||||
function load(name: keyof typeof presets): FlangerPluginOptions {
|
||||
return presets[name]!;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FlangerPluginOptions`
|
||||
- `readonly initialRateHz?: number`
|
||||
- `readonly initialDepthMs?: number`
|
||||
- `readonly initialFeedback?: number`
|
||||
- `readonly initialWet?: number`
|
||||
- `readonly initialBaseDelayMs?: number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface deliberately mirrors `ChorusPluginOptions` field-for-field except that `depth` is shorter-range (`0..5 ms` versus the chorus's `0..20 ms`) and `baseDelay` is much shorter (`0.1..10 ms` versus `5..30 ms`). This consistency lets a UI render both plugins from a shared component if it wants to, while the schema enforces the perceptually-distinct parameter ranges that define each effect.
|
||||
|
||||
All fields are `readonly` so structural-equality checks on saved options (e.g., undo/redo stacks) do not need defensive cloning. The constructor never mutates the passed object — it copies relevant values into the parameter list once and forgets the input.
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Construction options for `FlangerPlugin`. */
|
||||
export interface FlangerPluginOptions {
|
||||
readonly initialRateHz?: number;
|
||||
readonly initialDepthMs?: number;
|
||||
readonly initialFeedback?: number;
|
||||
readonly initialWet?: number;
|
||||
readonly initialBaseDelayMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# `flanger`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FlangerPlugin` is the universal comb-filter flanger. A short LFO-modulated delay line (sub-10 ms) is summed with the dry signal; the close tap spacing produces evenly-spaced comb peaks and notches in the frequency response. Feedback emphasises the comb peaks, producing the "metallic" or "jet-engine" timbre that defines the flange sound.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin` so it runs identically across browser, server, offline, and worklet renderers. State is a per-channel circular `Float32Array` sized for the plugin's maximum delay; `reset()` zeros all buffers in place without reallocation.
|
||||
|
||||
## Features
|
||||
|
||||
- Five parameters: `rate` (Hz, 0.05..10), `depth` (ms, 0..5), `feedback` (0..0.95), `wet` (0..1), `baseDelay` (ms, 0.1..10).
|
||||
- Sine LFO drives the delay tap around `baseDelay` with peak-to-peak excursion of `2 · depth`.
|
||||
- Linear-interpolated fractional read keeps the modulation smooth at low rates and high depths.
|
||||
- Feedback clamped at the parameter boundary (max 0.95) so the comb feedback loop stays bounded for any bounded input.
|
||||
- `wet === 0` early-out: returns the dry path sample-for-sample with no allocation and no buffer writes — keeps the spec contract "fully-dry mix is transparent" verifiable by static inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FlangerPlugin } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const flanger = new FlangerPlugin({
|
||||
initialRateHz: 0.4,
|
||||
initialDepthMs: 2,
|
||||
initialFeedback: 0.6,
|
||||
initialWet: 0.5,
|
||||
});
|
||||
chain.add(flanger);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FlangerPlugin } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
// "Through-zero" style deep flange: slow rate, very short base delay,
|
||||
// near-max feedback for the classic resonant jet sweep.
|
||||
const jet = new FlangerPlugin({
|
||||
initialRateHz: 0.2,
|
||||
initialDepthMs: 3,
|
||||
initialFeedback: 0.9,
|
||||
initialWet: 0.7,
|
||||
initialBaseDelayMs: 0.5,
|
||||
});
|
||||
await jet.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FlangerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new FlangerPlugin(options?: FlangerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessors for `rate`, `depth`, `feedback`, `wet`, `baseDelay`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The delay line is a circular `Float32Array` per channel sized for `MAX_DELAY_MS = 20 ms` plus a 4-sample safety margin (the fractional read indexes `i0` and `i0 + 1`). Twenty milliseconds is well over `baseDelay.max + 2·depth.max = 10 + 10 = 20`, with the extra margin so the wrap path is never hit on the hottest combination of parameters.
|
||||
|
||||
The LFO is mapped to the delay tap via `delay = base + (sin + 1)/2 · depth`, not `delay = base + sin · depth`. Mapping to the half-positive range `[0, depth]` means the flange always *adds* delay to the base — never *subtracts* — so the line never reads near `writeIndex - 0` (which would wrap aliased noise into the output). It also matches the standard convention used by most reference flanger plugins so the perceptual sweep direction is what producers expect.
|
||||
|
||||
Feedback at the comb-filter resonance is intentionally allowed to climb to 0.95 because the flanger character lives at the resonance edge. The test "max feedback over a long sine never produces non-finite samples" exercises this boundary against a one-second sine at `feedback = 0.95` and verifies every output sample is finite and amplitude-bounded. Allowing `1.0` would create a marginally stable system whose output diverges for any non-zero input; clamping at 0.95 leaves enough headroom that even a noise-floor leak stays bounded over arbitrarily long renders.
|
||||
|
||||
The `wet === 0` early-out short-circuits the entire DSP loop with a `Float32Array.set` copy per channel — both fast and bit-exact. This satisfies the spec's "wet=0 is bit-transparent" contract without relying on coincidence (`0 * read + dry === dry` would only hold for IEEE-clean values, not subnormals).
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { 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 { FlangerPlugin } from "./flanger.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 1,
|
||||
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): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "flanger-test",
|
||||
name: "flanger-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 256,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: defaultAmbientState(),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("FlangerPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const f = new FlangerPlugin();
|
||||
expect(f.metadata.id).toBe("flanger");
|
||||
expect(f.metadata.category).toBe("modulation");
|
||||
expect(f.parameters.map((p) => p.id)).toEqual([
|
||||
"rate",
|
||||
"depth",
|
||||
"feedback",
|
||||
"wet",
|
||||
"baseDelay",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new FlangerPlugin({ initialFeedback: 1.5 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlangerPlugin: wet=0 is bit-transparent to the dry signal", () => {
|
||||
it("output equals input sample-for-sample at wet=0", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const f = new FlangerPlugin({ initialWet: 0 });
|
||||
await f.setup(newConfig(1024, 1), newCtx(1));
|
||||
const { input, output } = buffers(1024, 1);
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
|
||||
}
|
||||
f.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
expect(output[0]![i]).toBe(input[0]![i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlangerPlugin: bounded output for bounded input", () => {
|
||||
it("max feedback over a long sine never produces non-finite samples", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const f = new FlangerPlugin({
|
||||
initialWet: 1,
|
||||
initialFeedback: 0.95,
|
||||
initialDepthMs: 3,
|
||||
initialRateHz: 1,
|
||||
});
|
||||
await f.setup(newConfig(SAMPLE_RATE, 1), newCtx(1));
|
||||
const { input, output } = buffers(SAMPLE_RATE, 1);
|
||||
for (let i = 0; i < SAMPLE_RATE; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 500 * i) / SAMPLE_RATE);
|
||||
}
|
||||
f.process(input, output, blockCtx());
|
||||
for (let i = 0; i < SAMPLE_RATE; i++) {
|
||||
expect(Number.isFinite(output[0]![i]!)).toBe(true);
|
||||
expect(Math.abs(output[0]![i]!)).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlangerPlugin: comb-filter character", () => {
|
||||
it("wet output differs from dry input on a steady sine", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const f = new FlangerPlugin({
|
||||
initialWet: 1,
|
||||
initialFeedback: 0.5,
|
||||
initialDepthMs: 2,
|
||||
initialRateHz: 0.5,
|
||||
initialBaseDelayMs: 2,
|
||||
});
|
||||
await f.setup(newConfig(2048, 1), newCtx(1));
|
||||
const { input, output } = buffers(2048, 1);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
}
|
||||
f.process(input, output, blockCtx());
|
||||
let diff = 0;
|
||||
for (let i = 1024; i < 2048; i++) {
|
||||
diff += Math.abs(output[0]![i]! - input[0]![i]!);
|
||||
}
|
||||
expect(diff).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlangerPlugin: reset clears state", () => {
|
||||
it("after a wet block + reset, empty input produces empty output", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const f = new FlangerPlugin({ initialWet: 1 });
|
||||
await f.setup(newConfig(256, 1), newCtx(1));
|
||||
const { input, output } = buffers(256, 1);
|
||||
for (let i = 0; i < 256; i++) input[0]![i] = 1;
|
||||
f.process(input, output, blockCtx());
|
||||
f.reset();
|
||||
const { input: in2, output: out2 } = buffers(256, 1);
|
||||
f.process(in2, out2, blockCtx());
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(out2[0]![i]).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlangerPlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips rate", { timeout: 5_000 }, () => {
|
||||
const a = new FlangerPlugin({ initialRateHz: 2 });
|
||||
const snap = a.getPreset();
|
||||
const b = new FlangerPlugin();
|
||||
b.applyPreset(snap);
|
||||
expect(b.getParameter("rate")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
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 { FlangerPluginOptions } from "../flanger.types.js";
|
||||
import {
|
||||
FlangerPluginOptionsSchema,
|
||||
FlangerPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
const MAX_DELAY_MS = 20;
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
|
||||
/**
|
||||
* Comb-filter flanger. A very short LFO-modulated delay line is added
|
||||
* back into the dry signal; the close spacing produces evenly-spaced
|
||||
* comb peaks/notches in the frequency response. Feedback emphasises
|
||||
* the peaks for the "metallic" flange sound. Feedback is clamped at
|
||||
* 0.95 so the output cannot diverge; `wet === 0` is bit-transparent to
|
||||
* the dry path (no flange voice is mixed in).
|
||||
*/
|
||||
export class FlangerPlugin extends AbstractAudioFxPlugin {
|
||||
private buffers: Float32Array[] = [];
|
||||
private writeIndex = 0;
|
||||
private bufferSize = 0;
|
||||
private lfoPhase = 0;
|
||||
|
||||
public constructor(options: FlangerPluginOptions = {}) {
|
||||
const parsed = FlangerPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.flanger-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "flanger",
|
||||
name: "Flanger",
|
||||
version: "1.0.0",
|
||||
category: "modulation",
|
||||
},
|
||||
schema: FlangerPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "rate",
|
||||
name: "Rate",
|
||||
value: opts.initialRateHz ?? 0.5,
|
||||
min: 0.05,
|
||||
max: 10,
|
||||
default: 0.5,
|
||||
unit: "hz",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "depth",
|
||||
name: "Depth",
|
||||
value: opts.initialDepthMs ?? 1,
|
||||
min: 0,
|
||||
max: 5,
|
||||
default: 1,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "feedback",
|
||||
name: "Feedback",
|
||||
value: opts.initialFeedback ?? 0.5,
|
||||
min: 0,
|
||||
max: 0.95,
|
||||
default: 0.5,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "wet",
|
||||
name: "Wet",
|
||||
value: opts.initialWet ?? 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0.5,
|
||||
unit: "percent",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "baseDelay",
|
||||
name: "Base Delay",
|
||||
value: opts.initialBaseDelayMs ?? 1.5,
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
default: 1.5,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
const samples =
|
||||
Math.ceil((MAX_DELAY_MS / 1000) * config.sampleRate) + 4;
|
||||
this.bufferSize = samples;
|
||||
this.buffers = [];
|
||||
for (let c = 0; c < config.channels; c++) {
|
||||
this.buffers.push(new Float32Array(samples));
|
||||
}
|
||||
this.writeIndex = 0;
|
||||
this.lfoPhase = 0;
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
for (const b of this.buffers) b.fill(0);
|
||||
this.writeIndex = 0;
|
||||
this.lfoPhase = 0;
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void {
|
||||
const wet = this.parameterValue("wet");
|
||||
const blockSize = input[0]!.length;
|
||||
if (wet === 0) {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rate = this.parameterValue("rate");
|
||||
const depthMs = this.parameterValue("depth");
|
||||
const feedback = this.parameterValue("feedback");
|
||||
const baseDelayMs = this.parameterValue("baseDelay");
|
||||
const sr = ctx.sampleRate;
|
||||
const baseDelaySamples = (baseDelayMs / 1000) * sr;
|
||||
const depthSamples = (depthMs / 1000) * sr;
|
||||
const phaseInc = (TWO_PI * rate) / sr;
|
||||
const channels = input.length;
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const lfo = Math.sin(this.lfoPhase + phaseInc * i);
|
||||
const delaySamples =
|
||||
baseDelaySamples + (lfo + 1) * 0.5 * depthSamples;
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const buf = this.buffers[ch]!;
|
||||
const read = this.readFractional(buf, delaySamples);
|
||||
const dry = input[ch]![i]!;
|
||||
buf[this.writeIndex] = dry + read * feedback;
|
||||
output[ch]![i] = dry * (1 - wet) + read * wet;
|
||||
}
|
||||
this.writeIndex = (this.writeIndex + 1) % this.bufferSize;
|
||||
}
|
||||
this.lfoPhase = (this.lfoPhase + phaseInc * blockSize) % TWO_PI;
|
||||
}
|
||||
|
||||
private readFractional(buf: Float32Array, delaySamples: number): number {
|
||||
const read = this.writeIndex - delaySamples;
|
||||
const wrapped =
|
||||
((read % this.bufferSize) + this.bufferSize) % this.bufferSize;
|
||||
const i0 = Math.floor(wrapped);
|
||||
const i1 = (i0 + 1) % this.bufferSize;
|
||||
const frac = wrapped - i0;
|
||||
return buf[i0]! * (1 - frac) + buf[i1]! * frac;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FlangerPlugin } from "./flanger.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { FlangerPlugin } from "./flanger/index.js";
|
||||
export type { FlangerPluginOptions } from "./flanger.types.js";
|
||||
export {
|
||||
FlangerPluginOptionsSchema,
|
||||
FlangerPresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
FlangerPluginOptionsSchema,
|
||||
FlangerPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
# `flanger/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `FlangerPlugin`. These run on the public boundary between user code and the plugin: any malformed object hitting the constructor or `applyPreset` is rejected with `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` before it can corrupt runtime state.
|
||||
|
||||
Separating schemas from the plugin module lets `index.ts` re-export them so callers can introspect the option surface for forms, RPC validation, or storybook controls without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `FlangerPluginOptionsSchema` — validates all five optional construction fields with their documented ranges (`rate` 0.05..10 Hz, `depth` 0..5 ms, `feedback` 0..0.95, `wet` 0..1, `baseDelay` 0.1..10 ms).
|
||||
- `FlangerPresetDataSchema` — empty strict object: the flanger has no plugin-specific preset payload, so any extra keys are rejected by `.strict()`.
|
||||
- All numeric fields use `z.number().finite()` so `NaN` and `Infinity` cannot sneak through.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FlangerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const parsed = FlangerPluginOptionsSchema.parse({ initialDepthMs: 1.5 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
FlangerPluginOptionsSchema,
|
||||
FlangerPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
// Pull keys + ranges from the schema for a generated form.
|
||||
const fields = FlangerPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) {
|
||||
form.addRow(field);
|
||||
}
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isFlangerPreset(blob: unknown): boolean {
|
||||
return FlangerPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FlangerPluginOptionsSchema: z.ZodObject<{ initialRateHz?, initialDepthMs?, initialFeedback?, initialWet?, initialBaseDelayMs? }>`
|
||||
- `FlangerPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The maximum feedback is `0.95`, matching the chorus, phaser, and delay plugins so all four feedback-bearing effects share one mental model. The flanger lives close to that boundary by design — the resonant comb peaks are the effect's character — but the same hard cap keeps the loop strictly under unity gain so the output cannot diverge.
|
||||
|
||||
`FlangerPresetDataSchema` is `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. The flanger has no plugin-specific preset payload today: every parameter (`rate`, `depth`, `feedback`, `wet`, `baseDelay`) travels through the universal parameter snapshot that `AbstractAudioFxPlugin` builds, and the empty `presetData` field marks that the plugin opts out of plugin-specific state.
|
||||
@@ -0,0 +1,11 @@
|
||||
import z from "zod";
|
||||
|
||||
export const FlangerPluginOptionsSchema = z.object({
|
||||
initialRateHz: z.number().finite().min(0.05).max(10).optional(),
|
||||
initialDepthMs: z.number().finite().min(0).max(5).optional(),
|
||||
initialFeedback: z.number().finite().min(0).max(0.95).optional(),
|
||||
initialWet: z.number().finite().min(0).max(1).optional(),
|
||||
initialBaseDelayMs: z.number().finite().min(0.1).max(10).optional(),
|
||||
});
|
||||
|
||||
export const FlangerPresetDataSchema = z.object({}).strict();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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-flanger",
|
||||
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