Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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,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,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,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.
|
||||
Reference in New Issue
Block a user