Files
skills/docs/media/audio-fx/audio-fx-plugin-synthesizer/src/synthesizer/synthesizer.md
2026-05-29 08:20:56 +01:00

6.0 KiB
Raw Blame History

synthesizer

(no submodules)

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

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

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.