Files
2026-05-27 23:29:53 +01:00

102 lines
5.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# 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`