Files
suaveplan/openspec/specs/audio-fx-plugin-spessasynth/spec.md
2026-05-27 23:29:53 +01:00

5.0 KiB
Raw Blame History

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 inited 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