Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
<!-- 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`