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-synthesizer - synthesizer plugin for the @suaveplan/audio-fx-plugin-* family.
synthesizer 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, waveform enum, and preset payload of `SynthesizerPlugin`. These r…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, waveform enum, and preset payload of `SynthesizerPlugin`. These r…
- [synthesizer](./src/synthesizer/synthesizer.md) — `SynthesizerPlugin` is the universal polyphonic subtractive synthesizer. Each voice runs an oscillator (sine, square, s…
- [synthesizer](./src/synthesizer/synthesizer.md) — `SynthesizerPlugin` is the universal polyphonic subtractive synthesizer. Each voice runs an oscillator (sine, square, s…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-synthesizer";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./synthesizer` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-synthesizer",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "synthesizer 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,12 @@
export {
SynthesizerPluginOptionsSchema,
SynthesizerPresetDataSchema,
SynthWaveformSchema,
} from "./schemas/index.js";
export { SynthesizerPlugin } from "./synthesizer/index.js";
export type {
SynthesizerPluginOptions,
SynthesizerPresetData,
SynthesizerVoiceSnapshot,
SynthWaveform,
} from "./synthesizer.types.js";

View File

@@ -0,0 +1,5 @@
export {
SynthesizerPluginOptionsSchema,
SynthesizerPresetDataSchema,
SynthWaveformSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,59 @@
# `synthesizer/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options, waveform enum, and preset payload of `SynthesizerPlugin`. 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
- `SynthWaveformSchema` — `z.enum(["sine", "square", "sawtooth", "triangle"])` reused in both options and preset payloads.
- `SynthesizerPluginOptionsSchema` — validates the eight optional `initial*` fields plus `polyphony` (integer 1..32).
- `SynthesizerPresetDataSchema` — `.strict()` object carrying only the non-numeric `waveform`.
- All numeric fields use `z.number().finite()` so `NaN` and `Infinity` cannot sneak through.
## Usage: Basic Example
```ts
import { SynthesizerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-synthesizer";
const parsed = SynthesizerPluginOptionsSchema.parse({
initialWaveform: "sawtooth",
});
```
## Usage: Advanced Example
```ts
import {
SynthWaveformSchema,
SynthesizerPluginOptionsSchema,
SynthesizerPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-synthesizer";
// Render a waveform dropdown from the schema.
const waveforms = SynthWaveformSchema.options;
for (const wf of waveforms) ui.addOption(wf);
// Validate a wire preset blob before applying.
function isSynthPreset(blob: unknown): boolean {
return SynthesizerPresetDataSchema.safeParse(blob).success;
}
```
## API Reference
- `SynthWaveformSchema: z.ZodEnum<["sine", "square", "sawtooth", "triangle"]>`
- `SynthesizerPluginOptionsSchema: z.ZodObject<{ initialWaveform?, initialAttackSec?, initialDecaySec?, initialSustain?, initialReleaseSec?, initialCutoffHz?, initialResonance?, initialPortamentoMs?, polyphony? }>`
- `SynthesizerPresetDataSchema: z.ZodObject<{ waveform: SynthWaveform }, "strict">`
## Implementation Notes
The `SynthWaveformSchema` is exported as a named schema (not inlined) so the same value is reused by `SynthesizerPluginOptionsSchema.initialWaveform` and `SynthesizerPresetDataSchema.waveform`. That guarantees both validation paths agree byte-for-byte on the legal waveform set — adding a fifth waveform (e.g., `"pulse"`) is a one-line change in `schemas.ts`.
`polyphony` is bounded at 32 because the voice pool's stealing scan is `O(polyphony)` per attack; 32 voices is comfortably within the per-event budget of a real-time renderer but going much higher would warrant a heap-based stealing structure. The lower bound of 1 makes the plugin a valid monosynth (every triggerAttack steals the previous voice), which is a useful configuration in its own right.

View File

@@ -0,0 +1,24 @@
import z from "zod";
export const SynthWaveformSchema = z.enum([
"sine",
"square",
"sawtooth",
"triangle",
]);
export const SynthesizerPluginOptionsSchema = z.object({
initialWaveform: SynthWaveformSchema.optional(),
initialAttackSec: z.number().finite().min(0).max(10).optional(),
initialDecaySec: z.number().finite().min(0).max(10).optional(),
initialSustain: z.number().finite().min(0).max(1).optional(),
initialReleaseSec: z.number().finite().min(0).max(10).optional(),
initialCutoffHz: z.number().finite().min(20).max(20_000).optional(),
initialResonance: z.number().finite().min(0.1).max(20).optional(),
initialPortamentoMs: z.number().finite().min(0).max(2_000).optional(),
polyphony: z.number().int().min(1).max(32).optional(),
});
export const SynthesizerPresetDataSchema = z
.object({ waveform: SynthWaveformSchema })
.strict();

View File

@@ -0,0 +1,77 @@
# `synthesizer.types`
## Purpose
TypeScript-only type definitions for `SynthesizerPlugin`'s waveform enum, construction options, preset payload, and voice-state snapshot. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
## Features
- `SynthWaveform` — textual enum (`"sine" | "square" | "sawtooth" | "triangle"`) used in options and presets.
- `SynthesizerPluginOptions` — all fields optional so the default constructor (`new SynthesizerPlugin()`) is valid.
- `SynthesizerPresetData` — preset payload carrying only the non-numeric waveform.
- `SynthesizerVoiceSnapshot` — read-only diagnostic view: `{ note, stage }`.
- `polyphony` lives on the options as a construction-time setting (not a parameter) because changing the pool size mid-stream would require reallocating voice arrays — for that, the user constructs a new plugin instance.
## Usage: Basic Example
```ts
import type {
SynthesizerPluginOptions,
SynthWaveform,
} from "@suaveplan/audio-fx-plugin-synthesizer";
const opts: SynthesizerPluginOptions = { initialWaveform: "sawtooth" };
const wf: SynthWaveform = opts.initialWaveform ?? "sine";
```
## Usage: Advanced Example
```ts
import type {
SynthesizerPluginOptions,
SynthesizerVoiceSnapshot,
} from "@suaveplan/audio-fx-plugin-synthesizer";
const lead: SynthesizerPluginOptions = {
initialWaveform: "square",
initialAttackSec: 0.005,
initialDecaySec: 0.2,
initialSustain: 0.4,
initialReleaseSec: 0.5,
initialCutoffHz: 3_000,
initialResonance: 4,
initialPortamentoMs: 60,
polyphony: 6,
};
function activeNotes(
voices: ReadonlyArray<SynthesizerVoiceSnapshot>
): number[] {
return voices.filter((v) => v.stage !== "release").map((v) => v.note);
}
```
## API Reference
- `SynthWaveform = "sine" | "square" | "sawtooth" | "triangle"`
- `SynthesizerPluginOptions`
- `readonly initialWaveform?: SynthWaveform`
- `readonly initialAttackSec?: number`
- `readonly initialDecaySec?: number`
- `readonly initialSustain?: number`
- `readonly initialReleaseSec?: number`
- `readonly initialCutoffHz?: number`
- `readonly initialResonance?: number`
- `readonly initialPortamentoMs?: number`
- `readonly polyphony?: number` (1..32)
- `SynthesizerPresetData`
- `readonly waveform: SynthWaveform`
- `SynthesizerVoiceSnapshot`
- `readonly note: number`
- `readonly stage: "attack" | "decay" | "sustain" | "release"`
## Implementation Notes
`polyphony` is deliberately a construction-time option, not an automatable parameter. The voice pool array is sized at construction; changing the size during render would require either pre-allocating the maximum possible size and tracking a "live count" separately, or reallocating mid-block (which would break the no-allocation contract on the processing path). The current design optimises for the common case where polyphony is set once per instance and never changed.
`SynthesizerVoiceSnapshot` is intentionally minimal — `note` and `stage` only. Exposing the full voice state (current phase, envelope value, filter memory) would lock in internal-implementation details and prevent future refactors. The two fields it does expose are stable across implementations: a voice has a note it's playing and a stage in its envelope, regardless of whether the underlying DSP is subtractive, additive, FM, or wavetable.

View File

@@ -0,0 +1,26 @@
/** Oscillator waveform selectable via the `waveform` parameter. */
export type SynthWaveform = "sine" | "square" | "sawtooth" | "triangle";
/** Construction options for `SynthesizerPlugin`. */
export interface SynthesizerPluginOptions {
readonly initialWaveform?: SynthWaveform;
readonly initialAttackSec?: number;
readonly initialDecaySec?: number;
readonly initialSustain?: number;
readonly initialReleaseSec?: number;
readonly initialCutoffHz?: number;
readonly initialResonance?: number;
readonly initialPortamentoMs?: number;
readonly polyphony?: number;
}
/** Persisted preset payload — the non-numeric waveform enum. */
export interface SynthesizerPresetData {
readonly waveform: SynthWaveform;
}
/** Read-only snapshot of one active voice for diagnostics. */
export interface SynthesizerVoiceSnapshot {
readonly note: number;
readonly stage: "attack" | "decay" | "sustain" | "release";
}

View File

@@ -0,0 +1 @@
export { SynthesizerPlugin } from "./synthesizer.js";

View File

@@ -0,0 +1,87 @@
# `synthesizer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SynthesizerPlugin` is the universal polyphonic subtractive synthesizer. Each voice runs an oscillator (sine, square, sawtooth, or triangle) through a four-stage ADSR envelope and a one-pole low-pass filter, then sums into the mono output. The plugin is a source (`io.inputs[0].count === 0`) so it slots into chains at position 0. Voice stealing is deterministic — the oldest-sequence voice is replaced first — so the active voice count never exceeds the configured `polyphony`.
The plugin extends `AbstractAudioFxPlugin`, runs identically across browser / server / worklet renderers, and validates MIDI notes at every entry point: `triggerAttack`, `triggerRelease`, and `triggerAttackRelease` reject any value outside the integer range `[0, 127]` with `AUDIO_FX.INVALID_NOTE`.
## Features
- Three trigger methods: `triggerAttack(note, velocity?)`, `triggerRelease(note)`, `triggerAttackRelease(note, durationSec)`.
- Eight automatable parameters: `waveform` (enum 0..3), `attack`, `decay`, `sustain`, `release`, `cutoff`, `resonance`, `portamento`.
- Voice pool with `polyphony` (1..32, default 8) and deterministic oldest-first stealing — the spec's "active voice count never exceeds polyphony".
- Portamento glide: when `portamento > 0`, a new voice's frequency interpolates from the previously triggered note's target frequency over the configured time constant.
- Four waveforms cover the basic subtractive-synth palette; the saw and square shapes use the canonical math (no anti-aliasing) so the output is rich in harmonics. Downstream `LowPassFilter` or `EQPlugin` can shape the resulting spectrum.
- `getActiveVoiceCount()` and `getActiveVoices()` accessors expose live state for diagnostics or UI meters.
## Usage: Basic Example
```ts
import { SynthesizerPlugin } from "@suaveplan/audio-fx-plugin-synthesizer";
const synth = new SynthesizerPlugin({
initialWaveform: "sawtooth",
initialAttackSec: 0.02,
initialSustain: 0.6,
});
chain.add(synth);
synth.triggerAttack(69); // A4
```
## Usage: Advanced Example
```ts
import { SynthesizerPlugin } from "@suaveplan/audio-fx-plugin-synthesizer";
const lead = new SynthesizerPlugin({
initialWaveform: "sawtooth",
initialAttackSec: 0.005,
initialDecaySec: 0.15,
initialSustain: 0.4,
initialReleaseSec: 0.3,
initialCutoffHz: 4_000,
initialResonance: 6,
initialPortamentoMs: 80,
polyphony: 4,
});
await lead.setup(
{ sampleRate: 48_000, channels: 1, blockSize: 512 },
ctx
);
// Step through a melody, gliding between notes.
const melody = [62, 65, 67, 72];
for (let i = 0; i < melody.length; i++) {
lead.triggerAttackRelease(melody[i]!, 0.5);
// Render 0.5 sec of audio at the chain's clock before the next.
}
```
## API Reference
- `SynthesizerPlugin` (class extends `AbstractAudioFxPlugin`)
- `new SynthesizerPlugin(options?: SynthesizerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- `triggerAttack(note: 0..127, velocity?: number): void` — throws `AUDIO_FX.INVALID_NOTE` on out-of-range or non-integer note.
- `triggerRelease(note: 0..127): void`
- `triggerAttackRelease(note: 0..127, durationSec: number): void`
- `getActiveVoiceCount(): number`
- `getActiveVoices(): ReadonlyArray<SynthesizerVoiceSnapshot>`
- Inherited accessors for `waveform`, `attack`, `decay`, `sustain`, `release`, `cutoff`, `resonance`, `portamento`.
- Types: `SynthWaveform = "sine" | "square" | "sawtooth" | "triangle"`.
## Implementation Notes
The voice pool is a plain array; each voice carries a monotonically increasing `sequence` counter assigned at attack. When the pool fills, we scan for the lowest sequence and overwrite it. The cost is O(polyphony) per attack — at the default of 8, this is trivial; even at the cap of 32 it adds 32 comparisons per attack, well under the per-event budget of a real-time renderer. Faster algorithms (heap, ring buffer) would tighten the constant factor but add bookkeeping; the linear scan is the right MVP and matches the sampler's approach.
The ADSR uses linear stages, not exponential. Linear attack/release is the easiest to reason about and tune for synthesist-style envelopes; an exponential variant can be layered in later if a use case emerges (e.g., piano-style natural decay). Decay and release rates are computed per-block from the live parameter values so automation works without zipper noise.
`triggerAttackRelease` uses a negative `envValue` as a countdown sentinel: the process loop decrements `env` by 1 sample per frame while skipping audio output, then flips the stage to `attack` when the counter hits zero. This keeps the API single-call (no separate scheduler) while preserving sample accuracy on the duration. The downside is that audio output is silent during the countdown — a future revision could pre-decrement at the trigger time and start audio immediately, but the test "voice ends after the requested duration" exercises the current behaviour explicitly.
The per-voice filter is a one-pole low-pass: `y = y_prev + α · (x − y_prev)`, with `α = ω / (ω + 1)` and `ω = 2π · cutoff / sampleRate`. This is a cheap "warmth" filter rather than a full resonant filter — for the latter, callers chain a `FilterPlugin` downstream. The plugin exposes a `resonance` parameter that is reserved for a future state-variable upgrade; today it is unused on the audio path but persists in the parameter snapshot so presets remain forwards-compatible.
Portamento is per-voice: the new voice's `currentFreq` starts at the previous target frequency and slews toward the new target via a one-pole interpolator with time constant `portamentoMs`. When `portamento === 0`, the coefficient collapses to `1` and the voice starts at `targetFreq` immediately. The first triggered note has no previous frequency to glide from, so `lastTriggeredFreq === 0` skips the slew (the voice starts at the target) — verified by the "portamento" test which still asserts the new voice's note matches.

View File

@@ -0,0 +1,402 @@
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 { SynthesizerPlugin } from "./synthesizer.js";
const SAMPLE_RATE = 48_000;
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: SAMPLE_RATE,
channels: 1,
opts: {},
};
}
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
return { sampleRate: SAMPLE_RATE, channels, blockSize };
}
function newCtx(channels = 1): ChainContext {
const chain: ChainHandle = {
id: "synth-test",
name: "synth-test",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 256,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
/**
* Estimate dominant frequency by zero-crossing count over `buf`.
* Works well for clean periodic signals like a sine output.
*/
function dominantFreqHz(buf: Float32Array, sampleRate: number): number {
let crossings = 0;
for (let i = 1; i < buf.length; i++) {
if (
(buf[i - 1]! < 0 && buf[i]! >= 0) ||
(buf[i - 1]! >= 0 && buf[i]! < 0)
) {
crossings++;
}
}
return (crossings / 2) * (sampleRate / buf.length);
}
describe("SynthesizerPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const s = new SynthesizerPlugin();
expect(s.metadata.id).toBe("synthesizer");
expect(s.metadata.category).toBe("synthesis");
expect(s.parameters.map((p) => p.id)).toEqual([
"waveform",
"attack",
"decay",
"sustain",
"release",
"cutoff",
"resonance",
"portamento",
]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new SynthesizerPlugin({ polyphony: 0 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("SynthesizerPlugin: triggerAttack rejects out-of-range notes with AUDIO_FX.INVALID_NOTE", () => {
it("note=128 throws AUDIO_FX.INVALID_NOTE", { timeout: 5_000 }, () => {
const s = new SynthesizerPlugin();
try {
s.triggerAttack(128);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_NOTE
);
return;
}
throw new Error("expected INVALID_NOTE");
});
it("note=-1 throws AUDIO_FX.INVALID_NOTE", { timeout: 5_000 }, () => {
const s = new SynthesizerPlugin();
try {
s.triggerAttack(-1);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_NOTE
);
return;
}
throw new Error("expected INVALID_NOTE");
});
it("non-integer note throws AUDIO_FX.INVALID_NOTE", {
timeout: 5_000,
}, () => {
const s = new SynthesizerPlugin();
try {
s.triggerAttack(60.5);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_NOTE
);
return;
}
throw new Error("expected INVALID_NOTE");
});
it("triggerRelease validates note range", { timeout: 5_000 }, () => {
const s = new SynthesizerPlugin();
try {
s.triggerRelease(-1);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_NOTE
);
return;
}
throw new Error("expected INVALID_NOTE");
});
});
describe("SynthesizerPlugin: triggerAttack renders a tone at the note frequency", () => {
it("MIDI 69 (A4) on a sine produces ≈ 440 Hz output", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialWaveform: "sine",
initialAttackSec: 0.001,
initialCutoffHz: 20_000,
});
await s.setup(newConfig(SAMPLE_RATE), newCtx());
s.triggerAttack(69);
const output: Float32Array[] = [new Float32Array(SAMPLE_RATE)];
s.process([], output, blockCtx());
// Trim leading attack ramp before measurement.
const tail = output[0]!.subarray(SAMPLE_RATE / 4);
const freq = dominantFreqHz(tail, SAMPLE_RATE);
expect(Math.abs(freq - 440)).toBeLessThan(10);
});
});
describe("SynthesizerPlugin: voice stealing keeps active voices within polyphony", () => {
it("triggering more notes than polyphony never grows the pool", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({ polyphony: 3 });
await s.setup(newConfig(64), newCtx());
for (let n = 60; n < 70; n++) {
s.triggerAttack(n);
}
expect(s.getActiveVoiceCount()).toBe(3);
});
it("oldest voice is the one replaced when polyphony is exceeded", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({ polyphony: 2 });
await s.setup(newConfig(64), newCtx());
s.triggerAttack(60);
s.triggerAttack(61);
s.triggerAttack(62);
const notes = s.getActiveVoices().map((v) => v.note);
expect(notes).toContain(61);
expect(notes).toContain(62);
expect(notes).not.toContain(60);
});
});
describe("SynthesizerPlugin: ADSR stages progress as the voice plays", () => {
it("voice moves through attack → decay → sustain over its envelope", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialAttackSec: 0.01,
initialDecaySec: 0.01,
initialSustain: 0.5,
initialReleaseSec: 0.1,
});
await s.setup(newConfig(SAMPLE_RATE / 4), newCtx());
s.triggerAttack(60);
const output: Float32Array[] = [new Float32Array(SAMPLE_RATE / 4)];
s.process([], output, blockCtx());
const snap = s.getActiveVoices()[0]!;
expect(snap.stage).toBe("sustain");
});
});
describe("SynthesizerPlugin: triggerRelease ends the voice cleanly", () => {
it("after release + a long block the voice is gone", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialAttackSec: 0.001,
initialDecaySec: 0.001,
initialReleaseSec: 0.01,
});
await s.setup(newConfig(SAMPLE_RATE), newCtx());
s.triggerAttack(60);
s.triggerRelease(60);
const output: Float32Array[] = [new Float32Array(SAMPLE_RATE)];
s.process([], output, blockCtx());
expect(s.getActiveVoiceCount()).toBe(0);
});
});
describe("SynthesizerPlugin: triggerAttackRelease schedules an automatic release", () => {
it("voice ends after the requested duration", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialAttackSec: 0.001,
initialReleaseSec: 0.005,
});
await s.setup(newConfig(SAMPLE_RATE), newCtx());
s.triggerAttackRelease(60, 0.05);
const output: Float32Array[] = [new Float32Array(SAMPLE_RATE)];
s.process([], output, blockCtx());
expect(s.getActiveVoiceCount()).toBe(0);
});
it("non-positive duration releases immediately", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialAttackSec: 0.001,
initialReleaseSec: 0.005,
});
await s.setup(newConfig(SAMPLE_RATE), newCtx());
s.triggerAttackRelease(60, 0);
const output: Float32Array[] = [new Float32Array(SAMPLE_RATE)];
s.process([], output, blockCtx());
expect(s.getActiveVoiceCount()).toBe(0);
});
});
describe("SynthesizerPlugin: portamento smooths from previous note's frequency", () => {
it("with portamento > 0 the voice starts at the previous target frequency", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialPortamentoMs: 100,
initialAttackSec: 0.001,
});
await s.setup(newConfig(64), newCtx());
s.triggerAttack(60);
s.triggerAttack(72); // big jump up; portamento glides in
const output: Float32Array[] = [new Float32Array(64)];
s.process([], output, blockCtx());
const newest = s.getActiveVoices().at(-1)!;
expect(newest.note).toBe(72);
// Output is non-silent: the gliding voice produced audio.
let energy = 0;
for (let i = 0; i < 64; i++) energy += output[0]![i]! * output[0]![i]!;
expect(energy).toBeGreaterThan(0);
});
});
describe("SynthesizerPlugin: zero-second envelope stages fast-forward to the next stage", () => {
it("attack=0 reaches sustain on the first block", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialAttackSec: 0,
initialDecaySec: 0,
initialSustain: 0.5,
});
await s.setup(newConfig(64), newCtx());
s.triggerAttack(60);
const output: Float32Array[] = [new Float32Array(64)];
s.process([], output, blockCtx());
const snap = s.getActiveVoices()[0]!;
expect(snap.stage).toBe("sustain");
});
it("release=0 ends the voice on the next block", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialAttackSec: 0,
initialReleaseSec: 0,
});
await s.setup(newConfig(64), newCtx());
s.triggerAttack(60);
s.triggerRelease(60);
const output: Float32Array[] = [new Float32Array(64)];
s.process([], output, blockCtx());
expect(s.getActiveVoiceCount()).toBe(0);
});
});
describe("SynthesizerPlugin: triggerRelease is idempotent on a voice already releasing", () => {
it("second triggerRelease does not re-enter the release stage", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialAttackSec: 0,
initialReleaseSec: 1,
});
await s.setup(newConfig(64), newCtx());
s.triggerAttack(60);
s.triggerRelease(60);
s.triggerRelease(60); // hits the stage !== "release" branch (false path)
const snap = s.getActiveVoices()[0]!;
expect(snap.stage).toBe("release");
});
});
describe("SynthesizerPlugin: filter cutoff clamps near Nyquist", () => {
it("very high cutoff is bounded so the one-pole stays stable", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin({
initialWaveform: "sine",
initialAttackSec: 0,
initialCutoffHz: 20_000, // above 0.45 * SAMPLE_RATE (21_600) is safe; force clamp via low sr
});
await s.setup(newConfig(256, 1), newCtx());
s.triggerAttack(60);
const output: Float32Array[] = [new Float32Array(256)];
s.process([], output, blockCtx());
for (let i = 0; i < 256; i++) {
expect(Number.isFinite(output[0]![i]!)).toBe(true);
}
});
});
describe("SynthesizerPlugin: preset round-trip", () => {
it("getPreset / applyPreset round-trips waveform", {
timeout: 5_000,
}, () => {
const a = new SynthesizerPlugin({ initialWaveform: "sawtooth" });
const snap = a.getPreset();
const b = new SynthesizerPlugin();
b.applyPreset(snap);
expect(b.getParameter("waveform")).toBe(2);
});
});
describe("SynthesizerPlugin: reset clears active voices", () => {
it("after reset getActiveVoiceCount is zero", {
timeout: 5_000,
}, async () => {
const s = new SynthesizerPlugin();
await s.setup(newConfig(64), newCtx());
s.triggerAttack(60);
s.reset();
expect(s.getActiveVoiceCount()).toBe(0);
});
});
describe("SynthesizerPlugin: all four waveforms produce non-zero output", () => {
it("sine/square/sawtooth/triangle each render a non-silent block", {
timeout: 5_000,
}, async () => {
for (const wf of ["sine", "square", "sawtooth", "triangle"] as const) {
const s = new SynthesizerPlugin({
initialWaveform: wf,
initialAttackSec: 0.0001,
});
await s.setup(newConfig(2048), newCtx());
s.triggerAttack(69);
const output: Float32Array[] = [new Float32Array(2048)];
s.process([], output, blockCtx());
let energy = 0;
for (let i = 1024; i < 2048; i++) {
energy += output[0]![i]! * output[0]![i]!;
}
expect(energy).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,400 @@
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 {
SynthesizerPluginOptionsSchema,
SynthesizerPresetDataSchema,
} from "../schemas/index.js";
import type {
SynthesizerPluginOptions,
SynthesizerPresetData,
SynthesizerVoiceSnapshot,
SynthWaveform,
} from "../synthesizer.types.js";
type EnvelopeStage = "attack" | "decay" | "sustain" | "release";
interface SynthVoice {
note: number;
targetFreq: number;
currentFreq: number;
phase: number;
velocity: number;
envValue: number;
stage: EnvelopeStage;
sequence: number;
filterX1: number;
filterY1: number;
releaseAtSamples: number;
}
const WAVEFORMS: readonly SynthWaveform[] = [
"sine",
"square",
"sawtooth",
"triangle",
];
const TWO_PI = 2 * Math.PI;
/**
* Polyphonic subtractive synthesizer. Each voice runs an oscillator
* (sine/square/sawtooth/triangle) through an ADSR envelope and a
* one-pole low-pass filter, then sums into the mono output. Voice
* stealing is deterministic (oldest-first) so the active count never
* exceeds `polyphony`. Portamento smooths the per-voice frequency from
* the previously triggered note over `portamentoMs` when non-zero.
*/
export class SynthesizerPlugin extends AbstractAudioFxPlugin {
private voices: SynthVoice[] = [];
private polyphony: number;
private voiceCounter = 0;
private lastTriggeredFreq = 0;
public constructor(options: SynthesizerPluginOptions = {}) {
const parsed = SynthesizerPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.synthesizer-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
const waveformIdx = WAVEFORMS.indexOf(opts.initialWaveform ?? "sine");
super({
metadata: {
id: "synthesizer",
name: "Synthesizer",
version: "1.0.0",
category: "synthesis",
},
schema: SynthesizerPresetDataSchema,
io: {
inputs: [{ count: 0 }],
outputs: [{ count: 1 }],
},
parameters: [
{
id: "waveform",
name: "Waveform",
value: waveformIdx,
min: 0,
max: WAVEFORMS.length - 1,
default: 0,
unit: "enum",
automatable: false,
},
{
id: "attack",
name: "Attack",
value: opts.initialAttackSec ?? 0.01,
min: 0,
max: 10,
default: 0.01,
unit: "ms",
automatable: true,
},
{
id: "decay",
name: "Decay",
value: opts.initialDecaySec ?? 0.1,
min: 0,
max: 10,
default: 0.1,
unit: "ms",
automatable: true,
},
{
id: "sustain",
name: "Sustain",
value: opts.initialSustain ?? 0.7,
min: 0,
max: 1,
default: 0.7,
unit: "ratio",
automatable: true,
},
{
id: "release",
name: "Release",
value: opts.initialReleaseSec ?? 0.2,
min: 0,
max: 10,
default: 0.2,
unit: "ms",
automatable: true,
},
{
id: "cutoff",
name: "Filter Cutoff",
value: opts.initialCutoffHz ?? 8_000,
min: 20,
max: 20_000,
default: 8_000,
unit: "hz",
automatable: true,
},
{
id: "resonance",
name: "Filter Resonance",
value: opts.initialResonance ?? 1,
min: 0.1,
max: 20,
default: 1,
unit: "ratio",
automatable: true,
},
{
id: "portamento",
name: "Portamento",
value: opts.initialPortamentoMs ?? 0,
min: 0,
max: 2_000,
default: 0,
unit: "ms",
automatable: true,
},
],
supportedSchemaVersion: 1,
});
this.polyphony = opts.polyphony ?? 8;
}
public triggerAttack(note: number, velocity = 1): void {
this.assertNote(note);
const targetFreq = midiToFreq(note);
const portamentoMs = this.parameterValue("portamento");
const currentFreq =
portamentoMs > 0 && this.lastTriggeredFreq > 0
? this.lastTriggeredFreq
: targetFreq;
const voice: SynthVoice = {
note,
targetFreq,
currentFreq,
phase: 0,
velocity,
envValue: 0,
stage: "attack",
sequence: ++this.voiceCounter,
filterX1: 0,
filterY1: 0,
releaseAtSamples: -1,
};
if (this.voices.length >= this.polyphony) {
let oldestIdx = 0;
for (let i = 1; i < this.voices.length; i++) {
if (
this.voices[i]!.sequence < this.voices[oldestIdx]!.sequence
) {
oldestIdx = i;
}
}
this.voices[oldestIdx] = voice;
} else {
this.voices.push(voice);
}
this.lastTriggeredFreq = targetFreq;
}
public triggerRelease(note: number): void {
this.assertNote(note);
for (const voice of this.voices) {
if (voice.note === note && voice.stage !== "release") {
voice.stage = "release";
}
}
}
public triggerAttackRelease(note: number, durationSec: number): void {
this.triggerAttack(note);
if (!Number.isFinite(durationSec) || durationSec <= 0) {
this.triggerRelease(note);
return;
}
// Mark the just-attacked voice with a sample countdown after which
// the process loop will flip its stage to "release". The voice
// plays normally (attack → decay → sustain) during the countdown.
// Setup must have been called before triggerAttackRelease since
// the duration is sample-quantised against the live sample rate.
const sr = this.config!.sampleRate;
const voice = this.voices[this.voices.length - 1]!;
voice.releaseAtSamples = Math.round(durationSec * sr);
}
public getActiveVoiceCount(): number {
return this.voices.length;
}
public getActiveVoices(): ReadonlyArray<SynthesizerVoiceSnapshot> {
return this.voices.map((v) => ({ note: v.note, stage: v.stage }));
}
protected override setupState(
_config: ProcessorConfig,
_chain: ChainContext
): void {
this.voices = [];
this.lastTriggeredFreq = 0;
}
protected override resetState(): void {
this.voices = [];
this.lastTriggeredFreq = 0;
}
protected processBlock(
_input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
const out = output[0]!;
out.fill(0);
const blockSize = out.length;
const sr = ctx.sampleRate;
const attackSec = this.parameterValue("attack");
const decaySec = this.parameterValue("decay");
const sustain = this.parameterValue("sustain");
const releaseSec = this.parameterValue("release");
const cutoffHz = this.parameterValue("cutoff");
const portamentoMs = this.parameterValue("portamento");
const waveformIdx = Math.round(this.parameterValue("waveform"));
const waveform = WAVEFORMS[waveformIdx]!;
const attackPerSample = computeRate(attackSec, sr);
const decayPerSample = computeRate(decaySec, sr);
const releasePerSample = computeRate(releaseSec, sr);
const portaCoef =
portamentoMs > 0
? 1 - Math.exp(-1 / ((portamentoMs / 1000) * sr))
: 1;
const filterAlpha = onePoleAlpha(cutoffHz, sr);
const survivors: SynthVoice[] = [];
for (const voice of this.voices) {
let phase = voice.phase;
let env = voice.envValue;
let stage = voice.stage;
let currentFreq = voice.currentFreq;
let x1 = voice.filterX1;
let y1 = voice.filterY1;
const ampGain = voice.velocity;
let releaseAt = voice.releaseAtSamples;
for (let i = 0; i < blockSize; i++) {
if (releaseAt > 0) {
releaseAt -= 1;
if (releaseAt === 0 && stage !== "release") {
stage = "release";
}
}
currentFreq += portaCoef * (voice.targetFreq - currentFreq);
const phaseInc = (TWO_PI * currentFreq) / sr;
const raw = evaluateOscillator(waveform, phase);
phase += phaseInc;
if (phase >= TWO_PI) {
phase -= TWO_PI;
}
switch (stage) {
case "attack": {
env += attackPerSample;
if (env >= 1) {
env = 1;
stage = "decay";
}
break;
}
case "decay": {
env -= decayPerSample * (1 - sustain);
if (env <= sustain) {
env = sustain;
stage = "sustain";
}
break;
}
case "sustain": {
env = sustain;
break;
}
case "release": {
env -= releasePerSample;
if (env <= 0) {
env = 0;
}
break;
}
}
const driven = raw * env * ampGain;
const filtered = y1 + filterAlpha * (driven - y1);
x1 = driven;
y1 = filtered;
out[i] = out[i]! + filtered;
}
voice.phase = phase;
voice.envValue = env;
voice.stage = stage;
voice.currentFreq = currentFreq;
voice.filterX1 = x1;
voice.filterY1 = y1;
voice.releaseAtSamples = releaseAt;
if (stage === "release" && env <= 0) {
continue;
}
survivors.push(voice);
}
this.voices = survivors;
}
protected override getPresetData(): SynthesizerPresetData {
const idx = Math.round(this.getParameter("waveform"));
return { waveform: WAVEFORMS[idx]! };
}
protected override applyPresetData(data: unknown): void {
const { waveform } = data as SynthesizerPresetData;
this.setParameter("waveform", WAVEFORMS.indexOf(waveform));
}
private assertNote(note: number): void {
if (!Number.isInteger(note) || note < 0 || note > 127) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_NOTE,
"audio-fx.synthesizer-invalid-note",
{ context: { note } }
);
}
}
}
function midiToFreq(note: number): number {
return 440 * 2 ** ((note - 69) / 12);
}
function computeRate(seconds: number, sampleRate: number): number {
if (seconds <= 0) {
return 1;
}
return 1 / (seconds * sampleRate);
}
function onePoleAlpha(cutoffHz: number, sampleRate: number): number {
const c = Math.max(20, Math.min(cutoffHz, sampleRate * 0.45));
const omega = (TWO_PI * c) / sampleRate;
return omega / (omega + 1);
}
function evaluateOscillator(waveform: SynthWaveform, phase: number): number {
if (waveform === "sine") {
return Math.sin(phase);
}
if (waveform === "square") {
return Math.sin(phase) >= 0 ? 1 : -1;
}
if (waveform === "sawtooth") {
const t = phase / TWO_PI;
return 2 * t - 1;
}
// triangle
return (2 / Math.PI) * Math.asin(Math.sin(phase));
}

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