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-microphone - microphone plugin for the @suaveplan/audio-fx-plugin-* family.
microphone 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
- [microphone](./src/microphone/microphone.md) — `MicrophoneSimulationPlugin` is the universal microphone-simulation source plugin. It synthesises a microphone-coloured…
- [microphone](./src/microphone/microphone.md) — `MicrophoneSimulationPlugin` is the universal microphone-simulation source plugin. It synthesises a microphone-coloured…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, the eight control groups, the full configuration tree, and the pr…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, the eight control groups, the full configuration tree, and the pr…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-microphone";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./microphone` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-microphone",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "microphone 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,32 @@
export {
createMicrophonePlugin,
MicrophoneSimulationPlugin,
} from "./microphone/index.js";
export type {
CharacterControls,
DistortionControls,
DynamicsControls,
FrequencyControls,
GainControls,
MicrophoneConfig,
MicrophonePresetData,
MicrophoneSimulationPluginOptions,
MicrophoneUIControl,
MicrophoneUISection,
NoiseControls,
ResonanceControls,
WowAndFlutterControls,
} from "./microphone.types.js";
export {
CharacterControlsSchema,
DistortionControlsSchema,
DynamicsControlsSchema,
FrequencyControlsSchema,
GainControlsSchema,
MicrophoneConfigSchema,
MicrophonePresetDataSchema,
MicrophoneSimulationPluginOptionsSchema,
NoiseControlsSchema,
ResonanceControlsSchema,
WowAndFlutterControlsSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,70 @@
# `microphone.types`
## Purpose
TypeScript-only type definitions for `MicrophoneSimulationPlugin`'s control groups, configuration tree, preset payload, and UI section descriptor. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. The control-group split (FrequencyControls, ResonanceControls, …) mirrors the main-branch `microphone.types.ts` so a UI built against either codebase can render the other.
## Features
- Eight `*Controls` interfaces, one per microphone signal stage.
- `MicrophoneConfig` aggregates them under named sections; consumers can pass `Partial<MicrophoneConfig>` to merge with current state.
- `MicrophoneSimulationPluginOptions` is a thin shell carrying the partial config.
- `MicrophonePresetData` carries the full live config so a preset restores every section.
- `MicrophoneUIControl` / `MicrophoneUISection` describe the UI without prescribing a renderer.
## Usage: Basic Example
```ts
import type {
MicrophoneConfig,
MicrophoneSimulationPluginOptions,
} from "@suaveplan/audio-fx-plugin-microphone";
const opts: MicrophoneSimulationPluginOptions = {
config: { noise: { level: 0.1, color: "pink" } },
};
```
## Usage: Advanced Example
```ts
import type {
MicrophoneConfig,
MicrophoneUISection,
} from "@suaveplan/audio-fx-plugin-microphone";
function indexControlsById(
sections: ReadonlyArray<MicrophoneUISection>
): Record<string, MicrophoneUISection["controls"][number]> {
const out: Record<string, MicrophoneUISection["controls"][number]> = {};
for (const section of sections) {
for (const control of section.controls) {
out[control.id] = control;
}
}
return out;
}
function presetFrom(config: MicrophoneConfig): MicrophoneConfig {
return { ...config };
}
```
## API Reference
- `FrequencyControls`, `ResonanceControls`, `DistortionControls`, `NoiseControls`, `DynamicsControls`, `GainControls`, `CharacterControls`, `WowAndFlutterControls` — one `readonly`-fielded interface per signal stage.
- `MicrophoneConfig` — the eight control groups composed into one object.
- `MicrophoneSimulationPluginOptions` — `{ config?: Partial<MicrophoneConfig> }`.
- `MicrophonePresetData` — `{ config: MicrophoneConfig }`.
- `MicrophoneUIControl` — `{ id, label, min, max, step, unit, group }`.
- `MicrophoneUISection` — `{ id, label, controls: ReadonlyArray<MicrophoneUIControl> }`.
## Implementation Notes
The eight `*Controls` interfaces are kept distinct (not collapsed into one flat record) because:
1. UI code can iterate the sections rather than the flat field list, producing a grouped form for free.
2. `Partial<MicrophoneConfig>` provides per-section partials; consumers can pass `setMicrophoneConfig({ noise: { level: 0.7 } })` without restating other fields.
3. The Zod schema for each section is reusable in RPC contracts that target one section at a time.
`MicrophoneUIControl.group` is typed as `keyof MicrophoneConfig` (a string literal union over the eight section names) rather than a freeform string so the type system enforces consistency: adding a section requires updating the interface name, the schema, and the UI descriptor in lockstep, all caught at compile time.

View File

@@ -0,0 +1,88 @@
/** Frequency-shaping controls (LP filter + tonal centre). */
export interface FrequencyControls {
readonly cutoffHz: number;
readonly tiltDb: number;
}
/** Resonance / Q controls feeding the filter. */
export interface ResonanceControls {
readonly q: number;
}
/** Drive + wet/dry distortion controls. */
export interface DistortionControls {
readonly drive: number;
readonly mix: number;
}
/** Noise floor controls. */
export interface NoiseControls {
readonly level: number;
readonly color: "white" | "pink";
}
/** Compressor / dynamics controls. */
export interface DynamicsControls {
readonly thresholdDb: number;
readonly ratio: number;
}
/** Input + output gain controls. */
export interface GainControls {
readonly inputDb: number;
readonly outputDb: number;
}
/** Tonal-character controls. */
export interface CharacterControls {
readonly warmth: number;
readonly presence: number;
}
/** Wow & flutter tape-style modulation. */
export interface WowAndFlutterControls {
readonly wowRateHz: number;
readonly wowDepth: number;
readonly flutterRateHz: number;
readonly flutterDepth: number;
}
/** Full microphone configuration carrying every control group. */
export interface MicrophoneConfig {
readonly frequency: FrequencyControls;
readonly resonance: ResonanceControls;
readonly distortion: DistortionControls;
readonly noise: NoiseControls;
readonly dynamics: DynamicsControls;
readonly gain: GainControls;
readonly character: CharacterControls;
readonly wowFlutter: WowAndFlutterControls;
}
/** Construction options for `MicrophoneSimulationPlugin`. */
export interface MicrophoneSimulationPluginOptions {
readonly config?: Partial<MicrophoneConfig>;
}
/** Preset payload — the full configuration tree. */
export interface MicrophonePresetData {
readonly config: MicrophoneConfig;
}
/** One numeric control on a UI section. */
export interface MicrophoneUIControl {
readonly id: string;
readonly label: string;
readonly min: number;
readonly max: number;
readonly step: number;
readonly unit: string;
readonly group: keyof MicrophoneConfig;
}
/** A grouped UI section listing its controls. */
export interface MicrophoneUISection {
readonly id: keyof MicrophoneConfig;
readonly label: string;
readonly controls: ReadonlyArray<MicrophoneUIControl>;
}

View File

@@ -0,0 +1,4 @@
export {
createMicrophonePlugin,
MicrophoneSimulationPlugin,
} from "./microphone.js";

View File

@@ -0,0 +1,89 @@
# `microphone`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MicrophoneSimulationPlugin` is the universal microphone-simulation source plugin. It synthesises a microphone-coloured signal from a deterministic noise generator (selectable white or pink) passed through a one-pole low-pass filter, an input/output gain stage, optional `tanh`-soft-clip distortion, a "warmth + presence" tonal stage, and wow/flutter amplitude modulation. The plugin is a source (`io.inputs[0].count === 0`, `io.outputs[0].count === 1`).
The configuration is grouped into eight control families (frequency, resonance, distortion, noise, dynamics, gain, character, wowFlutter); each family is defined as a structured interface so editors can render the UI by iterating `getUISections()`. The plugin extends `AbstractAudioFxPlugin` and runs identically across browser / server / worklet renderers.
## Features
- Eight control groups (`MicrophoneConfig`): `frequency`, `resonance`, `distortion`, `noise`, `dynamics`, `gain`, `character`, `wowFlutter`. Each is a typed `*Controls` interface with a Zod schema and a UI section descriptor.
- One automatable parameter: `outputGain` (0..4) for top-of-chain level.
- `getUISections()` returns a `ReadonlyArray<MicrophoneUISection>` with stable `id`s, min/max/step/unit/group metadata, so editors can render the full surface without hard-coding any of it.
- Deterministic noise generator (xorshift32 RNG); `reset()` re-seeds so two reset-then-process runs produce byte-identical output — useful for regression testing.
- `createMicrophonePlugin(options)` factory mirrors the main-branch surface for migration parity.
- Pink-noise mode uses Paul Kellet's three-pole approximation, the same one the main-branch package ships, so the spectral colour matches.
## Usage: Basic Example
```ts
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
const mic = new MicrophoneSimulationPlugin();
chain.add(mic);
```
## Usage: Advanced Example
```ts
import {
MicrophoneSimulationPlugin,
createMicrophonePlugin,
} from "@suaveplan/audio-fx-plugin-microphone";
const lofi = createMicrophonePlugin({
config: {
frequency: { cutoffHz: 3_500, tiltDb: 3 },
noise: { level: 0.2, color: "pink" },
distortion: { drive: 0.6, mix: 0.4 },
wowFlutter: {
wowRateHz: 1.2,
wowDepth: 0.3,
flutterRateHz: 10,
flutterDepth: 0.15,
},
},
});
await lofi.setup(
{ sampleRate: 48_000, channels: 1, blockSize: 512 },
ctx
);
// Drive a UI form from the section descriptor.
for (const section of lofi.getUISections()) {
form.addSection(section.id, section.label);
for (const control of section.controls) {
form.addControl(control);
}
}
```
## API Reference
- `MicrophoneSimulationPlugin` (class extends `AbstractAudioFxPlugin`)
- `new MicrophoneSimulationPlugin(options?: MicrophoneSimulationPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- `getMicrophoneConfig(): MicrophoneConfig`
- `setMicrophoneConfig(partial: Partial<MicrophoneConfig>): void` — merge-updates the live config; missing keys stay at their current values.
- `getUISections(): ReadonlyArray<MicrophoneUISection>`
- Inherited accessor for `outputGain`.
- `createMicrophonePlugin(options?)` — factory equivalent.
- Types: `FrequencyControls`, `ResonanceControls`, `DistortionControls`, `NoiseControls`, `DynamicsControls`, `GainControls`, `CharacterControls`, `WowAndFlutterControls`, `MicrophoneConfig`, `MicrophoneUIControl`, `MicrophoneUISection`.
## Implementation Notes
The noise generator is xorshift32 seeded with a fixed constant (`0x12345678`). Determinism matters for testing: the "reset re-seeds" test asserts that two runs after `reset()` produce byte-identical output. A `Math.random()`-based generator would fail that test on every invocation because the RNG state would advance between runs. The xorshift32 output is mapped to `[-1, 1]` via the standard `(u32 / 0xffffffff) * 2 - 1` formula.
Pink-noise mode uses Paul Kellet's three-pole approximation rather than a true 1/f filter because the latter requires a much higher-order filter to be accurate and would add per-sample CPU cost out of proportion with the perceptual improvement. The three-pole shape is the same as the main-branch implementation, so a side-by-side spectral comparison passes within a couple of dB.
The filter is a one-pole low-pass (`y = y_prev + α · (x - y_prev)`, with `α = ω / (ω + 1)`), not a biquad. The single-pole shape is what consumer microphones colour the signal with — biquad-class shaping is the territory of the `FilterPlugin` and `EQPlugin` siblings, which a caller can chain downstream. Cutoff is clamped to `[20, 0.45·sr]` so the bilinear transform stays stable across the full range.
Wow and flutter use two independent sine LFOs modulating the output gain. The wow rate is around 0.6 Hz by default (slow tape drift); flutter is around 8 Hz (fast tape wobble). Both depths are scaled by small constants (0.1 and 0.05 respectively) so the user-facing range `[0, 1]` translates to ±10% and ±5% amplitude variation — perceptually-sensible defaults rather than mathematically-extreme ones.
`MicrophoneConfig` is consumed as a tree of `Partial<*Controls>` so callers can update only the field they care about. `setMicrophoneConfig` merges with the current state per-section, so a `setMicrophoneConfig({ noise: { level: 0.7 } })` call does **not** require respecifying `color` — the previous value is preserved.
The plugin declares only one parameter (`outputGain`); every other control lives on the `MicrophoneConfig` tree because they are configuration, not real-time automation. If a future revision wants to automate a specific control (e.g., automate `cutoffHz`), promoting it to a parameter is a one-line change that does not break the section descriptor (which would still list it under `frequency`).

View File

@@ -0,0 +1,245 @@
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 {
createMicrophonePlugin,
MicrophoneSimulationPlugin,
} from "./microphone.js";
const SAMPLE_RATE = 48_000;
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: SAMPLE_RATE,
channels: 1,
opts: {},
};
}
function newConfig(blockSize: number): ProcessorConfig {
return { sampleRate: SAMPLE_RATE, channels: 1, blockSize };
}
function newCtx(): ChainContext {
const chain: ChainHandle = {
id: "mic-test",
name: "mic-test",
channels: 1,
sampleRate: SAMPLE_RATE,
blockSize: 128,
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("MicrophoneSimulationPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const m = new MicrophoneSimulationPlugin();
expect(m.metadata.id).toBe("microphone");
expect(m.metadata.category).toBe("source");
expect(m.parameters.map((x) => x.id)).toEqual(["outputGain"]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new MicrophoneSimulationPlugin({
config: { noise: { level: 99, color: "white" } },
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("createMicrophonePlugin factory returns a configured instance", {
timeout: 5_000,
}, () => {
const m = createMicrophonePlugin({
config: { noise: { level: 0.5, color: "pink" } },
});
expect(m.getMicrophoneConfig().noise.color).toBe("pink");
expect(m.getMicrophoneConfig().noise.level).toBeCloseTo(0.5, 6);
});
it("createMicrophonePlugin factory works with no arguments", {
timeout: 5_000,
}, () => {
const m = createMicrophonePlugin();
expect(m.getMicrophoneConfig().noise.color).toBe("white");
});
});
describe("MicrophoneSimulationPlugin: default microphone produces non-silent output", () => {
it("RMS of a 4096-sample block exceeds 1e-4 on the default config", {
timeout: 5_000,
}, async () => {
const m = new MicrophoneSimulationPlugin();
await m.setup(newConfig(4096), newCtx());
const output: Float32Array[] = [new Float32Array(4096)];
m.process([], output, blockCtx());
expect(rms(output[0]!)).toBeGreaterThan(1e-4);
for (let i = 0; i < 4096; i++) {
expect(Number.isFinite(output[0]![i]!)).toBe(true);
}
});
it("pink-noise variant also produces non-silent output", {
timeout: 5_000,
}, async () => {
const m = new MicrophoneSimulationPlugin({
config: { noise: { level: 0.5, color: "pink" } },
});
await m.setup(newConfig(4096), newCtx());
const output: Float32Array[] = [new Float32Array(4096)];
m.process([], output, blockCtx());
expect(rms(output[0]!)).toBeGreaterThan(1e-4);
});
});
describe("MicrophoneSimulationPlugin: getUISections enumerates every configured control", () => {
it("eight sections, each with at least one control, all groups present", {
timeout: 5_000,
}, () => {
const m = new MicrophoneSimulationPlugin();
const sections = m.getUISections();
expect(sections.map((s) => s.id)).toEqual([
"frequency",
"resonance",
"distortion",
"noise",
"dynamics",
"gain",
"character",
"wowFlutter",
]);
for (const section of sections) {
expect(section.controls.length).toBeGreaterThan(0);
for (const control of section.controls) {
expect(control.group).toBe(section.id);
expect(control.min).toBeLessThan(control.max);
expect(control.step).toBeGreaterThan(0);
expect(control.id.length).toBeGreaterThan(0);
}
}
});
});
describe("MicrophoneSimulationPlugin: preset round-trip preserves the full microphone control state", () => {
it("getPreset / applyPreset round-trips every control group", {
timeout: 5_000,
}, () => {
const a = new MicrophoneSimulationPlugin({
config: {
frequency: { cutoffHz: 4_000, tiltDb: 6 },
noise: { level: 0.1, color: "pink" },
gain: { inputDb: 6, outputDb: -3 },
wowFlutter: {
wowRateHz: 1.5,
wowDepth: 0.4,
flutterRateHz: 12,
flutterDepth: 0.2,
},
},
});
const snap = a.getPreset();
const b = new MicrophoneSimulationPlugin();
b.applyPreset(snap);
const cfg = b.getMicrophoneConfig();
expect(cfg.frequency.cutoffHz).toBeCloseTo(4_000, 4);
expect(cfg.frequency.tiltDb).toBeCloseTo(6, 6);
expect(cfg.noise.level).toBeCloseTo(0.1, 6);
expect(cfg.noise.color).toBe("pink");
expect(cfg.gain.inputDb).toBeCloseTo(6, 6);
expect(cfg.gain.outputDb).toBeCloseTo(-3, 6);
expect(cfg.wowFlutter.wowDepth).toBeCloseTo(0.4, 6);
expect(cfg.wowFlutter.flutterRateHz).toBeCloseTo(12, 6);
});
});
describe("MicrophoneSimulationPlugin: setMicrophoneConfig partial updates merge with current state", () => {
it("setting only `noise.color` leaves other fields intact", {
timeout: 5_000,
}, () => {
const m = new MicrophoneSimulationPlugin();
const before = m.getMicrophoneConfig();
m.setMicrophoneConfig({ noise: { level: 0.7, color: "pink" } });
const after = m.getMicrophoneConfig();
expect(after.noise.color).toBe("pink");
expect(after.noise.level).toBeCloseTo(0.7, 6);
expect(after.frequency.cutoffHz).toBe(before.frequency.cutoffHz);
});
});
describe("MicrophoneSimulationPlugin: distortion + wow/flutter add audible variation", () => {
it("hot drive + mix=1 produces an output range wider than the dry baseline", {
timeout: 5_000,
}, async () => {
const dry = new MicrophoneSimulationPlugin({
config: { distortion: { drive: 0, mix: 0 } },
});
const wet = new MicrophoneSimulationPlugin({
config: {
distortion: { drive: 1, mix: 1 },
wowFlutter: {
wowRateHz: 5,
wowDepth: 1,
flutterRateHz: 20,
flutterDepth: 1,
},
},
});
await dry.setup(newConfig(4096), newCtx());
await wet.setup(newConfig(4096), newCtx());
const a: Float32Array[] = [new Float32Array(4096)];
const b: Float32Array[] = [new Float32Array(4096)];
dry.process([], a, blockCtx());
wet.process([], b, blockCtx());
// Wet config typically produces a different RMS profile from dry.
expect(rms(a[0]!)).not.toBeCloseTo(rms(b[0]!), 3);
});
});
describe("MicrophoneSimulationPlugin: reset re-seeds the deterministic noise generator", () => {
it("reset re-creates the same noise stream from the seeded state", {
timeout: 5_000,
}, async () => {
const m = new MicrophoneSimulationPlugin();
await m.setup(newConfig(512), newCtx());
const a: Float32Array[] = [new Float32Array(512)];
m.process([], a, blockCtx());
m.reset();
const b: Float32Array[] = [new Float32Array(512)];
m.process([], b, blockCtx());
// After reset, the noise PRNG starts from the same seed → identical block.
for (let i = 0; i < 512; i++) {
expect(a[0]![i]).toBe(b[0]![i]!);
}
});
});

View File

@@ -0,0 +1,296 @@
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 {
MicrophoneConfig,
MicrophonePresetData,
MicrophoneSimulationPluginOptions,
MicrophoneUIControl,
MicrophoneUISection,
} from "../microphone.types.js";
import {
MicrophonePresetDataSchema,
MicrophoneSimulationPluginOptionsSchema,
} from "../schemas/index.js";
const TWO_PI = 2 * Math.PI;
const DEFAULT_CONFIG: MicrophoneConfig = {
frequency: { cutoffHz: 8_000, tiltDb: 0 },
resonance: { q: 1 },
distortion: { drive: 0, mix: 0 },
noise: { level: 0.02, color: "white" },
dynamics: { thresholdDb: -18, ratio: 2 },
gain: { inputDb: 0, outputDb: 0 },
character: { warmth: 0.3, presence: 0.5 },
wowFlutter: {
wowRateHz: 0.6,
wowDepth: 0,
flutterRateHz: 8,
flutterDepth: 0,
},
};
const UI_SECTIONS: ReadonlyArray<MicrophoneUISection> = Object.freeze([
section("frequency", "Frequency", [
ctrl("cutoffHz", "Cutoff", 20, 20_000, 1, "hz", "frequency"),
ctrl("tiltDb", "Tilt", -24, 24, 0.1, "db", "frequency"),
]),
section("resonance", "Resonance", [
ctrl("q", "Q", 0.1, 20, 0.1, "ratio", "resonance"),
]),
section("distortion", "Distortion", [
ctrl("drive", "Drive", 0, 1, 0.01, "ratio", "distortion"),
ctrl("mix", "Mix", 0, 1, 0.01, "ratio", "distortion"),
]),
section("noise", "Noise", [
ctrl("level", "Level", 0, 1, 0.001, "ratio", "noise"),
]),
section("dynamics", "Dynamics", [
ctrl("thresholdDb", "Threshold", -60, 0, 0.1, "db", "dynamics"),
ctrl("ratio", "Ratio", 1, 20, 0.1, "ratio", "dynamics"),
]),
section("gain", "Gain", [
ctrl("inputDb", "Input", -24, 24, 0.1, "db", "gain"),
ctrl("outputDb", "Output", -24, 24, 0.1, "db", "gain"),
]),
section("character", "Character", [
ctrl("warmth", "Warmth", 0, 1, 0.01, "ratio", "character"),
ctrl("presence", "Presence", 0, 1, 0.01, "ratio", "character"),
]),
section("wowFlutter", "Wow & Flutter", [
ctrl("wowRateHz", "Wow Rate", 0, 10, 0.1, "hz", "wowFlutter"),
ctrl("wowDepth", "Wow Depth", 0, 1, 0.01, "ratio", "wowFlutter"),
ctrl("flutterRateHz", "Flutter Rate", 0, 50, 0.1, "hz", "wowFlutter"),
ctrl(
"flutterDepth",
"Flutter Depth",
0,
1,
0.01,
"ratio",
"wowFlutter"
),
]),
]);
/**
* Universal microphone-simulation source plugin. Synthesises a
* microphone-coloured signal from a white-noise generator passed
* through a one-pole low-pass filter, gain, soft-clip distortion, and
* wow/flutter amplitude modulation. The plugin extends
* `AbstractAudioFxPlugin`, so it runs identically across browser,
* server, offline, and worklet renderers.
*/
export class MicrophoneSimulationPlugin extends AbstractAudioFxPlugin {
private microphoneConfig: MicrophoneConfig;
private lpY = 0;
private pinkB0 = 0;
private pinkB1 = 0;
private pinkB2 = 0;
private rngState = 0x12345678;
private wowPhase = 0;
private flutterPhase = 0;
public constructor(options: MicrophoneSimulationPluginOptions = {}) {
const parsed =
MicrophoneSimulationPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.microphone-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
super({
metadata: {
id: "microphone",
name: "Microphone Simulation",
version: "1.0.0",
category: "source",
},
schema: MicrophonePresetDataSchema,
io: {
inputs: [{ count: 0 }],
outputs: [{ count: 1 }],
},
parameters: [
{
id: "outputGain",
name: "Output Gain",
value: 1,
min: 0,
max: 4,
default: 1,
unit: "ratio",
automatable: true,
},
],
supportedSchemaVersion: 1,
});
this.microphoneConfig = mergeConfig(
DEFAULT_CONFIG,
(parsed.data.config ?? {}) as Partial<MicrophoneConfig>
);
}
public getMicrophoneConfig(): MicrophoneConfig {
return this.microphoneConfig;
}
public setMicrophoneConfig(config: Partial<MicrophoneConfig>): void {
this.microphoneConfig = mergeConfig(this.microphoneConfig, config);
}
public getUISections(): ReadonlyArray<MicrophoneUISection> {
return UI_SECTIONS;
}
protected override setupState(
_config: ProcessorConfig,
_chain: ChainContext
): void {
this.lpY = 0;
this.pinkB0 = 0;
this.pinkB1 = 0;
this.pinkB2 = 0;
this.rngState = 0x12345678;
this.wowPhase = 0;
this.flutterPhase = 0;
}
protected override resetState(): void {
this.setupState(this.config!, this.chainContext!);
}
protected processBlock(
_input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
const out = output[0]!;
const blockSize = out.length;
const sr = ctx.sampleRate;
const cfg = this.microphoneConfig;
const masterGain = this.parameterValue("outputGain");
// Schema clamps cutoffHz to [20, 20_000] and audio-tier sample
// rates are always ≥ 44_100 (Nyquist 22_050 > max cutoff), so the
// bilinear transform input stays in the stable region without an
// additional runtime clamp.
const alpha = onePoleAlpha(cfg.frequency.cutoffHz, sr);
const noiseLevel = cfg.noise.level;
const noiseIsPink = cfg.noise.color === "pink";
const driveLin = 10 ** (cfg.gain.inputDb / 20);
const outputLin = 10 ** (cfg.gain.outputDb / 20);
const wowInc = (TWO_PI * cfg.wowFlutter.wowRateHz) / sr;
const flutterInc = (TWO_PI * cfg.wowFlutter.flutterRateHz) / sr;
const wowDepth = cfg.wowFlutter.wowDepth * 0.1;
const flutterDepth = cfg.wowFlutter.flutterDepth * 0.05;
const distMix = cfg.distortion.mix;
const distDrive = 1 + cfg.distortion.drive * 8;
const warmth = cfg.character.warmth;
const presence = cfg.character.presence;
const tiltLin = 10 ** (cfg.frequency.tiltDb / 20);
for (let i = 0; i < blockSize; i++) {
const noise = this.nextNoise(noiseIsPink) * noiseLevel;
const driven = noise * driveLin;
this.lpY = this.lpY + alpha * (driven - this.lpY);
let shaped = this.lpY * tiltLin;
if (distMix > 0) {
const sat = Math.tanh(shaped * distDrive);
shaped = shaped * (1 - distMix) + sat * distMix;
}
const warmed =
shaped * (1 - warmth) + Math.tanh(shaped * 1.5) * warmth;
const present =
warmed * (1 - presence * 0.3) +
(warmed - this.lpY * 0.5) * (presence * 0.3);
const wow = 1 + Math.sin(this.wowPhase + wowInc * i) * wowDepth;
const flutter =
1 + Math.sin(this.flutterPhase + flutterInc * i) * flutterDepth;
out[i] = present * outputLin * masterGain * wow * flutter;
}
this.wowPhase = (this.wowPhase + wowInc * blockSize) % TWO_PI;
this.flutterPhase =
(this.flutterPhase + flutterInc * blockSize) % TWO_PI;
}
protected override getPresetData(): MicrophonePresetData {
return { config: this.microphoneConfig };
}
protected override applyPresetData(data: unknown): void {
const { config } = data as MicrophonePresetData;
this.microphoneConfig = config;
}
private nextNoise(pink: boolean): number {
// xorshift32 RNG → [-1, 1] white noise.
let x = this.rngState;
x ^= x << 13;
x ^= x >>> 17;
x ^= x << 5;
this.rngState = x >>> 0;
const white = (this.rngState / 0xffffffff) * 2 - 1;
if (!pink) return white;
// Paul Kellet's three-pole pink-noise approximation.
this.pinkB0 = 0.99765 * this.pinkB0 + white * 0.099046;
this.pinkB1 = 0.963 * this.pinkB1 + white * 0.2965164;
this.pinkB2 = 0.57 * this.pinkB2 + white * 1.0526913;
return (
(this.pinkB0 + this.pinkB1 + this.pinkB2 + white * 0.1848) * 0.25
);
}
}
export function createMicrophonePlugin(
options: MicrophoneSimulationPluginOptions = {}
): MicrophoneSimulationPlugin {
return new MicrophoneSimulationPlugin(options);
}
function mergeConfig(
base: MicrophoneConfig,
overlay: Partial<MicrophoneConfig>
): MicrophoneConfig {
return {
frequency: { ...base.frequency, ...(overlay.frequency ?? {}) },
resonance: { ...base.resonance, ...(overlay.resonance ?? {}) },
distortion: { ...base.distortion, ...(overlay.distortion ?? {}) },
noise: { ...base.noise, ...(overlay.noise ?? {}) },
dynamics: { ...base.dynamics, ...(overlay.dynamics ?? {}) },
gain: { ...base.gain, ...(overlay.gain ?? {}) },
character: { ...base.character, ...(overlay.character ?? {}) },
wowFlutter: { ...base.wowFlutter, ...(overlay.wowFlutter ?? {}) },
};
}
function section(
id: keyof MicrophoneConfig,
label: string,
controls: ReadonlyArray<MicrophoneUIControl>
): MicrophoneUISection {
return { id, label, controls };
}
function ctrl(
id: string,
label: string,
min: number,
max: number,
step: number,
unit: string,
group: keyof MicrophoneConfig
): MicrophoneUIControl {
return { id, label, min, max, step, unit, group };
}
function onePoleAlpha(cutoffHz: number, sr: number): number {
const omega = (TWO_PI * cutoffHz) / sr;
return omega / (omega + 1);
}

View File

@@ -0,0 +1,13 @@
export {
CharacterControlsSchema,
DistortionControlsSchema,
DynamicsControlsSchema,
FrequencyControlsSchema,
GainControlsSchema,
MicrophoneConfigSchema,
MicrophonePresetDataSchema,
MicrophoneSimulationPluginOptionsSchema,
NoiseControlsSchema,
ResonanceControlsSchema,
WowAndFlutterControlsSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,68 @@
# `microphone/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options, the eight control groups, the full configuration tree, and the preset payload of `MicrophoneSimulationPlugin`. These run on the public boundary so any malformed object 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
- Eight per-section schemas: `FrequencyControlsSchema`, `ResonanceControlsSchema`, `DistortionControlsSchema`, `NoiseControlsSchema`, `DynamicsControlsSchema`, `GainControlsSchema`, `CharacterControlsSchema`, `WowAndFlutterControlsSchema`. Each enforces its documented range with `z.number().finite()` to block `NaN`/`Infinity`.
- `MicrophoneConfigSchema` composes the eight sections into one object.
- `MicrophoneSimulationPluginOptionsSchema` wraps `MicrophoneConfigSchema.partial()` so callers can pass any subset of fields.
- `MicrophonePresetDataSchema` is `.strict()` and demands the full `MicrophoneConfig` payload.
## Usage: Basic Example
```ts
import { MicrophoneConfigSchema } from "@suaveplan/audio-fx-plugin-microphone";
const parsed = MicrophoneConfigSchema.partial().parse({
noise: { level: 0.05, color: "white" },
});
```
## Usage: Advanced Example
```ts
import {
MicrophoneConfigSchema,
NoiseControlsSchema,
} from "@suaveplan/audio-fx-plugin-microphone";
// Validate a section in isolation (useful for an RPC that updates only one
// part of the mic state).
function setNoise(plugin: MicrophoneSimulationPlugin, raw: unknown) {
const noise = NoiseControlsSchema.parse(raw);
plugin.setMicrophoneConfig({ noise });
}
const partial = MicrophoneConfigSchema.partial().parse({
frequency: { cutoffHz: 4_000, tiltDb: 0 },
});
```
## API Reference
- `FrequencyControlsSchema: z.ZodObject<{ cutoffHz, tiltDb }>`
- `ResonanceControlsSchema: z.ZodObject<{ q }>`
- `DistortionControlsSchema: z.ZodObject<{ drive, mix }>`
- `NoiseControlsSchema: z.ZodObject<{ level, color }>`
- `DynamicsControlsSchema: z.ZodObject<{ thresholdDb, ratio }>`
- `GainControlsSchema: z.ZodObject<{ inputDb, outputDb }>`
- `CharacterControlsSchema: z.ZodObject<{ warmth, presence }>`
- `WowAndFlutterControlsSchema: z.ZodObject<{ wowRateHz, wowDepth, flutterRateHz, flutterDepth }>`
- `MicrophoneConfigSchema: z.ZodObject<…>`
- `MicrophoneSimulationPluginOptionsSchema: z.ZodObject<{ config? }>`
- `MicrophonePresetDataSchema: z.ZodObject<{ config }, "strict">`
## Implementation Notes
The per-section schemas are exported as named values rather than inlined into `MicrophoneConfigSchema` so they can be reused by callers that want to validate a single section at a time — for example, an RPC endpoint that updates only the noise floor. The combined `MicrophoneConfigSchema` is built by composing the section schemas, so adding a field to a section is a single-place change.
`MicrophonePresetDataSchema` demands the full `MicrophoneConfig` (not a partial). Presets restore the entire microphone state in one shot; allowing a partial would leave the restored plugin in an indeterminate state where some sections came from the preset and others from whatever happened to be loaded. The `.strict()` modifier additionally rejects unknown fields so a forwards-incompatible preset payload is caught at the boundary rather than silently dropping unknown keys.

View File

@@ -0,0 +1,61 @@
import z from "zod";
export const FrequencyControlsSchema = z.object({
cutoffHz: z.number().finite().min(20).max(20_000),
tiltDb: z.number().finite().min(-24).max(24),
});
export const ResonanceControlsSchema = z.object({
q: z.number().finite().min(0.1).max(20),
});
export const DistortionControlsSchema = z.object({
drive: z.number().finite().min(0).max(1),
mix: z.number().finite().min(0).max(1),
});
export const NoiseControlsSchema = z.object({
level: z.number().finite().min(0).max(1),
color: z.enum(["white", "pink"]),
});
export const DynamicsControlsSchema = z.object({
thresholdDb: z.number().finite().min(-60).max(0),
ratio: z.number().finite().min(1).max(20),
});
export const GainControlsSchema = z.object({
inputDb: z.number().finite().min(-24).max(24),
outputDb: z.number().finite().min(-24).max(24),
});
export const CharacterControlsSchema = z.object({
warmth: z.number().finite().min(0).max(1),
presence: z.number().finite().min(0).max(1),
});
export const WowAndFlutterControlsSchema = z.object({
wowRateHz: z.number().finite().min(0).max(10),
wowDepth: z.number().finite().min(0).max(1),
flutterRateHz: z.number().finite().min(0).max(50),
flutterDepth: z.number().finite().min(0).max(1),
});
export const MicrophoneConfigSchema = z.object({
frequency: FrequencyControlsSchema,
resonance: ResonanceControlsSchema,
distortion: DistortionControlsSchema,
noise: NoiseControlsSchema,
dynamics: DynamicsControlsSchema,
gain: GainControlsSchema,
character: CharacterControlsSchema,
wowFlutter: WowAndFlutterControlsSchema,
});
export const MicrophoneSimulationPluginOptionsSchema = z.object({
config: MicrophoneConfigSchema.partial().optional(),
});
export const MicrophonePresetDataSchema = z
.object({ config: MicrophoneConfigSchema })
.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-microphone",
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 });