Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-microphone/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-microphone/README.md
Normal 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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createMicrophonePlugin,
|
||||
MicrophoneSimulationPlugin,
|
||||
} from "./microphone.js";
|
||||
@@ -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`).
|
||||
@@ -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]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
CharacterControlsSchema,
|
||||
DistortionControlsSchema,
|
||||
DynamicsControlsSchema,
|
||||
FrequencyControlsSchema,
|
||||
GainControlsSchema,
|
||||
MicrophoneConfigSchema,
|
||||
MicrophonePresetDataSchema,
|
||||
MicrophoneSimulationPluginOptionsSchema,
|
||||
NoiseControlsSchema,
|
||||
ResonanceControlsSchema,
|
||||
WowAndFlutterControlsSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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-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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user