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-distortion - distortion plugin for the @suaveplan/audio-fx-plugin-* family.
distortion 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
- [distortion](./src/distortion/distortion.md) — `DistortionPlugin` is the universal three-mode distortion: `waveshaper` (smooth `tanh` saturation), `bitcrusher` (unifo…
- [distortion](./src/distortion/distortion.md) — `DistortionPlugin` is the universal three-mode distortion: `waveshaper` (smooth `tanh` saturation), `bitcrusher` (unifo…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `DistortionPlugin`. These run at the public…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `DistortionPlugin`. These run at the public…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-distortion";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./distortion` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-distortion",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "distortion 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,69 @@
# `distortion.types`
## Purpose
TypeScript-only type definitions for `DistortionPlugin`'s construction options, preset payload, and mode enum. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
The `DistortionMode` union is the canonical naming of the three transfer functions; saved presets travel by name (not index) so a future parameter reordering does not break stored data.
## Features
- `DistortionMode` — textual enum (`"waveshaper" | "bitcrusher" | "overdrive"`) used in options and presets.
- `DistortionPluginOptions` — all-optional construction options; the default constructor (`new DistortionPlugin()`) is valid.
- `DistortionPresetData` — preset payload carrying only the non-numeric `mode`; all numeric parameters travel through the universal parameter snapshot.
- `readonly` fields prevent accidental mutation of saved options.
## Usage: Basic Example
```ts
import type {
DistortionMode,
DistortionPluginOptions,
} from "@suaveplan/audio-fx-plugin-distortion";
const opts: DistortionPluginOptions = { initialMode: "bitcrusher" };
const mode: DistortionMode = opts.initialMode ?? "waveshaper";
```
## Usage: Advanced Example
```ts
import type {
DistortionMode,
DistortionPluginOptions,
} from "@suaveplan/audio-fx-plugin-distortion";
const modes: readonly DistortionMode[] = [
"waveshaper",
"bitcrusher",
"overdrive",
];
function nextMode(current: DistortionMode): DistortionMode {
const i = modes.indexOf(current);
return modes[(i + 1) % modes.length]!;
}
const opts: DistortionPluginOptions = {
initialMode: nextMode("waveshaper"),
initialDriveDb: 18,
initialOutputGainDb: -3,
};
```
## API Reference
- `DistortionMode = "waveshaper" | "bitcrusher" | "overdrive"`
- `DistortionPluginOptions`
- `readonly initialMode?: DistortionMode`
- `readonly initialDriveDb?: number`
- `readonly initialBitDepth?: number`
- `readonly initialOutputGainDb?: number`
- `DistortionPresetData`
- `readonly mode: DistortionMode`
## Implementation Notes
The `mode` parameter in the plugin is a numeric enum (0..2) for compatibility with the universal `Parameter` shape, which carries `number` values. The plugin maps that index to/from the textual `DistortionMode` enum at the boundaries (`getPresetData` / `applyPresetData`). This keeps the runtime fast (no string comparisons in the inner loop) while making saved presets human-readable and forward-compatible.
`DistortionPresetData` deliberately includes only `mode`. Every numeric parameter (`drive`, `bits`, `outputGain`) is already part of the universal parameter snapshot that `AbstractAudioFxPlugin` builds, so re-encoding them here would double-store data and risk drift. The base class's preset round-trip honours both the universal parameters and the plugin-specific `presetData` field, applied in that order so plugin-specific decoding always wins for fields it cares about.

View File

@@ -0,0 +1,15 @@
/** Distortion modes selectable via the `mode` parameter. */
export type DistortionMode = "waveshaper" | "bitcrusher" | "overdrive";
/** Construction options for `DistortionPlugin`. */
export interface DistortionPluginOptions {
readonly initialMode?: DistortionMode;
readonly initialDriveDb?: number;
readonly initialBitDepth?: number;
readonly initialOutputGainDb?: number;
}
/** Persisted preset payload (only `mode` is non-numeric). */
export interface DistortionPresetData {
readonly mode: DistortionMode;
}

View File

@@ -0,0 +1,76 @@
# `distortion`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DistortionPlugin` is the universal three-mode distortion: `waveshaper` (smooth `tanh` saturation), `bitcrusher` (uniform quantisation to a chosen bit depth), and `overdrive` (cubic soft-clip). All three are stateless transfer functions applied per sample without allocation, so the plugin runs equally well on the audio thread, in a worklet, in an offline renderer, or under server-side rendering.
Every mode is bounded by construction: `tanh` is bounded in `[-1, 1]` for any input; the bitcrusher pre-clamps to `[-1, 1]` before quantisation; the cubic soft-clip clamps to `[-2/3, 2/3]`. That property is the contract enforced by the spec test "waveshaper output stays finite and bounded for an over-unity input".
## Features
- Four parameters: `mode` (enum 0..2), `drive` (dB, 0..40), `bits` (bitcrusher depth 1..16), `outputGain` (dB, -24..24).
- Drive pre-gain raises the input level *into* the transfer function — useful for waveshaper/overdrive to push the signal further into the non-linear region.
- `outputGain` compensates for level changes caused by shaping so the plugin can drop into an existing chain without raising perceived loudness.
- Mode switching is parameter-driven, so a single instance can crossfade between modes at runtime (snap by default; for click-free morphing, use the parameter automation API on `mode`).
- Preset payload carries the textual `mode` enum so saved presets are robust to future parameter-index changes.
## Usage: Basic Example
```ts
import { DistortionPlugin } from "@suaveplan/audio-fx-plugin-distortion";
const dist = new DistortionPlugin({
initialMode: "waveshaper",
initialDriveDb: 12,
initialOutputGainDb: -3,
});
chain.add(dist);
```
## Usage: Advanced Example
```ts
import { DistortionPlugin } from "@suaveplan/audio-fx-plugin-distortion";
// Lo-fi bitcrusher pad: 6-bit quantisation with mild gain compensation.
const lofi = new DistortionPlugin({
initialMode: "bitcrusher",
initialBitDepth: 6,
initialDriveDb: 0,
initialOutputGainDb: 4,
});
await lofi.setup(
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
ctx
);
// Automate bit depth from 8 down to 3 over four seconds for a tape-eject
// effect. `bits` is automatable so the same plugin handles both static
// and time-varying use.
const startTime = 0;
const endTime = 4;
lofi.setParameterAt("bits", 8, startTime);
lofi.setParameterAt("bits", 3, endTime);
```
## API Reference
- `DistortionPlugin` (class extends `AbstractAudioFxPlugin`)
- `new DistortionPlugin(options?: DistortionPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- Inherited parameter accessors for `mode`, `drive`, `bits`, `outputGain`.
- Types: `DistortionMode = "waveshaper" | "bitcrusher" | "overdrive"`.
- Schemas: `DistortionPluginOptionsSchema`, `DistortionPresetDataSchema`, `DistortionModeSchema`.
## Implementation Notes
The waveshaper transfer is plain `Math.tanh(drive · x)`. We chose `tanh` over `atan` or piecewise-polynomial shapers because it has a clean analytic bound (`|tanh(y)| < 1`), is monotonic everywhere, and is what most reference distortion plugins use, so the perceptual character matches what producers expect. Per-sample `Math.tanh` is fast enough on V8 / JavaScriptCore not to warrant a lookup table for the typical block sizes used in real-time audio.
The bitcrusher pre-clamps to `[-1, 1]` before quantisation because uniform rounding on an unbounded input would produce arbitrarily large output (e.g. `round(5 * 4) / 4 = 5`). The spec demands "the output takes only the expected quantised set of values" — that test would fail on an unclamped input. The number of distinct output values is `2^bits + 1` (`2^(bits-1)` per side plus zero) — for `bits = 3`, that's 9 levels.
The overdrive cubic `x - x³/3` is the standard "Reaper-style" soft-clip. Inside `[-1, 1]` it is monotonic and approximately linear near zero; outside `[-1, 1]` it would diverge, so we clamp to the saturation value `±2/3` (which equals `f(±1)`). This keeps the transfer function continuous at the clamp boundaries — no audible discontinuity even on huge transients.
`reset()` and `setupState()` are no-ops by design: distortion is a memory-less transfer function, so there is no internal buffer that would need clearing. Tests still exercise `reset()` to lock in the contract that it does not throw, which protects against accidentally adding state in a future revision without updating the docs.

View File

@@ -0,0 +1,253 @@
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 { DistortionPlugin } from "./distortion.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: "distortion-test",
name: "distortion-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("DistortionPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const d = new DistortionPlugin();
expect(d.metadata.id).toBe("distortion");
expect(d.metadata.category).toBe("distortion");
expect(d.parameters.map((p) => p.id)).toEqual([
"mode",
"drive",
"bits",
"outputGain",
]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new DistortionPlugin({ initialDriveDb: 999 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("DistortionPlugin: waveshaper output stays finite and bounded for an over-unity input", () => {
it("|tanh(driven)| <= 1 for all samples even on a hot input", {
timeout: 5_000,
}, async () => {
const d = new DistortionPlugin({
initialMode: "waveshaper",
initialDriveDb: 24,
initialOutputGainDb: 0,
});
await d.setup(newConfig(2048, 1), newCtx(1));
const { input, output } = buffers(2048, 1);
for (let i = 0; i < 2048; i++) {
input[0]![i] = 5 * Math.sin((2 * Math.PI * 500 * i) / SAMPLE_RATE);
}
d.process(input, output, blockCtx());
for (let i = 0; i < 2048; i++) {
const v = output[0]![i]!;
expect(Number.isFinite(v)).toBe(true);
expect(Math.abs(v)).toBeLessThanOrEqual(1);
}
});
});
describe("DistortionPlugin: bitcrusher quantises the signal to the configured bit depth", () => {
it("ramp through bitcrusher takes only the quantised set of values", {
timeout: 5_000,
}, async () => {
const d = new DistortionPlugin({
initialMode: "bitcrusher",
initialDriveDb: 0,
initialBitDepth: 3,
initialOutputGainDb: 0,
});
await d.setup(newConfig(1024, 1), newCtx(1));
const { input, output } = buffers(1024, 1);
// Ramp from -1 to nearly 1.
for (let i = 0; i < 1024; i++) {
input[0]![i] = (i / 1023) * 2 - 1;
}
d.process(input, output, blockCtx());
const unique = new Set<number>();
for (let i = 0; i < 1024; i++) {
unique.add(Math.round(output[0]![i]! * 1_000) / 1_000);
}
// bits=3 → 2^2=4 levels per side → 9 distinct quantised values
// (−1, −0.75, −0.5, −0.25, 0, 0.25, 0.5, 0.75, 1).
expect(unique.size).toBeLessThanOrEqual(9);
for (const v of unique) {
expect(Math.abs(v)).toBeLessThanOrEqual(1);
}
});
});
describe("DistortionPlugin: bitcrusher clamps hot input before quantisation", () => {
it("driven > 1 and driven < -1 both clamp to ±1", {
timeout: 5_000,
}, async () => {
const d = new DistortionPlugin({
initialMode: "bitcrusher",
initialDriveDb: 20,
initialBitDepth: 4,
initialOutputGainDb: 0,
});
await d.setup(newConfig(512, 1), newCtx(1));
const { input, output } = buffers(512, 1);
// Hot ramp from -5 to +5 so |driven| > 1 in both directions.
for (let i = 0; i < 512; i++) {
input[0]![i] = (i / 511) * 10 - 5;
}
d.process(input, output, blockCtx());
for (let i = 0; i < 512; i++) {
expect(Math.abs(output[0]![i]!)).toBeLessThanOrEqual(1 + 1e-6);
}
// First sample is driven negative => output should saturate to -1.
expect(output[0]![0]).toBe(-1);
// Last sample is driven positive => output should saturate to +1.
expect(output[0]![511]).toBe(1);
});
});
describe("DistortionPlugin: overdrive output is bounded", () => {
it("cubic soft-clip never exceeds |2/3| for in-range input", {
timeout: 5_000,
}, async () => {
const d = new DistortionPlugin({
initialMode: "overdrive",
initialDriveDb: 20,
initialOutputGainDb: 0,
});
await d.setup(newConfig(1024, 1), newCtx(1));
const { input, output } = buffers(1024, 1);
for (let i = 0; i < 1024; i++) {
input[0]![i] = 3 * Math.sin((2 * Math.PI * 400 * i) / SAMPLE_RATE);
}
d.process(input, output, blockCtx());
for (let i = 0; i < 1024; i++) {
const v = output[0]![i]!;
expect(Number.isFinite(v)).toBe(true);
expect(Math.abs(v)).toBeLessThanOrEqual(2 / 3 + 1e-6);
}
});
});
describe("DistortionPlugin: output-gain compensates for shaping", () => {
it("outputGain scales the shaped output linearly", {
timeout: 5_000,
}, async () => {
const a = new DistortionPlugin({
initialMode: "waveshaper",
initialDriveDb: 0,
initialOutputGainDb: 0,
});
const b = new DistortionPlugin({
initialMode: "waveshaper",
initialDriveDb: 0,
initialOutputGainDb: 6,
});
await a.setup(newConfig(256, 1), newCtx(1));
await b.setup(newConfig(256, 1), newCtx(1));
const inA = buffers(256, 1);
const inB = buffers(256, 1);
for (let i = 0; i < 256; i++) {
inA.input[0]![i] =
0.3 * Math.sin((2 * Math.PI * 1000 * i) / SAMPLE_RATE);
inB.input[0]![i] = inA.input[0]![i]!;
}
a.process(inA.input, inA.output, blockCtx());
b.process(inB.input, inB.output, blockCtx());
const ratio = 10 ** (6 / 20);
for (let i = 0; i < 256; i++) {
expect(inB.output[0]![i]).toBeCloseTo(
inA.output[0]![i]! * ratio,
4
);
}
});
});
describe("DistortionPlugin: preset round-trip", () => {
it("getPreset / applyPreset round-trips mode", { timeout: 5_000 }, () => {
const a = new DistortionPlugin({ initialMode: "bitcrusher" });
const snap = a.getPreset();
const b = new DistortionPlugin();
b.applyPreset(snap);
expect(b.getParameter("mode")).toBe(1);
});
it("overdrive preset round-trips", { timeout: 5_000 }, () => {
const a = new DistortionPlugin({ initialMode: "overdrive" });
const snap = a.getPreset();
const b = new DistortionPlugin();
b.applyPreset(snap);
expect(b.getParameter("mode")).toBe(2);
});
});
describe("DistortionPlugin: reset is a no-op", () => {
it("does not throw and state stays stateless", {
timeout: 5_000,
}, async () => {
const d = new DistortionPlugin();
await d.setup(newConfig(64, 1), newCtx(1));
expect(() => d.reset()).not.toThrow();
});
});

View File

@@ -0,0 +1,168 @@
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 {
DistortionMode,
DistortionPluginOptions,
DistortionPresetData,
} from "../distortion.types.js";
import {
DistortionPluginOptionsSchema,
DistortionPresetDataSchema,
} from "../schemas/index.js";
const MODES: readonly DistortionMode[] = [
"waveshaper",
"bitcrusher",
"overdrive",
];
/**
* Three-mode distortion: waveshaper (tanh), bitcrusher (uniform
* quantisation), overdrive (cubic soft-clip). All modes process per
* sample without allocation and clamp the transfer function so the
* output is bounded for any bounded input. `outputGain` lets the user
* compensate for level changes caused by shaping.
*/
export class DistortionPlugin extends AbstractAudioFxPlugin {
public constructor(options: DistortionPluginOptions = {}) {
const parsed = DistortionPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.distortion-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
const initialModeIndex = MODES.indexOf(
opts.initialMode ?? "waveshaper"
);
super({
metadata: {
id: "distortion",
name: "Distortion",
version: "1.0.0",
category: "distortion",
},
schema: DistortionPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [
{
id: "mode",
name: "Mode",
value: initialModeIndex,
min: 0,
max: MODES.length - 1,
default: 0,
unit: "enum",
automatable: false,
},
{
id: "drive",
name: "Drive",
value: opts.initialDriveDb ?? 12,
min: 0,
max: 40,
default: 12,
unit: "db",
automatable: true,
},
{
id: "bits",
name: "Bit Depth",
value: opts.initialBitDepth ?? 8,
min: 1,
max: 16,
default: 8,
unit: "samples",
automatable: true,
},
{
id: "outputGain",
name: "Output Gain",
value: opts.initialOutputGainDb ?? 0,
min: -24,
max: 24,
default: 0,
unit: "db",
automatable: true,
},
],
supportedSchemaVersion: 1,
});
}
protected override setupState(
_config: ProcessorConfig,
_chain: ChainContext
): void {
/* Stateless transfer functions — nothing to set up. */
}
protected override resetState(): void {
/* Nothing to reset. */
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
const modeIndex = Math.round(this.parameterValue("mode"));
const mode = MODES[modeIndex]!;
const driveDb = this.parameterValue("drive");
const bits = Math.round(this.parameterValue("bits"));
const outputGainDb = this.parameterValue("outputGain");
const driveLin = 10 ** (driveDb / 20);
const outputLin = 10 ** (outputGainDb / 20);
const levels = 2 ** (bits - 1);
const blockSize = input[0]!.length;
for (let ch = 0; ch < input.length; ch++) {
const inBuf = input[ch]!;
const outBuf = output[ch]!;
for (let i = 0; i < blockSize; i++) {
const driven = inBuf[i]! * driveLin;
let shaped: number;
if (mode === "waveshaper") {
shaped = Math.tanh(driven);
} else if (mode === "bitcrusher") {
const clamped = driven > 1 ? 1 : driven < -1 ? -1 : driven;
shaped = Math.round(clamped * levels) / levels;
} else {
shaped = softClipCubic(driven);
}
outBuf[i] = shaped * outputLin;
}
}
}
protected override getPresetData(): DistortionPresetData {
const modeIndex = Math.round(this.getParameter("mode"));
return { mode: MODES[modeIndex]! };
}
protected override applyPresetData(data: unknown): void {
const { mode } = data as DistortionPresetData;
const idx = MODES.indexOf(mode);
this.setParameter("mode", idx);
}
}
/**
* Cubic soft-clip used by the `overdrive` mode. Output is exactly
* bounded in `[-1, 1]` for any input.
*/
function softClipCubic(x: number): number {
if (x >= 1) return 2 / 3;
if (x <= -1) return -2 / 3;
return x - (x * x * x) / 3;
}

View File

@@ -0,0 +1 @@
export { DistortionPlugin } from "./distortion.js";

View File

@@ -0,0 +1,11 @@
export { DistortionPlugin } from "./distortion/index.js";
export type {
DistortionMode,
DistortionPluginOptions,
DistortionPresetData,
} from "./distortion.types.js";
export {
DistortionModeSchema,
DistortionPluginOptionsSchema,
DistortionPresetDataSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,5 @@
export {
DistortionModeSchema,
DistortionPluginOptionsSchema,
DistortionPresetDataSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,66 @@
# `distortion/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options and preset payload of `DistortionPlugin`. These run at the public boundary so any malformed object — wrong mode name, out-of-range drive, non-integer bit depth, NaN gain — is rejected with `AUDIO_FX.INVALID_OPTIONS` or `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
- `DistortionModeSchema` — `z.enum(["waveshaper", "bitcrusher", "overdrive"])` — used in both options and preset payloads.
- `DistortionPluginOptionsSchema` — validates all four optional construction fields with their documented ranges (`drive` 0..40 dB, `bits` 1..16 integer, `outputGain` ±24 dB).
- `DistortionPresetDataSchema` — `.strict()` object carrying only `mode`; extra fields are rejected.
- `bits` is `z.number().int()` so float bit-depths (which would round unpredictably) cannot sneak through.
## Usage: Basic Example
```ts
import { DistortionPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-distortion";
const parsed = DistortionPluginOptionsSchema.parse({
initialMode: "waveshaper",
});
```
## Usage: Advanced Example
```ts
import {
DistortionModeSchema,
DistortionPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-distortion";
// Validate a preset blob coming off the wire before applying it.
function safeApplyPreset(plugin: DistortionPlugin, payload: unknown) {
const parsed = DistortionPresetDataSchema.safeParse(payload);
if (!parsed.success) {
log.warn("dropped bad distortion preset", parsed.error.issues);
return;
}
plugin.applyPreset({
version: 1,
parameters: {},
presetData: parsed.data,
});
}
const modes = DistortionModeSchema.options;
console.log(modes); // ["waveshaper", "bitcrusher", "overdrive"]
```
## API Reference
- `DistortionModeSchema: z.ZodEnum<["waveshaper", "bitcrusher", "overdrive"]>`
- `DistortionPluginOptionsSchema: z.ZodObject<{ initialMode?, initialDriveDb?, initialBitDepth?, initialOutputGainDb? }>`
- `DistortionPresetDataSchema: z.ZodObject<{ mode: DistortionMode }, "strict">`
## Implementation Notes
`DistortionModeSchema` is exported as a named schema (not inlined) so the same value is reused by `DistortionPluginOptionsSchema.initialMode` and `DistortionPresetDataSchema.mode`. That guarantees both validation paths agree byte-for-byte on the legal mode set — adding a fourth mode in a future revision is a one-line change in `schemas.ts`.
`DistortionPresetDataSchema` uses `.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, and silent rejection at the boundary is much less harmful than partial application. The combined "mode-must-be-known, payload-must-be-exact" stance keeps the preset surface stable across plugin versions.

View File

@@ -0,0 +1,18 @@
import z from "zod";
export const DistortionModeSchema = z.enum([
"waveshaper",
"bitcrusher",
"overdrive",
]);
export const DistortionPluginOptionsSchema = z.object({
initialMode: DistortionModeSchema.optional(),
initialDriveDb: z.number().finite().min(0).max(40).optional(),
initialBitDepth: z.number().int().min(1).max(16).optional(),
initialOutputGainDb: z.number().finite().min(-24).max(24).optional(),
});
export const DistortionPresetDataSchema = z
.object({ mode: DistortionModeSchema })
.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-distortion",
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 });