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-spatial - spatial plugin for the @suaveplan/audio-fx-plugin-* family.
spatial 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 `SpatialAudioPlugin`. These run on the publi…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `SpatialAudioPlugin`. These run on the publi…
- [spatial](./src/spatial/spatial.md) — `SpatialAudioPlugin` is the universal positional spatial plugin. It computes a per-block distance attenuation from the…
- [spatial](./src/spatial/spatial.md) — `SpatialAudioPlugin` is the universal positional spatial plugin. It computes a per-block distance attenuation from the…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-spatial";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./spatial` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-spatial",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "spatial 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,11 @@
export {
SpatialAudioPluginOptionsSchema,
SpatialPresetDataSchema,
Vec3Schema,
} from "./schemas/index.js";
export { SpatialAudioPlugin } from "./spatial/index.js";
export type {
SpatialAudioPluginOptions,
SpatialPresetData,
Vec3,
} from "./spatial.types.js";

View File

@@ -0,0 +1,5 @@
export {
SpatialAudioPluginOptionsSchema,
SpatialPresetDataSchema,
Vec3Schema,
} from "./schemas.js";

View File

@@ -0,0 +1,66 @@
# `spatial/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options and preset payload of `SpatialAudioPlugin`. 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
- `Vec3Schema` — validates `{ x, y, z }` all `finite()` so `NaN` / `Infinity` cannot sneak through any vector field.
- `SpatialAudioPluginOptionsSchema` — validates all five optional construction fields with their documented ranges (`refDistance` 0.01..1 000, `maxDistance` 0.01..100 000, `rolloffFactor` 0..10) plus the two `Vec3` slots.
- `SpatialPresetDataSchema` — `.strict()` object carrying only `orientation: Vec3`; extra fields are rejected.
## Usage: Basic Example
```ts
import { SpatialAudioPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-spatial";
const parsed = SpatialAudioPluginOptionsSchema.parse({
initialPosition: { x: 5, y: 0, z: 0 },
});
```
## Usage: Advanced Example
```ts
import {
SpatialAudioPluginOptionsSchema,
SpatialPresetDataSchema,
Vec3Schema,
} from "@suaveplan/audio-fx-plugin-spatial";
// Reuse the shared Vec3 validator on a network message before
// forwarding it to the plugin.
function parseSpatialMessage(raw: unknown) {
const message = z
.object({
position: Vec3Schema,
orientation: Vec3Schema,
})
.parse(raw);
return message;
}
// Strict preset validation at the IPC boundary.
function isSpatialPreset(blob: unknown): boolean {
return SpatialPresetDataSchema.safeParse(blob).success;
}
```
## API Reference
- `Vec3Schema: z.ZodObject<{ x: number; y: number; z: number }>`
- `SpatialAudioPluginOptionsSchema: z.ZodObject<{ initialPosition?, initialOrientation?, initialRefDistance?, initialMaxDistance?, initialRolloffFactor? }>`
- `SpatialPresetDataSchema: z.ZodObject<{ orientation: Vec3 }, "strict">`
## Implementation Notes
`Vec3Schema` is exported as a named schema (not inlined) so the same value is reused by both `SpatialAudioPluginOptionsSchema` (for `initialPosition` and `initialOrientation`) and `SpatialPresetDataSchema` (for `orientation`). That guarantees all three vector fields agree byte-for-byte on what a legal `Vec3` looks like — and downstream code that wants to validate a `Vec3` independently (e.g., a network message carrying position updates) can pull the same schema.
`SpatialPresetDataSchema` uses `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. Position and the rolloff knobs are universal parameters and travel through the parameter snapshot built by `AbstractAudioFxPlugin`. Storing them again in the preset payload would double-store the same data and risk drift; encoding only the non-parameter `orientation` makes the preset contract minimal and unambiguous.

View File

@@ -0,0 +1,19 @@
import z from "zod";
export const Vec3Schema = z.object({
x: z.number().finite(),
y: z.number().finite(),
z: z.number().finite(),
});
export const SpatialAudioPluginOptionsSchema = z.object({
initialPosition: Vec3Schema.optional(),
initialOrientation: Vec3Schema.optional(),
initialRefDistance: z.number().finite().min(0.01).max(1_000).optional(),
initialMaxDistance: z.number().finite().min(0.01).max(100_000).optional(),
initialRolloffFactor: z.number().finite().min(0).max(10).optional(),
});
export const SpatialPresetDataSchema = z
.object({ orientation: Vec3Schema })
.strict();

View File

@@ -0,0 +1,74 @@
# `spatial.types`
## Purpose
TypeScript-only type definitions for `SpatialAudioPlugin`'s construction options, preset payload, and the shared `Vec3` 3-vector. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
`Vec3` is exported here (rather than from a shared package) so the spatial plugin's public API does not force callers to import an unrelated math library — a `{ x, y, z }` interface is small enough to live with its consumer.
## Features
- `Vec3` — a finite 3-vector with `x`, `y`, `z` number members.
- `SpatialAudioPluginOptions` — all fields optional so the default constructor (`new SpatialAudioPlugin()`) is valid.
- `SpatialPresetData` — preset payload carrying the non-parameter `orientation` vector so it survives round-trip.
- `readonly` fields prevent accidental mutation of saved options.
## Usage: Basic Example
```ts
import type {
SpatialAudioPluginOptions,
Vec3,
} from "@suaveplan/audio-fx-plugin-spatial";
const opts: SpatialAudioPluginOptions = {
initialPosition: { x: 5, y: 0, z: 0 },
initialRefDistance: 2,
};
const forward: Vec3 = { x: 1, y: 0, z: 0 };
```
## Usage: Advanced Example
```ts
import type {
SpatialAudioPluginOptions,
Vec3,
} from "@suaveplan/audio-fx-plugin-spatial";
function lerp(a: Vec3, b: Vec3, t: number): Vec3 {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
z: a.z + (b.z - a.z) * t,
};
}
const startPos: Vec3 = { x: 0, y: 0, z: 0 };
const endPos: Vec3 = { x: 30, y: 0, z: 0 };
const halfway: SpatialAudioPluginOptions = {
initialPosition: lerp(startPos, endPos, 0.5),
};
```
## API Reference
- `Vec3`
- `readonly x: number`
- `readonly y: number`
- `readonly z: number`
- `SpatialAudioPluginOptions`
- `readonly initialPosition?: Vec3`
- `readonly initialOrientation?: Vec3`
- `readonly initialRefDistance?: number`
- `readonly initialMaxDistance?: number`
- `readonly initialRolloffFactor?: number`
- `SpatialPresetData`
- `readonly orientation: Vec3`
## Implementation Notes
`Vec3` is intentionally a plain interface (not a class). Callers can construct one with an object literal at any seam, and TypeScript's structural typing means values produced by a math library can satisfy the shape without explicit conversion. The Zod schema in `schemas.ts` validates that all three fields are finite numbers, blocking `NaN` and `Infinity` at the construction boundary.
`SpatialPresetData` carries only `orientation`. Position and the rolloff knobs are universal parameters and travel through the parameter snapshot built by `AbstractAudioFxPlugin`. Storing them again in the preset payload would double-store the same data and risk drift; storing only the non-parameter `orientation` makes the preset contract minimal and unambiguous.

View File

@@ -0,0 +1,20 @@
/** 3-vector for position / orientation values. */
export interface Vec3 {
readonly x: number;
readonly y: number;
readonly z: number;
}
/** Construction options for `SpatialAudioPlugin`. */
export interface SpatialAudioPluginOptions {
readonly initialPosition?: Vec3;
readonly initialOrientation?: Vec3;
readonly initialRefDistance?: number;
readonly initialMaxDistance?: number;
readonly initialRolloffFactor?: number;
}
/** Persisted preset payload — the non-parameter orientation vector. */
export interface SpatialPresetData {
readonly orientation: Vec3;
}

View File

@@ -0,0 +1 @@
export { SpatialAudioPlugin } from "./spatial.js";

View File

@@ -0,0 +1,75 @@
# `spatial`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SpatialAudioPlugin` is the universal positional spatial plugin. It computes a per-block distance attenuation from the listener-relative `position` vector using the Web Audio "inverse-distance" rolloff model, then applies equal-power stereo panning based on the source's position along the X axis. The model matches the standard `PannerNode` semantics so a browser-side build using `createAudioNode()` (mounted by `@suaveplan/audio-engine-browser`) renders identically to the universal block path.
The plugin extends `AbstractAudioFxPlugin` and is stateless on the processing path. `reset()` is a no-op by design.
## Features
- Six parameters: `positionX/Y/Z`, `refDistance`, `maxDistance`, `rolloffFactor` — all automatable, mirroring the Web Audio `PannerNode` surface.
- `orientation: Vec3` carried via `setOrientation` / `getOrientation` and survives preset round-trip via `SpatialPresetData`. Not used by the universal distance/pan path, but available so a future HRTF renderer can read it without API breakage.
- Inverse-distance rolloff: `gain = refDistance / (refDistance + rolloffFactor · (clamp(d, refDistance, maxDistance) − refDistance))`. At `d ≤ refDistance`, gain = 1 (unity); for `d > refDistance`, gain decreases monotonically as `d` increases — the spec's "distance attenuation decreases monotonically per the rolloff model" contract.
- Equal-power stereo panning: `panNorm = x / d`, `angle = (panNorm + 1) · π / 4`, `outL = in · cos(angle) · distanceGain`, `outR = in · sin(angle) · distanceGain`. Mono input gets distance gain only; surround channels (third+) get distance gain only.
## Usage: Basic Example
```ts
import { SpatialAudioPlugin } from "@suaveplan/audio-fx-plugin-spatial";
const spatial = new SpatialAudioPlugin({
initialPosition: { x: 0, y: 0, z: 0 },
initialRefDistance: 1,
initialMaxDistance: 100,
initialRolloffFactor: 1,
});
chain.add(spatial);
```
## Usage: Advanced Example
```ts
import { SpatialAudioPlugin } from "@suaveplan/audio-fx-plugin-spatial";
// Game footstep emitter: position is updated every frame from the
// listener-relative player transform.
const footstep = new SpatialAudioPlugin({
initialRefDistance: 1,
initialMaxDistance: 50,
initialRolloffFactor: 2,
});
await footstep.setup(
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
ctx
);
function onFrame(playerToSource: { x: number; y: number; z: number }) {
footstep.setParameter("positionX", playerToSource.x);
footstep.setParameter("positionY", playerToSource.y);
footstep.setParameter("positionZ", playerToSource.z);
}
```
## API Reference
- `SpatialAudioPlugin` (class extends `AbstractAudioFxPlugin`)
- `new SpatialAudioPlugin(options?: SpatialAudioPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- `setOrientation(orientation: Vec3): void` — replace the stored orientation vector.
- `getOrientation(): Vec3` — read the current orientation vector.
- Inherited parameter accessors for `positionX`, `positionY`, `positionZ`, `refDistance`, `maxDistance`, `rolloffFactor`.
- Types: `Vec3 = { x: number; y: number; z: number }`.
## Implementation Notes
The distance gain uses the Web Audio inverse-distance model rather than the linear or exponential alternatives because it has the most predictable monotonic-decrease property: every `d > refDistance` strictly lowers the gain, with no inflection points or asymptote at `maxDistance`. The clamp `clamp(d, refDistance, maxDistance)` handles the boundary cases — `d < refDistance` collapses to unity gain (no boost for sources past the listener's head), and `d > maxDistance` freezes the attenuation at the maximum-distance value (a typical game-audio "kill zone" that prevents distant sources from being inaudible-but-still-rendered).
Panning is equal-power (`cos`/`sin`) rather than linear (`1-panNorm`/`panNorm`) because equal-power preserves perceived loudness as a source moves across the stereo field. The mapping `angle = (panNorm + 1) · π / 4` puts `panNorm = -1` (hard left) at `angle = 0` (gain = `cos 0 = 1`, sin 0 = 0`) and`panNorm = 1` (hard right) at `angle = π/2` (gain = `cos π/2 = 0`, sin π/2 = 1). At`panNorm = 0` (centre), both channels get `√½` so the summed energy matches a mono source unchanged.
`orientation` is deliberately *not* a parameter. Parameters in the universal scheme are scalar-valued and automatable; a 3-vector orientation would need three separate parameters that are typically updated together. Exposing `setOrientation` / `getOrientation` keeps the API ergonomic for game-audio callers while preserving the vector across preset round-trip via `SpatialPresetData`. A future revision could add automatable `orientationX/Y/Z` parameters if a real use case emerges.
Mono input is distance-attenuated only — no pan is applied because there is no L/R to mix between. This matches typical 3D-audio engine behaviour: if a sound is recorded mono, it gets routed through the spatial pipeline but emerges mono unless the engine explicitly upmixes. The `io.outputs[0].count === "preserve"` declaration carries that intent into the chain composition rules.

View File

@@ -0,0 +1,256 @@
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 { SpatialAudioPlugin } from "./spatial.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: "spatial-test",
name: "spatial-test",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 256,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
function rms(buf: Float32Array): number {
let acc = 0;
for (let i = 0; i < buf.length; i++) acc += buf[i]! * buf[i]!;
return Math.sqrt(acc / buf.length);
}
describe("SpatialAudioPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const s = new SpatialAudioPlugin();
expect(s.metadata.id).toBe("spatial");
expect(s.metadata.category).toBe("spatial");
expect(s.parameters.map((p) => p.id)).toEqual([
"positionX",
"positionY",
"positionZ",
"refDistance",
"maxDistance",
"rolloffFactor",
]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new SpatialAudioPlugin({ initialRolloffFactor: 99 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("SpatialAudioPlugin: distance attenuation decreases monotonically per the rolloff model", () => {
it("output RMS strictly decreases as distance grows from refDistance to maxDistance", {
timeout: 5_000,
}, async () => {
const distances = [1, 2, 4, 8, 16, 32, 64];
const rmsValues: number[] = [];
for (const d of distances) {
const s = new SpatialAudioPlugin({
initialPosition: { x: d, y: 0, z: 0 },
initialRefDistance: 1,
initialMaxDistance: 1_000,
initialRolloffFactor: 1,
});
await s.setup(newConfig(512, 1), newCtx(1));
const { input, output } = buffers(512, 1);
for (let i = 0; i < 512; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
s.process(input, output, blockCtx());
rmsValues.push(rms(output[0]!));
}
for (let i = 1; i < rmsValues.length; i++) {
expect(rmsValues[i]!).toBeLessThan(rmsValues[i - 1]!);
}
});
});
describe("SpatialAudioPlugin: distance ≤ refDistance is unity gain", () => {
it("position at origin produces output equal to input on mono", {
timeout: 5_000,
}, async () => {
const s = new SpatialAudioPlugin({
initialPosition: { x: 0, y: 0, z: 0 },
initialRefDistance: 1,
});
await s.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 * 1_000 * i) / SAMPLE_RATE);
}
s.process(input, output, blockCtx());
for (let i = 0; i < 256; i++) {
expect(output[0]![i]).toBe(input[0]![i]!);
}
});
});
describe("SpatialAudioPlugin: stereo panning follows position.x", () => {
it("hard-right position attenuates left channel and boosts right", {
timeout: 5_000,
}, async () => {
const s = new SpatialAudioPlugin({
initialPosition: { x: 10, y: 0, z: 0 },
initialRefDistance: 1,
initialMaxDistance: 1_000,
initialRolloffFactor: 0,
});
await s.setup(newConfig(256, 2), newCtx(2));
const { input, output } = buffers(256, 2);
for (let i = 0; i < 256; i++) {
input[0]![i] = 0.5;
input[1]![i] = 0.5;
}
s.process(input, output, blockCtx());
// rolloff=0 → distanceGain=1, so the only effect is pan.
// pan x>0 → right channel louder.
expect(rms(output[1]!)).toBeGreaterThan(rms(output[0]!));
});
it("centred position (x=0) gives equal-power split", {
timeout: 5_000,
}, async () => {
const s = new SpatialAudioPlugin({
initialPosition: { x: 0, y: 10, z: 0 },
initialRefDistance: 100,
initialRolloffFactor: 0,
});
await s.setup(newConfig(128, 2), newCtx(2));
const { input, output } = buffers(128, 2);
for (let i = 0; i < 128; i++) {
input[0]![i] = 1;
input[1]![i] = 1;
}
s.process(input, output, blockCtx());
const sqrtHalf = Math.SQRT1_2;
for (let i = 0; i < 128; i++) {
expect(output[0]![i]).toBeCloseTo(sqrtHalf, 5);
expect(output[1]![i]).toBeCloseTo(sqrtHalf, 5);
}
});
});
describe("SpatialAudioPlugin: surround channels get distance gain only", () => {
it("channels beyond L/R are scaled by distance gain", {
timeout: 5_000,
}, async () => {
const s = new SpatialAudioPlugin({
initialPosition: { x: 0, y: 0, z: 0 },
initialRefDistance: 1,
initialRolloffFactor: 0,
});
await s.setup(newConfig(128, 4), newCtx(4));
const { input, output } = buffers(128, 4);
for (let i = 0; i < 128; i++) {
input[2]![i] = 0.5;
input[3]![i] = -0.25;
}
s.process(input, output, blockCtx());
// At origin with rolloff=0, distance gain is 1, so the surround
// channels should pass through unchanged.
for (let i = 0; i < 128; i++) {
expect(output[2]![i]).toBe(input[2]![i]!);
expect(output[3]![i]).toBe(input[3]![i]!);
}
});
});
describe("SpatialAudioPlugin: orientation is stored and round-trips", () => {
it("setOrientation / getOrientation preserve the vector", {
timeout: 5_000,
}, () => {
const s = new SpatialAudioPlugin();
s.setOrientation({ x: 0, y: 1, z: 0 });
expect(s.getOrientation()).toEqual({ x: 0, y: 1, z: 0 });
});
it("orientation survives preset round-trip", { timeout: 5_000 }, () => {
const a = new SpatialAudioPlugin({
initialOrientation: { x: 0, y: 0, z: -1 },
});
const snap = a.getPreset();
const b = new SpatialAudioPlugin();
b.applyPreset(snap);
expect(b.getOrientation()).toEqual({ x: 0, y: 0, z: -1 });
});
});
describe("SpatialAudioPlugin: preset round-trip carries position parameters", () => {
it("positionX round-trips through preset", { timeout: 5_000 }, () => {
const a = new SpatialAudioPlugin({
initialPosition: { x: 3, y: 4, z: 5 },
});
const snap = a.getPreset();
const b = new SpatialAudioPlugin();
b.applyPreset(snap);
expect(b.getParameter("positionX")).toBe(3);
expect(b.getParameter("positionY")).toBe(4);
expect(b.getParameter("positionZ")).toBe(5);
});
});
describe("SpatialAudioPlugin: reset is a no-op", () => {
it("does not throw on a stateless plugin", { timeout: 5_000 }, async () => {
const s = new SpatialAudioPlugin();
await s.setup(newConfig(64, 1), newCtx(1));
expect(() => s.reset()).not.toThrow();
});
});

View File

@@ -0,0 +1,202 @@
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 {
SpatialAudioPluginOptionsSchema,
SpatialPresetDataSchema,
} from "../schemas/index.js";
import type {
SpatialAudioPluginOptions,
SpatialPresetData,
Vec3,
} from "../spatial.types.js";
const DEFAULT_ORIENTATION: Vec3 = { x: 1, y: 0, z: 0 };
/**
* Universal positional spatial plugin. Computes per-block distance
* attenuation using the Web Audio "inverse-distance" rolloff model
* (`gain = refDistance / (refDistance + rolloffFactor · (d − refDistance))`)
* and applies equal-power stereo panning when the channel count
* supports it. Distance attenuation is monotonically non-increasing
* for distances in `[refDistance, maxDistance]`, satisfying the spec
* contract.
*
* Orientation is stored as a 3-vector but not used by the universal
* distance/pan path — it is carried so a browser-side HRTF renderer
* (mounted via `createAudioNode`) can read it. The vector survives
* preset round-trip via `SpatialPresetData`.
*/
export class SpatialAudioPlugin extends AbstractAudioFxPlugin {
private orientation: Vec3 = DEFAULT_ORIENTATION;
public constructor(options: SpatialAudioPluginOptions = {}) {
const parsed = SpatialAudioPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.spatial-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
const position = opts.initialPosition ?? { x: 0, y: 0, z: 0 };
super({
metadata: {
id: "spatial",
name: "Spatial Audio",
version: "1.0.0",
category: "spatial",
},
schema: SpatialPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [
{
id: "positionX",
name: "Position X",
value: position.x,
min: -10_000,
max: 10_000,
default: 0,
unit: "samples",
automatable: true,
},
{
id: "positionY",
name: "Position Y",
value: position.y,
min: -10_000,
max: 10_000,
default: 0,
unit: "samples",
automatable: true,
},
{
id: "positionZ",
name: "Position Z",
value: position.z,
min: -10_000,
max: 10_000,
default: 0,
unit: "samples",
automatable: true,
},
{
id: "refDistance",
name: "Reference Distance",
value: opts.initialRefDistance ?? 1,
min: 0.01,
max: 1_000,
default: 1,
unit: "samples",
automatable: true,
},
{
id: "maxDistance",
name: "Max Distance",
value: opts.initialMaxDistance ?? 10_000,
min: 0.01,
max: 100_000,
default: 10_000,
unit: "samples",
automatable: true,
},
{
id: "rolloffFactor",
name: "Rolloff Factor",
value: opts.initialRolloffFactor ?? 1,
min: 0,
max: 10,
default: 1,
unit: "ratio",
automatable: true,
},
],
supportedSchemaVersion: 1,
});
if (opts.initialOrientation !== undefined) {
this.orientation = opts.initialOrientation;
}
}
public setOrientation(orientation: Vec3): void {
this.orientation = orientation;
}
public getOrientation(): Vec3 {
return this.orientation;
}
protected override setupState(
_config: ProcessorConfig,
_chain: ChainContext
): void {
/* Stateless DSP path — nothing to set up. */
}
protected override resetState(): void {
/* Nothing to reset. */
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
const x = this.parameterValue("positionX");
const y = this.parameterValue("positionY");
const z = this.parameterValue("positionZ");
const refDistance = this.parameterValue("refDistance");
const maxDistance = this.parameterValue("maxDistance");
const rolloffFactor = this.parameterValue("rolloffFactor");
const blockSize = input[0]!.length;
const distance = Math.sqrt(x * x + y * y + z * z);
const clamped = Math.min(maxDistance, Math.max(refDistance, distance));
const distanceGain =
refDistance /
(refDistance + rolloffFactor * (clamped - refDistance));
if (input.length >= 2) {
const panNorm = distance > 0 ? x / distance : 0;
const angle = ((panNorm + 1) * Math.PI) / 4;
const leftGain = Math.cos(angle) * distanceGain;
const rightGain = Math.sin(angle) * distanceGain;
const left = input[0]!;
const right = input[1]!;
const outL = output[0]!;
const outR = output[1]!;
for (let i = 0; i < blockSize; i++) {
outL[i] = left[i]! * leftGain;
outR[i] = right[i]! * rightGain;
}
for (let ch = 2; ch < input.length; ch++) {
const inBuf = input[ch]!;
const outBuf = output[ch]!;
for (let i = 0; i < blockSize; i++) {
outBuf[i] = inBuf[i]! * distanceGain;
}
}
return;
}
const inBuf = input[0]!;
const outBuf = output[0]!;
for (let i = 0; i < blockSize; i++) {
outBuf[i] = inBuf[i]! * distanceGain;
}
}
protected override getPresetData(): SpatialPresetData {
return { orientation: this.orientation };
}
protected override applyPresetData(data: unknown): void {
const { orientation } = data as SpatialPresetData;
this.orientation = orientation;
}
}

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-spatial",
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 });