102 lines
5.0 KiB
Markdown
102 lines
5.0 KiB
Markdown
<!-- 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`
|