Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-distortion/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-distortion/README.md
Normal 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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DistortionPlugin } from "./distortion.js";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DistortionModeSchema,
|
||||
DistortionPluginOptionsSchema,
|
||||
DistortionPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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-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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user