# `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 ```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` - 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.