Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
# @suaveplan/audio-fx-plugin-chorus - chorus plugin for the @suaveplan/audio-fx-plugin-* family.
chorus 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
- [chorus](./src/chorus/chorus.md) — `ChorusPlugin` is the universal modulated-delay chorus. Each channel reads from its own delay line at an LFO-modulated…
- [chorus](./src/chorus/chorus.md) — `ChorusPlugin` is the universal modulated-delay chorus. Each channel reads from its own delay line at an LFO-modulated…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `ChorusPlugin`. These run on the public boun…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `ChorusPlugin`. These run on the public boun…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-chorus";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./chorus` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-chorus",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "chorus 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"
}
}

View File

@@ -0,0 +1,50 @@
# `chorus.types`
## Purpose
TypeScript-only type definitions for `ChorusPlugin`'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, dependency-injection wiring, or storybook controls without pulling in the plugin's runtime code.
## Features
- `ChorusPluginOptions` — all fields optional so the default constructor (`new ChorusPlugin()`) is valid.
- `readonly` properties — emphasises that the options object is consumed once at construction and never mutated.
- Numeric ranges are encoded in the Zod schema (single source of truth) rather than in TypeScript types.
## Usage: Basic Example
```ts
import type { ChorusPluginOptions } from "@suaveplan/audio-fx-plugin-chorus";
const opts: ChorusPluginOptions = { initialRateHz: 0.5 };
```
## Usage: Advanced Example
```ts
import type { ChorusPluginOptions } from "@suaveplan/audio-fx-plugin-chorus";
const presets: Record<string, ChorusPluginOptions> = {
subtle: { initialDepthMs: 3, initialWet: 0.2, initialRateHz: 0.4 },
lush: { initialDepthMs: 14, initialWet: 0.6, initialRateHz: 1.2 },
seasick: { initialDepthMs: 18, initialWet: 0.8, initialRateHz: 2.5 },
};
function load(name: keyof typeof presets): ChorusPluginOptions {
return presets[name]!;
}
```
## API Reference
- `ChorusPluginOptions`
- `readonly initialRateHz?: number`
- `readonly initialDepthMs?: number`
- `readonly initialFeedback?: number`
- `readonly initialWet?: number`
- `readonly initialBaseDelayMs?: number`
## Implementation Notes
The options interface mirrors the parameter declarations exactly so a future "save these options" round-trip can be implemented by just copying field-for-field from the plugin's parameter snapshot. There is no separate "options vs. parameters" split here — every option corresponds to one parameter, named `initial<X>` to make it clear that the value is consumed once at construction and then handed to the universal parameter system.
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 — but typing the surface as `readonly` lets the type system enforce that intent at every call site.

View File

@@ -0,0 +1,8 @@
/** Construction options for `ChorusPlugin`. */
export interface ChorusPluginOptions {
readonly initialRateHz?: number;
readonly initialDepthMs?: number;
readonly initialFeedback?: number;
readonly initialWet?: number;
readonly initialBaseDelayMs?: number;
}

View File

@@ -0,0 +1,70 @@
# `chorus`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ChorusPlugin` is the universal modulated-delay chorus. Each channel reads from its own delay line at an LFO-modulated tap point, centred on `baseDelay`. The LFO is phase-offset per channel so a stereo signal smears in two different directions, producing the characteristic chorus width without relying on platform-specific spatial APIs. The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser, server, offline, and worklet renderers.
Feedback is clamped at the parameter boundary (`0..0.95`) so the output cannot diverge for bounded input, and `wet === 0` is bit-transparent to the dry signal (early-out copy) — both contracts are exercised in the test suite.
## Features
- Five parameters: `rate` (Hz, 0.05..10), `depth` (ms, 0..20), `feedback` (0..0.95), `wet` (0..1), `baseDelay` (ms, 5..30).
- Per-channel delay buffer sized for the spec's max delay (60 ms) plus a fractional-read safety margin.
- Sine LFO with channel-indexed phase offset (`2π / channels`) so two-channel input gets the classic L/R width.
- Linear-interpolated fractional read so the modulation is smooth at low rates and high depths.
- `wet === 0` early-out: returns the dry path sample-for-sample with no allocation and no buffer writes — saves CPU when the plugin is fully bypassed by automation.
## Usage: Basic Example
```ts
import { ChorusPlugin } from "@suaveplan/audio-fx-plugin-chorus";
const chorus = new ChorusPlugin({
initialRateHz: 0.5,
initialDepthMs: 5,
initialWet: 0.4,
});
chain.add(chorus);
```
## Usage: Advanced Example
```ts
import { ChorusPlugin } from "@suaveplan/audio-fx-plugin-chorus";
// "Lush" chorus: faster rate, deeper modulation, moderate feedback.
// Useful as the front of a pad-synth chain to thicken the sound.
const lush = new ChorusPlugin({
initialRateHz: 1.2,
initialDepthMs: 14,
initialFeedback: 0.35,
initialWet: 0.6,
initialBaseDelayMs: 18,
});
await lush.setup(
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
ctx,
);
// Crossfade to dry on a long note-off via parameter automation.
lush.setParameterAt("wet", 0.6, 0);
lush.setParameterAt("wet", 0, 0.8);
```
## API Reference
- `ChorusPlugin` (class extends `AbstractAudioFxPlugin`)
- `new ChorusPlugin(options?: ChorusPluginOptions)` — 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 = 60 ms` plus a 4-sample safety margin (the fractional read indexes `i0` and `i0 + 1`). Sixty milliseconds is well over `baseDelay.max + depth.max = 30 + 20 = 50`, so a typical real-time use case never wraps inside a single LFO period; the modulus is there for correctness when the buffer is much smaller than the modulation window (e.g., very low sample rates).
The LFO is a plain `Math.sin(phase)` evaluated per sample. Phase advances by `phaseInc = 2π · rate / sr` per sample and the running phase is kept in `this.lfoPhase % 2π` so a long-running session never accumulates floating-point error past about `1e-15` per cycle. Phase-offset per channel is `2π / channels` — for stereo that's `π`, which gives the classic "left goes high while right goes low" L/R smear; for mono it collapses to a single voice with no decorrelation, which is the expected behaviour.
Feedback is clamped at the parameter boundary (`max: 0.95`) rather than at runtime so the bounded-output property is provable by static inspection of the parameter declaration. A higher feedback would push the system close to self-oscillation; the test "max feedback over a long sine never produces non-finite samples" exercises the boundary and verifies the result stays finite and within a comfortable amplitude budget. The `wet === 0` early-out short-circuits the entire DSP loop with a `Float32Array.set` copy per channel — both fast and bit-exact, satisfying the spec's "wet=0 is bit-transparent" contract without relying on coincidence (`0 * lfo + dry === dry` would only hold for IEEE-clean values, not subnormals).

View File

@@ -0,0 +1,211 @@
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 { ChorusPlugin } from "./chorus.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: "chorus-test",
name: "chorus-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("ChorusPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const c = new ChorusPlugin();
expect(c.metadata.id).toBe("chorus");
expect(c.metadata.category).toBe("modulation");
expect(c.parameters.map((p) => p.id)).toEqual([
"rate",
"depth",
"feedback",
"wet",
"baseDelay",
]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new ChorusPlugin({ initialFeedback: 1.5 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("ChorusPlugin: wet=0 is bit-transparent to the dry signal", () => {
it("output equals input sample-for-sample at wet=0", {
timeout: 5_000,
}, async () => {
const c = new ChorusPlugin({ initialWet: 0 });
await c.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);
}
c.process(input, output, blockCtx());
for (let i = 0; i < 1024; i++) {
expect(output[0]![i]).toBe(input[0]![i]!);
}
});
});
describe("ChorusPlugin: bounded output for bounded input", () => {
it("max feedback over a long sine never produces non-finite samples", {
timeout: 5_000,
}, async () => {
const c = new ChorusPlugin({
initialWet: 1,
initialFeedback: 0.95,
initialDepthMs: 10,
initialRateHz: 1,
});
await c.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);
}
c.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("ChorusPlugin: stereo channels decorrelate via LFO phase offset", () => {
it("left and right outputs differ on identical stereo input", {
timeout: 5_000,
}, async () => {
const c = new ChorusPlugin({
initialWet: 1,
initialDepthMs: 8,
initialRateHz: 1,
});
await c.setup(newConfig(2048, 2), newCtx(2));
const { input, output } = buffers(2048, 2);
for (let i = 0; i < 2048; i++) {
const v = 0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
input[0]![i] = v;
input[1]![i] = v;
}
c.process(input, output, blockCtx());
let diff = 0;
for (let i = 256; i < 2048; i++) {
diff += Math.abs(output[0]![i]! - output[1]![i]!);
}
expect(diff).toBeGreaterThan(1);
});
});
describe("ChorusPlugin: produces a chorused voice", () => {
it("wet output differs from dry input on a steady sine", {
timeout: 5_000,
}, async () => {
const c = new ChorusPlugin({
initialWet: 1,
initialFeedback: 0.3,
initialDepthMs: 10,
initialRateHz: 1.5,
});
await c.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);
}
c.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("ChorusPlugin: reset clears state", () => {
it("after a wet block + reset, internal buffers are zero", {
timeout: 5_000,
}, async () => {
const c = new ChorusPlugin({ initialWet: 1 });
await c.setup(newConfig(256, 1), newCtx(1));
const { input, output } = buffers(256, 1);
for (let i = 0; i < 256; i++) {
input[0]![i] = 1;
}
c.process(input, output, blockCtx());
c.reset();
// After reset, processing an all-zero block should produce zero.
const { input: in2, output: out2 } = buffers(256, 1);
c.process(in2, out2, blockCtx());
for (let i = 0; i < 256; i++) {
expect(out2[0]![i]).toBe(0);
}
});
});
describe("ChorusPlugin: preset round-trip", () => {
it("getPreset / applyPreset round-trips rate", { timeout: 5_000 }, () => {
const a = new ChorusPlugin({ initialRateHz: 2 });
const snap = a.getPreset();
const b = new ChorusPlugin();
b.applyPreset(snap);
expect(b.getParameter("rate")).toBe(2);
});
});

View File

@@ -0,0 +1,181 @@
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 { ChorusPluginOptions } from "../chorus.types.js";
import {
ChorusPluginOptionsSchema,
ChorusPresetDataSchema,
} from "../schemas/index.js";
const MAX_DELAY_MS = 60;
const TWO_PI = 2 * Math.PI;
/**
* Modulated-delay chorus. Each channel reads from its own delay line
* at an LFO-modulated tap point centred on `baseDelay`. Stereo
* channels get phase-offset LFOs so the two voices smear in different
* directions, producing the characteristic chorus width. Feedback is
* clamped at 0.95 so the output cannot diverge; `wet === 0` is
* bit-transparent to the dry path (no chorus voice is mixed in).
*/
export class ChorusPlugin extends AbstractAudioFxPlugin {
private buffers: Float32Array[] = [];
private writeIndex = 0;
private bufferSize = 0;
private lfoPhase = 0;
public constructor(options: ChorusPluginOptions = {}) {
const parsed = ChorusPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.chorus-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
super({
metadata: {
id: "chorus",
name: "Chorus",
version: "1.0.0",
category: "modulation",
},
schema: ChorusPresetDataSchema,
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 ?? 5,
min: 0,
max: 20,
default: 5,
unit: "ms",
automatable: true,
},
{
id: "feedback",
name: "Feedback",
value: opts.initialFeedback ?? 0.2,
min: 0,
max: 0.95,
default: 0.2,
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 ?? 10,
min: 5,
max: 30,
default: 10,
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 channelOffset = TWO_PI / Math.max(1, input.length);
const channels = input.length;
for (let i = 0; i < blockSize; i++) {
const phase = this.lfoPhase + phaseInc * i;
for (let ch = 0; ch < channels; ch++) {
const buf = this.buffers[ch]!;
const lfo = Math.sin(phase + channelOffset * ch);
const delaySamples = baseDelaySamples + lfo * depthSamples;
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;
}
}

View File

@@ -0,0 +1 @@
export { ChorusPlugin } from "./chorus.js";

View File

@@ -0,0 +1,6 @@
export { ChorusPlugin } from "./chorus/index.js";
export type { ChorusPluginOptions } from "./chorus.types.js";
export {
ChorusPluginOptionsSchema,
ChorusPresetDataSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,4 @@
export {
ChorusPluginOptionsSchema,
ChorusPresetDataSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,57 @@
# `chorus/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options and preset payload of `ChorusPlugin`. 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, editors, RPC validation) without importing the audio implementation.
## Features
- `ChorusPluginOptionsSchema` — validates all five optional construction fields with their documented ranges (`rate` 0.05..10 Hz, `depth` 0..20 ms, `feedback` 0..0.95, `wet` 0..1, `baseDelay` 5..30 ms).
- `ChorusPresetDataSchema` — empty strict object: the chorus has no plugin-specific preset payload, so any extra keys are rejected by `.strict()`.
- Both schemas use `z.number().finite()` so `NaN` and `Infinity` cannot sneak through.
## Usage: Basic Example
```ts
import { ChorusPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-chorus";
const parsed = ChorusPluginOptionsSchema.parse({ initialWet: 0.4 });
```
## Usage: Advanced Example
```ts
import {
ChorusPluginOptionsSchema,
ChorusPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-chorus";
// Build a form from the schema's shape keys.
const fields = ChorusPluginOptionsSchema.keyof().options;
for (const field of fields) {
form.addRow(field);
}
// Reject foreign preset payloads at the IPC boundary before they
// reach the plugin.
function validateChorusPreset(blob: unknown): boolean {
return ChorusPresetDataSchema.safeParse(blob).success;
}
```
## API Reference
- `ChorusPluginOptionsSchema: z.ZodObject<{ initialRateHz?, initialDepthMs?, initialFeedback?, initialWet?, initialBaseDelayMs? }>`
- `ChorusPresetDataSchema: z.ZodObject<{}, "strict">`
## Implementation Notes
The maximum feedback is `0.95`, not `1.0`. Allowing feedback at unity 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 test "max feedback over a long sine never produces non-finite samples" exercises this boundary.
`ChorusPresetDataSchema` is `.strict()` for the same reason as the other plugins: unknown fields almost always indicate a payload from a sibling plugin or a forwards-incompatible schema. Silent rejection at the boundary is much less harmful than partial application, which can leave the plugin in a half-restored state.

View File

@@ -0,0 +1,12 @@
import z from "zod";
export const ChorusPluginOptionsSchema = z.object({
initialRateHz: z.number().finite().min(0.05).max(10).optional(),
initialDepthMs: z.number().finite().min(0).max(20).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(5).max(30).optional(),
});
/** No plugin-specific preset payload — all parameters are universal. */
export const ChorusPresetDataSchema = z.object({}).strict();

View 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"]
}

View File

@@ -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"
]
}

View 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-chorus",
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",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });