Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-widener/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-widener/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-widener - widener plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
widener 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
|
||||
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `StereoWidenerPlugin`. These run on the publ…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `StereoWidenerPlugin`. These run on the publ…
|
||||
- [widener](./src/widener/widener.md) — `StereoWidenerPlugin` is the universal mid/side stereo-width control. It decomposes a stereo input into mid (`(L + R) /…
|
||||
- [widener](./src/widener/widener.md) — `StereoWidenerPlugin` is the universal mid/side stereo-width control. It decomposes a stereo input into mid (`(L + R) /…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-widener";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./widener` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-widener/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-widener/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-widener",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "widener 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,6 @@
|
||||
export {
|
||||
StereoWidenerPluginOptionsSchema,
|
||||
StereoWidenerPresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
export { StereoWidenerPlugin } from "./widener/index.js";
|
||||
export type { StereoWidenerPluginOptions } from "./widener.types.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
StereoWidenerPluginOptionsSchema,
|
||||
StereoWidenerPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
# `widener/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `StereoWidenerPlugin`. 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
|
||||
|
||||
- `StereoWidenerPluginOptionsSchema` — validates `initialWidth ∈ [0, 2]`.
|
||||
- `StereoWidenerPresetDataSchema` — empty strict object; the widener has no plugin-specific preset payload, so any extra keys are rejected.
|
||||
- `z.number().finite()` blocks `NaN` and `Infinity` from sneaking through.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StereoWidenerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-widener";
|
||||
|
||||
const parsed = StereoWidenerPluginOptionsSchema.parse({ initialWidth: 1.2 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
StereoWidenerPluginOptionsSchema,
|
||||
StereoWidenerPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-widener";
|
||||
|
||||
// Driver for an editor form: pull the keys from the schema.
|
||||
const fields = StereoWidenerPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) {
|
||||
form.addRow(field);
|
||||
}
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isWidenerPreset(blob: unknown): boolean {
|
||||
return StereoWidenerPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StereoWidenerPluginOptionsSchema: z.ZodObject<{ initialWidth? }>`
|
||||
- `StereoWidenerPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The maximum width of `2` is documented in `widener.md` — the cap is a perceptual choice rather than a numerical one. The M/S transform stays numerically stable up to any finite width; the cap at `2` reflects the practical ceiling at which a typical pop mix starts to lose mono fold-down compatibility. Raising the cap is a one-line change in this file, but should be made together with a perceptual review.
|
||||
|
||||
`StereoWidenerPresetDataSchema` is `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. The widener has no plugin-specific preset payload today: every parameter (just `width`) 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,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const StereoWidenerPluginOptionsSchema = z.object({
|
||||
initialWidth: z.number().finite().min(0).max(2).optional(),
|
||||
});
|
||||
|
||||
export const StereoWidenerPresetDataSchema = z.object({}).strict();
|
||||
@@ -0,0 +1,47 @@
|
||||
# `widener.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `StereoWidenerPlugin`'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 runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- `StereoWidenerPluginOptions` — one optional field so the default constructor (`new StereoWidenerPlugin()`) is valid.
|
||||
- `readonly` property emphasises that the options object is consumed once at construction.
|
||||
- Numeric range (0..2) lives in the Zod schema rather than in TypeScript types.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { StereoWidenerPluginOptions } from "@suaveplan/audio-fx-plugin-widener";
|
||||
|
||||
const opts: StereoWidenerPluginOptions = { initialWidth: 1.3 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { StereoWidenerPluginOptions } from "@suaveplan/audio-fx-plugin-widener";
|
||||
|
||||
const presets: Record<string, StereoWidenerPluginOptions> = {
|
||||
mono: { initialWidth: 0 },
|
||||
natural: { initialWidth: 1 },
|
||||
wider: { initialWidth: 1.4 },
|
||||
enormous: { initialWidth: 1.8 },
|
||||
};
|
||||
|
||||
function load(name: keyof typeof presets): StereoWidenerPluginOptions {
|
||||
return presets[name]!;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StereoWidenerPluginOptions`
|
||||
- `readonly initialWidth?: number` — initial side-component scale factor.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface deliberately exposes only `initialWidth` and omits any per-channel routing or gain compensation knob. The M/S transform is symmetric and lossless at `width = 1`, so no balance-compensation parameter is needed; gain compensation (if the user wants to keep perceived loudness flat across width sweeps) is the consumer's responsibility, typically via a downstream gain plugin. Keeping the options surface minimal makes the plugin easy to reason about in automation graphs.
|
||||
|
||||
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 the relevant value into the parameter list once and forgets the input.
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Construction options for `StereoWidenerPlugin`. */
|
||||
export interface StereoWidenerPluginOptions {
|
||||
readonly initialWidth?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { StereoWidenerPlugin } from "./widener.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# `widener`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StereoWidenerPlugin` is the universal mid/side stereo-width control. It decomposes a stereo input into mid (`(L + R) / 2`) and side (`(L - R) / 2`) components, scales the side by `width`, and recomposes the output. `width === 1` is exactly bit-transparent — `mid + side = L` and `mid - side = R` collapse to the original samples in IEEE arithmetic. `width === 0` collapses the stereo image to mono (both channels equal `mid`). `width > 1` exaggerates the stereo image, useful for over-narrow recordings.
|
||||
|
||||
The plugin is stateless on the processing path, so it adds no latency, has no `reset()` work, and runs identically across browser, server, offline, and worklet renderers.
|
||||
|
||||
## Features
|
||||
|
||||
- One parameter: `width` (ratio, 0..2).
|
||||
- Pure M/S transform: `outL = mid + side·width`, `outR = mid − side·width`. No filtering, no allocation, no per-channel state.
|
||||
- `width === 1` early-out: copies L/R bit-exact via `Float32Array.set`, avoiding even the M/S decomposition arithmetic when fully transparent.
|
||||
- Mono input passes through unchanged because there is no side component to manipulate.
|
||||
- Surround channels (third channel and beyond) pass through unchanged so the plugin can sit in a multichannel chain without disturbing the rear-channel routing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StereoWidenerPlugin } from "@suaveplan/audio-fx-plugin-widener";
|
||||
|
||||
const widener = new StereoWidenerPlugin({ initialWidth: 1.4 });
|
||||
chain.add(widener);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { StereoWidenerPlugin } from "@suaveplan/audio-fx-plugin-widener";
|
||||
|
||||
// Automate width from mono to wide over a build-up — a classic
|
||||
// "stereo reveal" effect for the first chorus of a track.
|
||||
const reveal = new StereoWidenerPlugin({ initialWidth: 0 });
|
||||
await reveal.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
reveal.setParameterAt("width", 0, 0);
|
||||
reveal.setParameterAt("width", 1.6, 4);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StereoWidenerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new StereoWidenerPlugin(options?: StereoWidenerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessor for `width`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The M/S transform is mathematically lossless: starting from `(L, R)`, computing `mid = (L+R)/2`, `side = (L-R)/2`, then recomposing as `(mid+side, mid−side)` returns `(L, R)` exactly in IEEE-754. We exploit this by early-returning at `width === 1` with a `Float32Array.set` copy. The early-out is logical, not just for performance — without it, the recomposition path would still introduce one bit of rounding error on subnormal inputs, which an audit would catch as a violation of the "width=1 is bit-transparent" contract.
|
||||
|
||||
Mono input (`channels === 1`) and surround channels (`channels > 2`) pass through unchanged because the M/S transform only defines L/R semantics for the first two channels. Surround applications typically run a separate widener on the front pair and leave the rear pair untouched, which is exactly what this implementation does without requiring an explicit channel-routing parameter.
|
||||
|
||||
The maximum width of `2` is conservative — some "extra-wide" plugins go up to 4 or 8. The cap is a phase-coherence guard: at `width = 2`, a mono signal stays mono (no side to amplify), but a typical pop track with `side / mid ≈ 0.3` produces `outL − outR ≈ 1.2 · (L − R)`, already past the point where the mono fold-down starts to lose punch. Going higher would not break the plugin, but it would surprise users; if a future surface needs `width > 2`, raise the cap in both the Zod schema and the parameter declaration in one commit.
|
||||
|
||||
There is no plugin-specific preset payload — `width` is a universal parameter and travels through the parameter snapshot built by `AbstractAudioFxPlugin`. `reset()` is a no-op by design and the test exercises it to lock in that contract.
|
||||
@@ -0,0 +1,199 @@
|
||||
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 { StereoWidenerPlugin } from "./widener.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: "widener-test",
|
||||
name: "widener-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("StereoWidenerPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const w = new StereoWidenerPlugin();
|
||||
expect(w.metadata.id).toBe("widener");
|
||||
expect(w.metadata.category).toBe("spatial");
|
||||
expect(w.parameters.map((p) => p.id)).toEqual(["width"]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new StereoWidenerPlugin({ initialWidth: 5 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StereoWidenerPlugin: width=1 is bit-transparent on stereo", () => {
|
||||
it("output equals input sample-for-sample at width=1", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const w = new StereoWidenerPlugin({ initialWidth: 1 });
|
||||
await w.setup(newConfig(1024, 2), newCtx(2));
|
||||
const { input, output } = buffers(1024, 2);
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
input[1]![i] =
|
||||
0.3 * Math.sin((2 * Math.PI * 660 * i) / SAMPLE_RATE);
|
||||
}
|
||||
w.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
expect(output[0]![i]).toBe(input[0]![i]!);
|
||||
expect(output[1]![i]).toBe(input[1]![i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("StereoWidenerPlugin: width=0 collapses to mono", () => {
|
||||
it("both output channels equal mid = (L + R) / 2", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const w = new StereoWidenerPlugin({ initialWidth: 0 });
|
||||
await w.setup(newConfig(512, 2), newCtx(2));
|
||||
const { input, output } = buffers(512, 2);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
input[0]![i] = 0.4;
|
||||
input[1]![i] = -0.2;
|
||||
}
|
||||
w.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 512; i++) {
|
||||
expect(output[0]![i]).toBeCloseTo(0.1, 6);
|
||||
expect(output[1]![i]).toBeCloseTo(0.1, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("StereoWidenerPlugin: width=2 doubles the side component", () => {
|
||||
it("L' - R' is twice (L - R) when width=2", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const w = new StereoWidenerPlugin({ initialWidth: 2 });
|
||||
await w.setup(newConfig(512, 2), newCtx(2));
|
||||
const { input, output } = buffers(512, 2);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
input[0]![i] = 0.5;
|
||||
input[1]![i] = 0.1;
|
||||
}
|
||||
w.process(input, output, blockCtx());
|
||||
// mid = 0.3, side = 0.2 → outL = 0.3 + 0.4 = 0.7, outR = 0.3 - 0.4 = -0.1.
|
||||
for (let i = 0; i < 512; i++) {
|
||||
expect(output[0]![i]).toBeCloseTo(0.7, 6);
|
||||
expect(output[1]![i]).toBeCloseTo(-0.1, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("StereoWidenerPlugin: mono input passes through unchanged", () => {
|
||||
it("single-channel input is copied to output", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const w = new StereoWidenerPlugin({ initialWidth: 0.5 });
|
||||
await w.setup(newConfig(256, 1), newCtx(1));
|
||||
const { input, output } = buffers(256, 1);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
}
|
||||
w.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(output[0]![i]).toBe(input[0]![i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("StereoWidenerPlugin: surround channels pass through unchanged", () => {
|
||||
it("channels beyond L/R are copied verbatim", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const w = new StereoWidenerPlugin({ initialWidth: 2 });
|
||||
await w.setup(newConfig(128, 4), newCtx(4));
|
||||
const { input, output } = buffers(128, 4);
|
||||
for (let i = 0; i < 128; i++) {
|
||||
input[0]![i] = 0.5;
|
||||
input[1]![i] = 0.1;
|
||||
input[2]![i] = 0.2;
|
||||
input[3]![i] = -0.4;
|
||||
}
|
||||
w.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 128; i++) {
|
||||
expect(output[2]![i]).toBe(input[2]![i]!);
|
||||
expect(output[3]![i]).toBe(input[3]![i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("StereoWidenerPlugin: reset is a no-op", () => {
|
||||
it("does not throw on a stateless plugin", { timeout: 5_000 }, async () => {
|
||||
const w = new StereoWidenerPlugin();
|
||||
await w.setup(newConfig(64, 2), newCtx(2));
|
||||
expect(() => w.reset()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StereoWidenerPlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips width", { timeout: 5_000 }, () => {
|
||||
const a = new StereoWidenerPlugin({ initialWidth: 1.5 });
|
||||
const snap = a.getPreset();
|
||||
const b = new StereoWidenerPlugin();
|
||||
b.applyPreset(snap);
|
||||
expect(b.getParameter("width")).toBe(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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 {
|
||||
StereoWidenerPluginOptionsSchema,
|
||||
StereoWidenerPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
import type { StereoWidenerPluginOptions } from "../widener.types.js";
|
||||
|
||||
/**
|
||||
* Mid/side stereo width control. Decomposes the L/R signal into
|
||||
* `mid = (L + R) / 2` and `side = (L - R) / 2`, scales the side
|
||||
* component by `width`, and recomposes. `width === 1` is bit-exact
|
||||
* pass-through; `width === 0` collapses to mono; `width > 1`
|
||||
* exaggerates the stereo image. Mono inputs are passed through
|
||||
* unchanged because there is no side component to manipulate.
|
||||
*/
|
||||
export class StereoWidenerPlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(options: StereoWidenerPluginOptions = {}) {
|
||||
const parsed = StereoWidenerPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.widener-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "widener",
|
||||
name: "Stereo Widener",
|
||||
version: "1.0.0",
|
||||
category: "spatial",
|
||||
},
|
||||
schema: StereoWidenerPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "width",
|
||||
name: "Width",
|
||||
value: opts.initialWidth ?? 1,
|
||||
min: 0,
|
||||
max: 2,
|
||||
default: 1,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
_config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
/* Stateless M/S transform — nothing to set up. */
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
/* Nothing to reset. */
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const width = this.parameterValue("width");
|
||||
const blockSize = input[0]!.length;
|
||||
if (input.length < 2) {
|
||||
output[0]!.set(input[0]!);
|
||||
return;
|
||||
}
|
||||
if (width === 1) {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const left = input[0]!;
|
||||
const right = input[1]!;
|
||||
const outL = output[0]!;
|
||||
const outR = output[1]!;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const mid = 0.5 * (left[i]! + right[i]!);
|
||||
const side = 0.5 * (left[i]! - right[i]!) * width;
|
||||
outL[i] = mid + side;
|
||||
outR[i] = mid - side;
|
||||
}
|
||||
// Any extra channels (e.g. surround) pass through unchanged.
|
||||
for (let ch = 2; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-widener",
|
||||
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