# Capability Spec: audio-fx-plugin-spessasynth **Package**: `@suaveplan/audio-fx-plugin-spessasynth` **Status**: ACTIVE ## ADDED Requirements ### Requirement: SpessaSynthPlugin implements AudioFxPlugin `SpessaSynthPlugin` SHALL implement `AudioFxPlugin` from `@suaveplan/audio-fx-plugin-base`, exposing `init`, `process`, `dispose`, `getParameters`, `snapshot`, `restore`. It is a **source** plugin (no audio input; output is the synth's rendered PCM). #### Scenario: Render a single noteOn into a buffer - **WHEN** `init({ soundFontUrl: "fixtures/gm.sf2", sampleRate: 48_000 })` resolves and the consumer feeds a `{ type: "noteOn", channel: 0, note: 60, velocity: 100 }` MIDI message followed by 48,000 samples of `process()` calls - **THEN** the produced output has non-zero RMS (audible voice rendered) **Verified by:** `tests/integration.test.ts::"renders a noteOn"` ### Requirement: Lazy SF2 loading via dynamic import `init()` SHALL `import("spessasynth_lib")` lazily so consumers who never load this plugin pay no cost. #### Scenario: Bundle without import does not eagerly load SpessaSynth - **WHEN** the package's `dist/index.js` is statically analysed for imports of `spessasynth_lib` - **THEN** zero static imports are found; only one dynamic `import("spessasynth_lib")` exists, inside `soundfont-loader.ts` **Verified by:** `tests/bundle.test.ts::"spessasynth_lib is lazy-only"` ### Requirement: SoundFont parse failures surface typed errors A corrupt SF2/SF3 file SHALL reject `init()` with `SpessaSynthError("SPESSASYNTH.SOUNDFONT_PARSE_FAILED")` carrying the underlying parse error on `cause`. #### Scenario: Invalid SF2 bytes - **WHEN** `init({ soundFontBytes: new Uint8Array([0,0,0,0]), sampleRate: 48_000 })` is called - **THEN** it rejects with code `SPESSASYNTH.SOUNDFONT_PARSE_FAILED` **Verified by:** `src/loader/soundfont-loader.test.ts` ### Requirement: MIDI routing — 16 channels + program change + NRPN/RPN The plugin SHALL route every channel-voice message to the synth, program-change messages to the corresponding SF2 preset (bank + program), and NRPN/RPN events to the registered handlers (per-note tuning, master fine-tune). #### Scenario: Program-change selects piano patch on channel 0 - **WHEN** the synth is initialised with GM and a `programChange` to program 0 arrives on channel 0 - **THEN** subsequent `noteOn` events on channel 0 render with the "Acoustic Grand Piano" preset #### Scenario: NRPN per-note tuning - **WHEN** an NRPN with paramHigh=0x65, paramLow=0x4c (per-note tuning) is received followed by a `noteOn` of note 60 - **THEN** the rendered fundamental matches the tuned frequency (verified via FFT peak detection on the output) **Verified by:** `tests/midi-routing.test.ts` ### Requirement: Parameter surface with chain-driven automation The plugin SHALL expose 16-channel × 5-parameter (`volume`, `pan`, `program`, `reverbSend`, `chorusSend`) + master gain via `getParameters()`. Every parameter SHALL be drivable by the host chain's automation lanes. #### Scenario: Automate channel 0 volume from 0.0 to 1.0 over 1 second - **WHEN** the host chain schedules a volume automation on `channel/0/volume` from 0 to 1 over 48,000 samples - **THEN** the rendered output envelope tracks the schedule (RMS at sample 24,000 is approximately half RMS at sample 48,000, within 5%) **Verified by:** `tests/automation.test.ts` ### Requirement: Voice management The plugin SHALL expose `getActiveVoiceCount(): number`, `setMaxPolyphony(n: number): void`, and `allNotesOff(channel?: number): void`. When polyphony is exceeded, the oldest-released or quietest voice is stolen per the SF2 spec. #### Scenario: Polyphony cap steals voices - **WHEN** `setMaxPolyphony(4)` is called and 5 simultaneous noteOn events are sent - **THEN** `getActiveVoiceCount()` equals 4, not 5 **Verified by:** `src/voice-manager/voice-manager.test.ts` ### Requirement: Snapshot / restore round-trips state Calling `snapshot()` SHALL produce a JSON-serialisable object capturing: soundfont URL, per-channel program selection, per-channel parameter values, master gain, polyphony cap. `restore(snapshot)` SHALL bring a fresh plugin instance to identical state. #### Scenario: snapshot/restore round-trip - **WHEN** plugin A is configured (load SF2, set channel-0 program 24, set channel-3 pan 0.5), `snap = A.snapshot()`, and plugin B is `init`ed then `restore(snap)` - **THEN** B's `getParameters()` matches A's exactly **Verified by:** `src/plugin/spessasynth-plugin.test.ts` ### Requirement: OTel instrumentation Every `noteOn` SHALL increment `spessasynth_voices_triggered_total`. `getActiveVoiceCount()` SHALL be reflected in gauge `spessasynth_voices_active`. Every `process()` call SHALL record `spessasynth_process_duration_us`. #### Scenario: Voice gauge tracks live count - **WHEN** 3 simultaneous noteOn events are active and the in-memory `MeterProvider` is sampled - **THEN** `spessasynth_voices_active` reads 3 **Verified by:** `src/telemetry/telemetry.test.ts`