first commit
This commit is contained in:
119
docs/media/audio-fx/audio-fx-plugin-spessasynth/README.md
Normal file
119
docs/media/audio-fx/audio-fx-plugin-spessasynth/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# @suaveplan/audio-fx-plugin-spessasynth - SF2/SF3 SoundFont synthesizer plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
SF2/SF3 SoundFont synthesizer plugin for the `@suaveplan/audio-fx-plugin-*` family. Wraps the MIT-licensed [SpessaSynth](https://github.com/spessasus/SpessaSynth) engine as a standard `AudioFxPlugin`, so it composes inside any `PluginChain`, accepts MIDI from a `MidiPortLike` consumer, and exposes its 16-channel parameter surface through the same automation / snapshot / preset machinery every other plugin uses.
|
||||
|
||||
`spessasynth_lib` is declared as an `optionalDependencies` entry and pulled in only via lazy `import()` inside the loader, so consumers who never call `init()` pay zero bundle cost.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `SPESSASYNTH.*` error-code namespace used by every other module in `@suaveplan/audio-fx-plugin-spess…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `SPESSASYNTH.*` error-code namespace used by every other module in `@suaveplan/audio-fx-plugin-spess…
|
||||
- [loader](./src/loader/soundfont-loader.md) — Lazy entry point for the SF2/SF3/DLS SoundFont pipeline. The loader is the **only** place in the package where `spessas…
|
||||
- [soundfont-loader](./src/loader/soundfont-loader.md) — Lazy entry point for the SF2/SF3/DLS SoundFont pipeline. The loader is the **only** place in the package where `spessas…
|
||||
- [midi-router](./src/midi-router/midi-router.md) — Routes `MidiMessage` events from a `MidiPortLike` (or a direct `handle()` call) into the upstream `SpessaSynthProcessor…
|
||||
- [midi-router](./src/midi-router/midi-router.md) — Routes `MidiMessage` events from a `MidiPortLike` (or a direct `handle()` call) into the upstream `SpessaSynthProcessor…
|
||||
- [parameters](./src/parameters/parameters.md) — Declares the parameter surface the `SpessaSynthPlugin` exposes to the host chain: 16 GM channels × 5 controls (`volume`…
|
||||
- [parameters](./src/parameters/parameters.md) — Declares the parameter surface the `SpessaSynthPlugin` exposes to the host chain: 16 GM channels × 5 controls (`volume`…
|
||||
- [plugin](./src/plugin/spessasynth-plugin.md) — `SpessaSynthPlugin` is the `AudioFxPlugin` implementation that wraps the SpessaSynth SF2/SF3 SoundFont engine. It is a…
|
||||
- [spessasynth-plugin](./src/plugin/spessasynth-plugin.md) — `SpessaSynthPlugin` is the `AudioFxPlugin` implementation that wraps the SpessaSynth SF2/SF3 SoundFont engine. It is a…
|
||||
- [register](./src/register/register.md) — Provides the one-call helper `registerSpessaSynthPlugin(registry)` that attaches the SpessaSynth plugin factory to a `P…
|
||||
- [register](./src/register/register.md) — Provides the one-call helper `registerSpessaSynthPlugin(registry)` that attaches the SpessaSynth plugin factory to a `P…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry instrument bridge for the SpessaSynth plugin. Exposes three instruments under the meter `@suaveplan/audio…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry instrument bridge for the SpessaSynth plugin. Exposes three instruments under the meter `@suaveplan/audio…
|
||||
- [voice-manager](./src/voice-manager/voice-manager.md) — Owns the polyphony cap and the voice-stealing policy layered on top of the upstream `SpessaSynthProcessor`. The process…
|
||||
- [voice-manager](./src/voice-manager/voice-manager.md) — Owns the polyphony cap and the voice-stealing policy layered on top of the upstream `SpessaSynthProcessor`. The process…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { SpessaSynthPlugin } from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
const plugin = new SpessaSynthPlugin();
|
||||
await plugin.init({
|
||||
soundFontUrl: "/assets/gm.sf2",
|
||||
sampleRate: 48_000,
|
||||
});
|
||||
await plugin.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 128 },
|
||||
chainContext
|
||||
);
|
||||
|
||||
plugin.sendMidi({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
|
||||
|
||||
const out: Float32Array[] = [
|
||||
new Float32Array(128),
|
||||
new Float32Array(128),
|
||||
];
|
||||
plugin.process([], out, {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
opts: {},
|
||||
});
|
||||
```
|
||||
|
||||
## SF2 loading
|
||||
|
||||
```ts
|
||||
// From a URL (browser):
|
||||
await plugin.init({ soundFontUrl: "/assets/gm.sf2" });
|
||||
|
||||
// From an in-memory blob (offline render / tests):
|
||||
await plugin.init({ soundFontBytes: sf2Bytes });
|
||||
|
||||
// With an AbortController:
|
||||
await plugin.init({ soundFontUrl, signal: ac.signal });
|
||||
```
|
||||
|
||||
## Control-surface integration
|
||||
|
||||
```ts
|
||||
import {
|
||||
SpessaSynthPlugin,
|
||||
channelParameterId,
|
||||
MASTER_GAIN_ID,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
// A control-surface knob driving channel-0 volume in real time:
|
||||
controlSurface.onKnobMove((value) => {
|
||||
plugin.setParameter(channelParameterId(0, "volume"), value);
|
||||
});
|
||||
|
||||
// Automate the master gain over half a second:
|
||||
plugin.setParameterCurve(MASTER_GAIN_ID, {
|
||||
type: "linear",
|
||||
value: 0,
|
||||
duration: 0.5,
|
||||
});
|
||||
```
|
||||
|
||||
## Registry
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { registerSpessaSynthPlugin } from "@suaveplan/audio-fx-plugin-spessasynth/register";
|
||||
|
||||
const registry = new PluginRegistry();
|
||||
registerSpessaSynthPlugin(registry);
|
||||
|
||||
// Now `chain.restore(snapshot, registry)` can rehydrate any snapshot
|
||||
// containing a SpessaSynth slot.
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- `error-codes` — `SPESSASYNTH.*` codes via `defineCodes`.
|
||||
- `loader` — lazy `import("spessasynth_lib")`, SF2/SF3/DLS magic sniff, parse.
|
||||
- `midi-router` — channel-voice + program-change + NRPN/RPN reassembly.
|
||||
- `parameters` — 16-channel × 5-control + master gain (81 parameters).
|
||||
- `voice-manager` — polyphony cap + voice-stealing.
|
||||
- `plugin` — `SpessaSynthPlugin` (the `AudioFxPlugin` implementation).
|
||||
- `telemetry` — OTel counter + histogram + observable gauge.
|
||||
- `register` — registry helper for snapshot-restore.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable `SPESSASYNTH.*` error-code namespace used by every other module in `@suaveplan/audio-fx-plugin-spessasynth`. The codes are surfaced through `defineCodes` from `@suaveplan/error`, which makes them discoverable to wire-format consumers (the API gateway, the OpenTelemetry span-event adapter, the i18n message resolver) without importing this package directly.
|
||||
|
||||
Every operational failure raised by the loader, MIDI router, parameter surface, voice manager, or the `SpessaSynthPlugin` orchestrator carries one of these codes plus a stable message key. The codes are intentionally narrow — load-failure vs parse-failure vs unsupported-format vs voice-limit vs initialisation vs MIDI/parameter validation vs module-unavailable — so that consumers can pattern-match on `error.code` and decide whether to retry, fall back to a smaller SoundFont, or surface a permanent error to the operator.
|
||||
|
||||
## Features
|
||||
|
||||
- `SPESSASYNTH_CODES` — closed enum of nine codes registered via `defineCodes` so cross-package consumers can refer to them by literal string.
|
||||
- `SPESSASYNTH_ERROR_METADATA` — frozen record giving every code a one-line human description and a severity (`warning` for `VOICE_LIMIT_EXCEEDED`, `error` for the rest).
|
||||
- `SpessaSynthError` — `SuaveplanError` subclass with a `category: "audio-fx-spessasynth"` discriminator and a descriptive `name` derived from the code's severity, suitable for log-aggregation grouping.
|
||||
- Re-exported through the package barrel and the `./error-codes` subpath so consumers tree-shake to just the codes they need.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SpessaSynthError,
|
||||
SPESSASYNTH_CODES,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth/error-codes";
|
||||
|
||||
throw new SpessaSynthError(
|
||||
SPESSASYNTH_CODES.NOT_INITIALIZED,
|
||||
"spessasynth.process-before-init"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
SpessaSynthError,
|
||||
SPESSASYNTH_CODES,
|
||||
SPESSASYNTH_ERROR_METADATA,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth/error-codes";
|
||||
|
||||
try {
|
||||
await plugin.init({ soundFontBytes });
|
||||
} catch (err) {
|
||||
if (err instanceof SpessaSynthError) {
|
||||
const meta = SPESSASYNTH_ERROR_METADATA[err.code];
|
||||
logger[meta.severity]("spessasynth init failed", {
|
||||
code: err.code,
|
||||
description: meta.description,
|
||||
cause: err.cause,
|
||||
});
|
||||
if (err.code === SPESSASYNTH_CODES.SOUNDFONT_PARSE_FAILED) {
|
||||
// attempt a known-good fallback SF2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SPESSASYNTH_CODES` — `Record<Code, "SPESSASYNTH.<Code>">`.
|
||||
- `SpessaSynthErrorCode` — string-literal union of every code.
|
||||
- `SPESSASYNTH_ERROR_METADATA[code]: { description, severity }`.
|
||||
- `class SpessaSynthError extends SuaveplanError<SpessaSynthErrorCode>` — constructor takes `(code, messageKey, options?)` with optional `cause`/`context`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor reads `SPESSASYNTH_ERROR_METADATA[code]` to populate `this.name`, which serves the secondary purpose of guaranteeing the constructor body is not elided by `noUselessConstructor` lint. The metadata table is `Object.freeze`d so consumers can't accidentally mutate it; the codes themselves are an `as const` tuple supplied to `defineCodes`, so changing them requires editing the source — no run-time mutation is possible. The class extends `SuaveplanError` directly (no intermediate base) so the redaction, span-event, and i18n hooks defined on `SuaveplanError` apply uniformly.
|
||||
@@ -0,0 +1,62 @@
|
||||
# soundfont-loader
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Lazy entry point for the SF2/SF3/DLS SoundFont pipeline. The loader is the **only** place in the package where `spessasynth_lib` is referenced — the import is a dynamic `import("spessasynth_lib")` so consumers who instantiate the plugin but never call `init()` pay zero bundle cost. The loader also accepts an injectable `moduleLoader` so tests can swap in a deterministic stub without touching the real upstream library.
|
||||
|
||||
The loader is responsible for three things: (1) lazy-resolving the optional upstream module and surfacing `SPESSASYNTH.MODULE_UNAVAILABLE` if it isn't installed; (2) fetching the SoundFont bytes from a URL or accepting them inline; (3) sniffing the RIFF magic to reject obviously-wrong inputs before handing them to `SoundBankLoader.fromArrayBuffer`. Parse failures from the upstream parser are caught and re-raised as `SPESSASYNTH.SOUNDFONT_PARSE_FAILED` with the underlying error on `cause` so log aggregation can correlate.
|
||||
|
||||
## Features
|
||||
|
||||
- Dynamic `import("spessasynth_lib")` — the only static reference in the codebase, ring-fenced inside `defaultModuleLoader`.
|
||||
- Injectable `moduleLoader` and `fetchImpl` for hermetic tests.
|
||||
- Magic-byte sniff (`RIFF…sfbk` or `RIFF…DLS `) — rejects non-SoundFont inputs with `UNSUPPORTED_FORMAT` before invoking the parser.
|
||||
- `AbortSignal` propagation to the fetch.
|
||||
- Returns parsed `SoundBankLike` + resolved `SpessaSynthModuleLike` + raw bytes so callers can construct the processor and (optionally) persist the bytes.
|
||||
- Strict typed error surface: `SOUNDFONT_LOAD_FAILED`, `UNSUPPORTED_FORMAT`, `SOUNDFONT_PARSE_FAILED`, `MODULE_UNAVAILABLE`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { loadSoundFont } from "@suaveplan/audio-fx-plugin-spessasynth/loader";
|
||||
|
||||
const { bank, module } = await loadSoundFont({
|
||||
soundFontUrl: "/assets/gm.sf2",
|
||||
});
|
||||
const processor = new module.SpessaSynthProcessor(48_000);
|
||||
processor.soundBankManager.addSoundBank(bank, "main", 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { loadSoundFont } from "@suaveplan/audio-fx-plugin-spessasynth/loader";
|
||||
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5_000);
|
||||
|
||||
const { bank, bytes, module } = await loadSoundFont({
|
||||
soundFontUrl: "https://cdn.example.com/orchestra.sf3",
|
||||
signal: ac.signal,
|
||||
fetchImpl: customFetcher,
|
||||
moduleLoader: async () => upstreamStubModule,
|
||||
});
|
||||
|
||||
// Persist bytes for offline reuse.
|
||||
await driveStore.put("orchestra.sf3", bytes);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `loadSoundFont(opts: LoadSoundFontOptions): Promise<LoadedSoundFont>`.
|
||||
- `LoadSoundFontOptions = { soundFontUrl?, soundFontBytes?, signal?, fetchImpl?, moduleLoader? }`. Exactly one of `soundFontUrl` / `soundFontBytes` is required.
|
||||
- `LoadedSoundFont = { bank, module, bytes }`.
|
||||
- `SpessaSynthModuleLike`, `SpessaSynthProcessorLike`, `SoundBankLike` — structural views of the upstream surface used elsewhere in the package.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The structural module / processor / bank types are declared inside the package rather than imported from `spessasynth_lib` so the optional dependency does not leak into the static type graph — the only place the upstream is named is inside `defaultModuleLoader`'s dynamic `import()`. The magic sniff is intentionally cheap (12 bytes) and runs before the parser, so consumers that hand the loader random Uint8Arrays (e.g. a corrupt cache entry) get a fast, specific error rather than a deep stack from the parser. The `ArrayBuffer` is sliced from the supplied `Uint8Array.buffer` using the `byteOffset`/`byteLength` window so callers can pass a `Uint8Array` view of a larger buffer without leaking the surrounding bytes into the parser.
|
||||
@@ -0,0 +1,68 @@
|
||||
# midi-router
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Routes `MidiMessage` events from a `MidiPortLike` (or a direct `handle()` call) into the upstream `SpessaSynthProcessor`, with note-on/note-off mediated through the `VoiceManager` so the polyphony cap and per-channel voice tracking stay coherent. NRPN/RPN parameter writes are reassembled in software (per the MIDI 1.0 spec — MSB/LSB selects + data MSB/LSB or inc/dec data entry) and surfaced to consumers via an `onParameterChange` callback in addition to being forwarded as raw CCs.
|
||||
|
||||
The router is lifecycle-detached at construction and `attach()`-ed inside `SpessaSynthPlugin.setup()`. Messages received before attach are dropped (the plugin's setup guarantees the call ordering, and the alternative — buffering — risks confusing the host with late timing).
|
||||
|
||||
## Features
|
||||
|
||||
- All MIDI 1.0 channel-voice messages: noteOn (velocity-0 demoted to noteOff), noteOff, controlChange, programChange, pitchBend, channelPressure, polyPressure.
|
||||
- System Exclusive forwarded via `processor.systemExclusive`.
|
||||
- NRPN/RPN reassembly with state per channel: CC#99/98 (NRPN MSB/LSB), CC#101/100 (RPN MSB/LSB), CC#6/38 (data MSB/LSB), CC#96/97 (data inc/dec).
|
||||
- `onParameterChange(event)` callback fires on each data-write phase so consumers can observe high-resolution parameter writes (per-note tuning, master fine-tune, modulation depth range, etc.).
|
||||
- Multiple `MidiPortLike` connections; each `connectPort` call returns an unsubscribe.
|
||||
- Both port-driven (`connectPort`) and direct-call (`handle`) entry points.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MidiRouter } from "@suaveplan/audio-fx-plugin-spessasynth/midi-router";
|
||||
|
||||
const router = new MidiRouter();
|
||||
router.attach(processor, voiceManager);
|
||||
router.handle({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MidiRouter } from "@suaveplan/audio-fx-plugin-spessasynth/midi-router";
|
||||
|
||||
const router = new MidiRouter({
|
||||
onParameterChange(event) {
|
||||
if (
|
||||
event.kind === "nrpn" &&
|
||||
event.paramHigh === 0x65 &&
|
||||
event.paramLow === 0x4c
|
||||
) {
|
||||
logger.debug("per-note tuning update", event);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.attach(processor, voiceManager);
|
||||
const disconnect = router.connectPort(midiInputPort);
|
||||
// later
|
||||
disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MidiRouter`
|
||||
- `constructor(options?: MidiRouterOptions)`
|
||||
- `attach(processor, voiceManager): void`
|
||||
- `detach(): void`
|
||||
- `connectPort(port: MidiPortLike): () => void`
|
||||
- `handle(message: MidiMessage): void`
|
||||
- `interface MidiRouterOptions { onParameterChange?(event): void }`
|
||||
- `interface ParameterChangeEvent { kind: "nrpn" | "rpn", channel, paramHigh, paramLow, dataHigh, dataLow }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The NRPN/RPN reassembly intentionally fires `onParameterChange` on each of CC#6, CC#38, CC#96, CC#97 — not just on a "complete" pair — because the MIDI spec doesn't define what "complete" means: some controllers send only MSB, some send LSB-then-MSB, some send inc/dec sequences. Surfacing every data-write phase lets observers do whatever they want, including debouncing. The raw CC is also forwarded to the synth on every controller change so the upstream library's own NRPN handler still runs — software reassembly is an additional read-side hook, not a replacement. Messages arriving before `attach()` are silently dropped rather than buffered; the plugin's setup() ordering makes this unreachable in normal use, and silent drop is safer than late playback if a consumer mis-wires the lifecycle.
|
||||
@@ -0,0 +1,67 @@
|
||||
# parameters
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the parameter surface the `SpessaSynthPlugin` exposes to the host chain: 16 GM channels × 5 controls (`volume`, `pan`, `program`, `reverbSend`, `chorusSend`) plus a master gain, for a total of 81 parameters. Each is described as a `PluginParameter` from `@suaveplan/audio-fx-plugin-base` so the host chain's automation lanes, parameter-curve scheduler, and preset snapshot machinery treat them uniformly with every other plugin in the chain.
|
||||
|
||||
Parameter ids follow the deterministic `channel/<n>/<kind>` pattern (and the singleton `master/gain`) so external systems — control surfaces, OSC bridges, MIDI-learn UIs — can address any control by literal string without consulting a runtime registry.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildParameterDeclarations()` — emits 81 `PluginParameter` records in deterministic order.
|
||||
- `channelParameterId(channel, kind)` / `parseChannelParameterId(id)` — symmetric id encode / decode.
|
||||
- `assertChannel(channel)` — throws `SPESSASYNTH.INVALID_CHANNEL` for non-integer or out-of-range channels.
|
||||
- `CHANNEL_COUNT`, `CHANNEL_PARAM_KINDS`, `MASTER_GAIN_ID` — closed-enum constants.
|
||||
- All ratios use `[0, 1]` for sends and volume, `[-1, 1]` for pan, `[0, 127]` for program selection, `[0, 4]` for master gain.
|
||||
- Every parameter except `program` is marked `automatable: true` (program-change is a discrete enum jump and shouldn't be interpolated).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildParameterDeclarations,
|
||||
channelParameterId,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth/parameters";
|
||||
|
||||
const parameters = buildParameterDeclarations();
|
||||
console.log(channelParameterId(0, "volume")); // "channel/0/volume"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseChannelParameterId,
|
||||
MASTER_GAIN_ID,
|
||||
assertChannel,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth/parameters";
|
||||
|
||||
function applyAutomation(id: string, value: number) {
|
||||
if (id === MASTER_GAIN_ID) {
|
||||
processor.setMasterGain(value);
|
||||
return;
|
||||
}
|
||||
const decoded = parseChannelParameterId(id);
|
||||
if (!decoded) return;
|
||||
assertChannel(decoded.channel);
|
||||
routeToChannel(decoded.channel, decoded.kind, value);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CHANNEL_COUNT: 16`, `MAX_CHANNEL_INDEX: 15`.
|
||||
- `CHANNEL_PARAM_KINDS: ReadonlyArray<"volume" | "pan" | "program" | "reverbSend" | "chorusSend">`.
|
||||
- `channelParameterId(channel: number, kind: ChannelParamKind): string`.
|
||||
- `parseChannelParameterId(id: string): { channel, kind } | undefined`.
|
||||
- `buildParameterDeclarations(): ReadonlyArray<PluginParameter>`.
|
||||
- `assertChannel(channel: number): void`.
|
||||
- `MASTER_GAIN_ID: "master/gain"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`program` is declared `automatable: false` because the host chain's parameter-schedule evaluator interpolates linearly; an in-flight ramp between two program numbers would emit non-integer program values to the underlying synth, which has no defined semantics. Treating program-change as a discrete `setParameter` write keeps the contract clean. `volume` is `[0, 1]` to match MIDI CC #7 semantics — callers that need pre-master headroom should drive `master/gain` (which goes to 4×) rather than over-driving channel volume. The parameter order — channels in numeric order, kinds in declaration order, master last — is deterministic so snapshots and UI iteration produce stable byte-for-byte output.
|
||||
@@ -0,0 +1,91 @@
|
||||
# spessasynth-plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpessaSynthPlugin` is the `AudioFxPlugin` implementation that wraps the SpessaSynth SF2/SF3 SoundFont engine. It is a **source** plugin (zero inputs, stereo output) — instances live at slot 0 of a `PluginChain` and feed downstream effects (EQ, reverb, compressor, …) with synthesised audio. The plugin lazy-loads `spessasynth_lib` only when `init()` is called, so consumers that never touch this plugin pay no bundle cost.
|
||||
|
||||
The plugin composes the four sibling modules in this package: `loader` (lazy module + SF2 fetch + parse), `parameters` (16-channel × 5-control + master surface), `voice-manager` (polyphony cap + voice stealing), `midi-router` (channel-voice + NRPN/RPN reassembly + program-change). On every `process()` block the plugin flushes the current parameter snapshot to the synth via standard MIDI CCs (CC#7 volume, CC#10 pan, CC#91 reverb send, CC#93 chorus send, plus program-change), renders into stereo scratch buffers, and applies the master gain.
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle: `init({ soundFontUrl | soundFontBytes, sampleRate? })` → `setup(config, chain)` → `process()` → `dispose()`.
|
||||
- 81-parameter surface (16 × 5 + master), all automatable except `program` (discrete enum).
|
||||
- Polyphony cap with default 350, sanity-checked, voice-stealing oldest-first.
|
||||
- MIDI surface: `sendMidi(message)` accepts a `MidiMessage` union (noteOn/noteOff/CC/programChange/pitchBend/aftertouch/sysex).
|
||||
- `allNotesOff(channel?)` — channel-targeted or global panic.
|
||||
- `getSoundFontUrl()` — read-back of the most recent `init()` URL (for diagnostics and snapshot).
|
||||
- Snapshot/restore via the base class' `getPreset()` / `applyPreset()` — parameters in the standard map, plugin-specific `data` carrying `soundFontUrl`, `hasInlineSoundFont`, `maxPolyphony`.
|
||||
- OTel: `spessasynth_voices_triggered_total` counter on `noteOn`, `spessasynth_process_duration_us` histogram on every `process()`, `spessasynth_voices_active` observable gauge tracking the synth's live voice count.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SpessaSynthPlugin } from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
const plugin = new SpessaSynthPlugin();
|
||||
await plugin.init({
|
||||
soundFontUrl: "/assets/gm.sf2",
|
||||
sampleRate: 48_000,
|
||||
});
|
||||
await plugin.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 128 },
|
||||
chainContext
|
||||
);
|
||||
plugin.sendMidi({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
|
||||
|
||||
const out: Float32Array[] = [
|
||||
new Float32Array(128),
|
||||
new Float32Array(128),
|
||||
];
|
||||
plugin.process([], out, { blockOffsetSamples: 0, sampleRate: 48_000, channels: 2, opts: {} });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SpessaSynthPlugin } from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
const plugin = new SpessaSynthPlugin({ initialMaxPolyphony: 64 });
|
||||
|
||||
await plugin.init({
|
||||
soundFontBytes: vendoredSoundFont,
|
||||
sampleRate: 48_000,
|
||||
moduleLoader: customModuleLoader, // hermetic test path
|
||||
});
|
||||
|
||||
// Drive channel-0 volume from a control-surface knob.
|
||||
plugin.setParameterCurve("channel/0/volume", {
|
||||
type: "linear",
|
||||
value: 1,
|
||||
duration: 0.5,
|
||||
});
|
||||
|
||||
// Snapshot for session persistence.
|
||||
const snapshot = plugin.getPreset();
|
||||
await driveStore.put("session.json", JSON.stringify(snapshot));
|
||||
|
||||
// Restore on the next boot.
|
||||
const restored = new SpessaSynthPlugin();
|
||||
await restored.init({ soundFontBytes: vendoredSoundFont });
|
||||
restored.applyPreset(snapshot);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SpessaSynthPlugin(options?: SpessaSynthPluginOptions)`.
|
||||
- `init(opts: SpessaSynthInitOptions): Promise<void>` — accepts `LoadSoundFontOptions` plus optional `sampleRate`.
|
||||
- `sendMidi(message: MidiMessage): void`.
|
||||
- `setMaxPolyphony(n: number): void`, `getMaxPolyphony(): number`.
|
||||
- `getActiveVoiceCount(): number`.
|
||||
- `allNotesOff(channel?: number): void`.
|
||||
- `getSoundFontUrl(): string | undefined`.
|
||||
- Standard `AudioFxPlugin` surface: `setup`, `process`, `reset`, `dispose`, `getParameter`, `setParameter`, `setParameterCurve`, `setParameterAt`, `getPreset`, `applyPreset`, `onLatencyChanged`.
|
||||
- Re-exports: `parseChannelParameterId` from the parameters module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plugin keeps two stereo scratch buffers sized to `blockSize` and re-uses them across `process()` blocks — the upstream `processor.process()` writes into pre-allocated arrays, so allocation in the audio thread is avoided after the first block. Parameter snapshots are flushed to the synth on every block via standard MIDI CCs rather than reaching into the upstream's private API; the trade-off is one round of MIDI CC events per block (≈ 80 CCs at block-rate, sub-microsecond cost) for full decoupling from the upstream's internal channel state representation. The master gain is applied at the mix stage rather than through CC#7 because the spec range `[0, 4]` (×4 makeup) exceeds CC#7's 0..127 representation. `applyPresetData` re-applies the per-channel parameter map by re-running `flushParametersToSynth`, which guarantees a restored instance behaves identically to a freshly-configured one without coupling restore to internal state.
|
||||
@@ -0,0 +1,58 @@
|
||||
# register
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the one-call helper `registerSpessaSynthPlugin(registry)` that attaches the SpessaSynth plugin factory to a `PluginRegistry` from `@suaveplan/audio-fx-plugin-base`. The chain's snapshot-restore path uses the registry to look up the right factory for each serialised `pluginType` string; registering the factory ahead of time means a chain snapshot containing a SpessaSynth slot will rehydrate without bespoke code at the call site.
|
||||
|
||||
The helper closes over a `SpessaSynthPluginOptions` payload so consumers can pin a custom `moduleLoader` (for hermetic tests, alternative bundling, or a vendored upstream fork) or a fixed `initialMaxPolyphony` for embedded targets.
|
||||
|
||||
## Features
|
||||
|
||||
- One-call registration: `registerSpessaSynthPlugin(registry)`.
|
||||
- Stable type string: `SPESSASYNTH_PLUGIN_TYPE = "audio-fx.spessasynth"` — also exposed as the plugin's `metadata.id`.
|
||||
- Optional `SpessaSynthPluginOptions` baked into the factory closure.
|
||||
- Idempotent: re-registering overwrites the previous factory (the registry contract explicitly allows this).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { registerSpessaSynthPlugin } from "@suaveplan/audio-fx-plugin-spessasynth/register";
|
||||
|
||||
const registry = new PluginRegistry();
|
||||
registerSpessaSynthPlugin(registry);
|
||||
const plugin = registry.create("audio-fx.spessasynth");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import {
|
||||
registerSpessaSynthPlugin,
|
||||
SPESSASYNTH_PLUGIN_TYPE,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth/register";
|
||||
|
||||
const registry = new PluginRegistry();
|
||||
registerSpessaSynthPlugin(registry, {
|
||||
initialMaxPolyphony: 32,
|
||||
moduleLoader: async () => testStubModule,
|
||||
});
|
||||
|
||||
console.log(registry.has(SPESSASYNTH_PLUGIN_TYPE)); // true
|
||||
const plugin = registry.create(SPESSASYNTH_PLUGIN_TYPE);
|
||||
await plugin.init({ soundFontBytes: fixtureBytes });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SPESSASYNTH_PLUGIN_TYPE: "audio-fx.spessasynth"` — string constant matching the plugin's `metadata.id`.
|
||||
- `registerSpessaSynthPlugin(registry: PluginRegistry, options?: SpessaSynthPluginOptions): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helper passes a closure into `registry.register` rather than the class itself so the factory contract (`() => AudioFxPlugin`) is satisfied without callers learning the constructor signature. The plugin-type string is hard-coded and exported as a constant so chain snapshots and registry lookups stay byte-for-byte stable — changing the literal would silently break round-trip restore of any existing snapshot file, and surfacing it via a constant makes such a change obvious in a diff. Idempotence is inherited from `PluginRegistry.register`, which is documented to overwrite — re-registration is intentionally supported so tests can swap in a stub factory.
|
||||
@@ -0,0 +1,68 @@
|
||||
# telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrument bridge for the SpessaSynth plugin. Exposes three instruments under the meter `@suaveplan/audio-fx-plugin-spessasynth`: a monotonic counter `spessasynth_voices_triggered_total`, a histogram `spessasynth_process_duration_us`, and an observable gauge `spessasynth_voices_active`. The instruments are lazily created on first use and cached so the underlying OTel SDK sees a stable identity across the process lifetime.
|
||||
|
||||
The plugin core, voice manager, and MIDI router call into this module — none of those modules import `@opentelemetry/api` directly — so telemetry can be swapped or stubbed by mutating this single file.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument creation: instruments are not registered with the SDK until first call.
|
||||
- `recordVoicesTriggered(n?)` — counter bump on every `noteOn`.
|
||||
- `recordProcessDuration(durationUs)` — histogram sample per `process()` block.
|
||||
- `setActiveVoices(n)` — latched value the observable gauge reads on each collection.
|
||||
- `getActiveVoicesValue()` — read-back for diagnostics and unit tests.
|
||||
- `resetForTesting()` — clears the singletons so a test suite that swaps the meter provider can re-register from scratch.
|
||||
- All instrument labels are cardinality-stable: no per-channel dimensions, no per-voice tags — chosen so a long-running synth session does not blow up the metric backend.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordVoicesTriggered,
|
||||
recordProcessDuration,
|
||||
setActiveVoices,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth/telemetry";
|
||||
|
||||
recordVoicesTriggered(1);
|
||||
recordProcessDuration(412); // microseconds
|
||||
setActiveVoices(processor.voiceCount);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { InMemoryMetricExporter } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
resetForTesting,
|
||||
setActiveVoices,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth/telemetry";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const provider = new MeterProvider({ readers: [/* periodic reader */] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetForTesting();
|
||||
|
||||
setActiveVoices(3);
|
||||
const collected = await exporter.collect();
|
||||
// `spessasynth_voices_active` gauge in collected.scopeMetrics → 3
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordVoicesTriggered(n?: number): void` — counter add, default 1.
|
||||
- `recordProcessDuration(durationUs: number): void` — histogram record.
|
||||
- `setActiveVoices(count: number): void` — latch the gauge value.
|
||||
- `getActiveVoicesValue(): number` — read the latched value.
|
||||
- `resetForTesting(): void` — clears singletons (test-only).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The observable gauge is a one-shot construction with a callback that reads the latched `_activeVoicesValue` module-level variable. The latched approach (rather than passing the processor in to read `voiceCount`) decouples the telemetry module from the upstream library and lets `setActiveVoices` be called from a high-frequency path (every `process()` block) without per-call overhead. `resetForTesting` deliberately drops references to the cached instruments — the OTel API gracefully re-creates them on the next call, but the gauge callback registration is lost; tests that need to re-arm the gauge should call `setActiveVoices` at least once after resetting.
|
||||
@@ -0,0 +1,85 @@
|
||||
# midi.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the structural `MidiMessage` discriminated union and the `MidiPortLike` consumer interface this package depends on. Both shapes mirror the corresponding exports from `@suaveplan/midi` (parallel work on the `feat/midi` branch); defining them locally as structural types lets this package compile, ship, and pass the OpenSpec gate before `@suaveplan/midi` is published.
|
||||
|
||||
Once `@suaveplan/midi` lands, this file becomes the bridge: consumers passing a `@suaveplan/midi`-typed `MidiPortLike` into this plugin will structurally satisfy the local `MidiPortLike` declaration without an explicit cast. The shape was reviewed against the `@suaveplan/midi` spec contract (which is stable) so the duck-typing is intentional, not accidental.
|
||||
|
||||
## Features
|
||||
|
||||
- `MidiMessage` discriminated union of 8 channel-voice + sysex types: `noteOn`, `noteOff`, `controlChange`, `programChange`, `pitchBend`, `channelPressure`, `polyPressure`, `sysex`.
|
||||
- Per-variant interfaces (`MidiNoteOn`, `MidiNoteOff`, …) exported individually so callers can narrow on `message.type` and accept the corresponding type.
|
||||
- `pitchBend.value` is documented as 14-bit (0..16383, 8192 = centre), matching the wire format.
|
||||
- `polyPressure` is per-note (vs `channelPressure` which is per-channel) — the union models both rather than collapsing them.
|
||||
- `sysex.data` is a `Uint8Array` for zero-copy forwarding into the upstream `processor.systemExclusive`.
|
||||
- `MidiPortLike` is read-side only — subscribe via `onMessage(listener)`, get an unsubscribe function back. No write-side / send-side / port-lifecycle members; that's the consumer's responsibility, not this plugin's.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MidiMessage,
|
||||
MidiPortLike,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
function send(port: MidiPortLike) {
|
||||
const off = port.onMessage((m: MidiMessage) => {
|
||||
if (m.type === "noteOn") {
|
||||
console.log("note", m.note, "vel", m.velocity);
|
||||
}
|
||||
});
|
||||
return off;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MidiControlChange,
|
||||
MidiPortLike,
|
||||
MidiSystemExclusive,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
function handleSysex(msg: MidiSystemExclusive) {
|
||||
if (msg.data[0] === 0xf0 && msg.data[1] === 0x7e) {
|
||||
// Universal Non-Realtime — Device Inquiry
|
||||
}
|
||||
}
|
||||
|
||||
function handleCC(msg: MidiControlChange) {
|
||||
if (msg.controller === 7) {
|
||||
applyVolume(msg.channel, msg.value / 127);
|
||||
}
|
||||
}
|
||||
|
||||
const port: MidiPortLike = makeWebMidiPort();
|
||||
port.onMessage((m) => {
|
||||
switch (m.type) {
|
||||
case "sysex":
|
||||
handleSysex(m);
|
||||
break;
|
||||
case "controlChange":
|
||||
handleCC(m);
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type MidiMessage` — union of 8 variants below.
|
||||
- `interface MidiNoteOn { type: "noteOn"; channel; note; velocity }`.
|
||||
- `interface MidiNoteOff { type: "noteOff"; channel; note; velocity? }`.
|
||||
- `interface MidiControlChange { type: "controlChange"; channel; controller; value }`.
|
||||
- `interface MidiProgramChange { type: "programChange"; channel; program }`.
|
||||
- `interface MidiPitchBend { type: "pitchBend"; channel; value /* 0..16383 */ }`.
|
||||
- `interface MidiChannelPressure { type: "channelPressure"; channel; pressure }`.
|
||||
- `interface MidiPolyPressure { type: "polyPressure"; channel; note; pressure }`.
|
||||
- `interface MidiSystemExclusive { type: "sysex"; data: Uint8Array }`.
|
||||
- `interface MidiPortLike { onMessage(listener): () => void }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The local declaration deliberately mirrors the `@suaveplan/midi` shape rather than depending on the parallel package because the package is being built in parallel on `feat/midi` and isn't installable from this branch. The TypeScript structural-type rule means a consumer holding a `@suaveplan/midi.MidiPortLike` can pass it directly to `router.connectPort` without an `as` cast — both interfaces have the same single `onMessage` method. When `@suaveplan/midi` lands, a future change can swap this file for a thin re-export without breaking any external consumer.
|
||||
@@ -0,0 +1,66 @@
|
||||
# voice-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the polyphony cap and the voice-stealing policy layered on top of the upstream `SpessaSynthProcessor`. The processor itself tracks live voices, but the SF2 spec leaves the stealing strategy to the implementation; this module makes that strategy explicit (oldest-first, by insertion order) and exposes the surface the spec requires: `getActiveVoiceCount`, `setMaxPolyphony`, `allNotesOff(channel?)`, plus the `noteOn` / `noteOff` paths that consult the cap.
|
||||
|
||||
`VoiceManager` is constructed lifecycle-detached and `attach()`-ed to a processor inside `SpessaSynthPlugin.setup()`; this lets snapshot/restore swap processors under the same manager and keeps the manager's unit tests independent of the upstream library.
|
||||
|
||||
## Features
|
||||
|
||||
- Polyphony cap with sanity bounds: 1 ≤ cap ≤ `ABSOLUTE_MAX_POLYPHONY` (1024). Default 350 matches the upstream `VOICE_CAP`.
|
||||
- Voice stealing by oldest sequence number — deterministic and cheap (`O(n)` over `active`).
|
||||
- `allNotesOff(channel?)` — channel-targeted (CC #123) or global (`stopAllChannels(true)`).
|
||||
- `triggeredTotal` counter for OTel.
|
||||
- Validates channel and note ranges; emits `SPESSASYNTH.INVALID_CHANNEL` / `SPESSASYNTH.INVALID_NOTE` / `SPESSASYNTH.INVALID_PARAMETER` (for polyphony) / `SPESSASYNTH.NOT_INITIALIZED` (use-before-attach).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VoiceManager } from "@suaveplan/audio-fx-plugin-spessasynth/voice-manager";
|
||||
|
||||
const vm = new VoiceManager();
|
||||
vm.attach(processor);
|
||||
vm.noteOn(0, 60, 100);
|
||||
console.log(vm.getActiveVoiceCount());
|
||||
vm.noteOff(0, 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { VoiceManager } from "@suaveplan/audio-fx-plugin-spessasynth/voice-manager";
|
||||
|
||||
const vm = new VoiceManager();
|
||||
vm.attach(processor);
|
||||
vm.setMaxPolyphony(8); // tight cap for an embedded target
|
||||
for (let n = 60; n < 80; n++) {
|
||||
vm.noteOn(0, n, 100); // earlier voices stolen once cap is hit
|
||||
}
|
||||
console.log(vm.getActiveVoiceCount()); // 8
|
||||
|
||||
vm.allNotesOff(0); // panic on channel 0
|
||||
vm.allNotesOff(); // global panic
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class VoiceManager`
|
||||
- `attach(processor: SpessaSynthProcessorLike): void`
|
||||
- `detach(): void`
|
||||
- `noteOn(channel, note, velocity): void`
|
||||
- `noteOff(channel, note): void`
|
||||
- `allNotesOff(channel?): void`
|
||||
- `getActiveVoiceCount(): number`
|
||||
- `getTriggeredTotal(): number`
|
||||
- `getMaxPolyphony(): number`
|
||||
- `setMaxPolyphony(n): void`
|
||||
- Constants: `DEFAULT_MAX_POLYPHONY = 350`, `ABSOLUTE_MAX_POLYPHONY = 1024`, `MAX_CHANNEL_INDEX = 15`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getActiveVoiceCount` prefers `processor.voiceCount` over the manager's own `active` length — the processor knows about voices in their release tail that the manager has already removed from its book-keeping. When the processor isn't attached, the manager falls back to its own count so unit tests can stand in. `noteOff` removes the most-recent matching `(channel, note)` entry rather than scanning forward, which keeps mass-release patterns (chord release) cheap. The stealing loop is defensive: it scans for the smallest sequence rather than trusting array order, so if a future refactor inserts out-of-order entries (e.g. priority queues) it still chooses the genuine oldest.
|
||||
Reference in New Issue
Block a user