Initial Commit
This commit is contained in:
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
SynthesizerPluginOptionsSchema,
|
||||
SynthesizerPresetDataSchema,
|
||||
SynthWaveformSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SynthesizerPlugin } from "./synthesizer.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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-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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user