Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,102 @@
{
"name": "@suaveplan/audio-fx-plugin-spessasynth",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "SF2/SF3 SoundFont synthesizer plugin for the @suaveplan/audio-fx-plugin-* family.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./loader": {
"import": {
"types": "./dist/loader/index.d.ts",
"default": "./dist/loader/index.js"
}
},
"./midi-router": {
"import": {
"types": "./dist/midi-router/index.d.ts",
"default": "./dist/midi-router/index.js"
}
},
"./parameters": {
"import": {
"types": "./dist/parameters/index.d.ts",
"default": "./dist/parameters/index.js"
}
},
"./voice-manager": {
"import": {
"types": "./dist/voice-manager/index.d.ts",
"default": "./dist/voice-manager/index.js"
}
},
"./plugin": {
"import": {
"types": "./dist/plugin/index.d.ts",
"default": "./dist/plugin/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
},
"./register": {
"import": {
"types": "./dist/register/index.d.ts",
"default": "./dist/register/index.js"
}
}
},
"scripts": {
"build": "vite build",
"lint": "biome check --max-diagnostics 500 src/",
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/audio-fx-plugin-base": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"optionalDependencies": {
"spessasynth_lib": "catalog:audio",
"spessasynth_core": "catalog:audio"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"spessasynth_lib": "catalog:audio",
"spessasynth_core": "catalog:audio",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -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.

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SPESSASYNTH_CODES,
SPESSASYNTH_ERROR_METADATA,
SpessaSynthError,
} from "./error-codes.js";
describe("error-codes", () => {
it("exposes every required code", { timeout: 5_000 }, () => {
expect(SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED).toBe(
"SPESSASYNTH.SOUNDFONT_LOAD_FAILED"
);
expect(SPESSASYNTH_CODES.SOUNDFONT_PARSE_FAILED).toBe(
"SPESSASYNTH.SOUNDFONT_PARSE_FAILED"
);
expect(SPESSASYNTH_CODES.UNSUPPORTED_FORMAT).toBe(
"SPESSASYNTH.UNSUPPORTED_FORMAT"
);
expect(SPESSASYNTH_CODES.VOICE_LIMIT_EXCEEDED).toBe(
"SPESSASYNTH.VOICE_LIMIT_EXCEEDED"
);
expect(SPESSASYNTH_CODES.NOT_INITIALIZED).toBe(
"SPESSASYNTH.NOT_INITIALIZED"
);
});
it("metadata covers every code", { timeout: 5_000 }, () => {
for (const code of Object.values(SPESSASYNTH_CODES)) {
const meta = SPESSASYNTH_ERROR_METADATA[code];
expect(typeof meta.description).toBe("string");
expect(["warning", "error"]).toContain(meta.severity);
}
});
it("SpessaSynthError carries code, category, and name", {
timeout: 5_000,
}, () => {
const err = new SpessaSynthError(
SPESSASYNTH_CODES.NOT_INITIALIZED,
"test.msg"
);
expect(err.code).toBe("SPESSASYNTH.NOT_INITIALIZED");
expect(err.category).toBe("audio-fx-spessasynth");
expect(err.name).toBe("SpessaSynthError(error)");
});
it("warning-severity code is reflected in name", { timeout: 5_000 }, () => {
const err = new SpessaSynthError(
SPESSASYNTH_CODES.VOICE_LIMIT_EXCEEDED,
"test.msg"
);
expect(err.name).toBe("SpessaSynthError(warning)");
});
it("preserves cause and context", { timeout: 5_000 }, () => {
const cause = new Error("boom");
const err = new SpessaSynthError(
SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED,
"test.msg",
{ cause, context: { url: "x" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ url: "x" });
});
});

View File

@@ -0,0 +1,88 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable code namespace for `@suaveplan/audio-fx-plugin-spessasynth`.
* Surfaced via `defineCodes` so wire-format consumers can pattern-match
* `code` strings without importing the package.
*/
export const SPESSASYNTH_CODES = defineCodes("SPESSASYNTH", [
"SOUNDFONT_LOAD_FAILED",
"SOUNDFONT_PARSE_FAILED",
"UNSUPPORTED_FORMAT",
"VOICE_LIMIT_EXCEEDED",
"NOT_INITIALIZED",
"INVALID_CHANNEL",
"INVALID_NOTE",
"INVALID_PARAMETER",
"MODULE_UNAVAILABLE",
] as const);
export type SpessaSynthErrorCode =
(typeof SPESSASYNTH_CODES)[keyof typeof SPESSASYNTH_CODES];
export interface SpessaSynthErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const SPESSASYNTH_ERROR_METADATA: Readonly<
Record<SpessaSynthErrorCode, SpessaSynthErrorMetadata>
> = Object.freeze({
[SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED]: {
description:
"fetch or read of the SoundFont URL/bytes failed before parsing began.",
severity: "error",
},
[SPESSASYNTH_CODES.SOUNDFONT_PARSE_FAILED]: {
description: "SF2/SF3 parser rejected the supplied bytes.",
severity: "error",
},
[SPESSASYNTH_CODES.UNSUPPORTED_FORMAT]: {
description:
"the file is not a recognised SF2/SF3/DLS SoundFont format.",
severity: "error",
},
[SPESSASYNTH_CODES.VOICE_LIMIT_EXCEEDED]: {
description:
"a voice cap was set lower than the current active voice count.",
severity: "warning",
},
[SPESSASYNTH_CODES.NOT_INITIALIZED]: {
description:
"MIDI / parameter / process calls were made before init() resolved.",
severity: "error",
},
[SPESSASYNTH_CODES.INVALID_CHANNEL]: {
description: "MIDI channel index outside the 0..15 range.",
severity: "error",
},
[SPESSASYNTH_CODES.INVALID_NOTE]: {
description: "MIDI note number outside the 0..127 range.",
severity: "error",
},
[SPESSASYNTH_CODES.INVALID_PARAMETER]: {
description:
"parameter identifier or numeric value outside the declared range.",
severity: "error",
},
[SPESSASYNTH_CODES.MODULE_UNAVAILABLE]: {
description:
"dynamic import of spessasynth_lib failed; the optional dependency is not installed.",
severity: "error",
},
});
export class SpessaSynthError extends SuaveplanError<SpessaSynthErrorCode> {
/** Stable discriminator preventing the constructor body being elided by lint. */
public readonly category = "audio-fx-spessasynth" as const;
public constructor(
code: SpessaSynthErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = `SpessaSynthError(${SPESSASYNTH_ERROR_METADATA[code].severity})`;
}
}

View File

@@ -0,0 +1,9 @@
export type {
SpessaSynthErrorCode,
SpessaSynthErrorMetadata,
} from "./error-codes.js";
export {
SPESSASYNTH_CODES,
SPESSASYNTH_ERROR_METADATA,
SpessaSynthError,
} from "./error-codes.js";

View File

@@ -0,0 +1,68 @@
export {
SPESSASYNTH_CODES,
SPESSASYNTH_ERROR_METADATA,
SpessaSynthError,
type SpessaSynthErrorCode,
type SpessaSynthErrorMetadata,
} from "./error-codes/error-codes.js";
export {
type LoadedSoundFont,
type LoadSoundFontOptions,
loadSoundFont,
type SoundBankLike,
type SpessaSynthModuleLike,
type SpessaSynthProcessorCtor,
type SpessaSynthProcessorLike,
} from "./loader/soundfont-loader.js";
export {
MidiRouter,
type MidiRouterOptions,
type ParameterChangeEvent,
} from "./midi-router/midi-router.js";
export {
assertChannel,
buildParameterDeclarations,
CHANNEL_COUNT,
CHANNEL_PARAM_KINDS,
type ChannelParamKind,
channelParameterId,
MASTER_GAIN_ID,
MAX_CHANNEL_INDEX,
parseChannelParameterId,
} from "./parameters/parameters.js";
export {
parseChannelParameterId as parsePluginChannelParameterId,
type SpessaSynthInitOptions,
SpessaSynthPlugin,
type SpessaSynthPluginOptions,
type SpessaSynthPresetData,
SpessaSynthPresetDataSchema,
} from "./plugin/spessasynth-plugin.js";
export {
registerSpessaSynthPlugin,
SPESSASYNTH_PLUGIN_TYPE,
} from "./register/register.js";
export {
getActiveVoicesValue,
recordProcessDuration,
recordVoicesTriggered,
resetForTesting as resetTelemetryForTesting,
setActiveVoices,
} from "./telemetry/telemetry.js";
export type {
MidiChannelPressure,
MidiControlChange,
MidiMessage,
MidiNoteOff,
MidiNoteOn,
MidiPitchBend,
MidiPolyPressure,
MidiPortLike,
MidiProgramChange,
MidiSystemExclusive,
} from "./types/midi.types.js";
export {
ABSOLUTE_MAX_POLYPHONY,
DEFAULT_MAX_POLYPHONY,
VoiceManager,
} from "./voice-manager/voice-manager.js";

View File

@@ -0,0 +1,10 @@
export {
type LoadedSoundFont,
type LoadSoundFontOptions,
loadSoundFont,
type SoundBankLike,
type SpessaSynthModuleLike,
type SpessaSynthProcessorCtor,
type SpessaSynthProcessorLike,
wrapModuleImport,
} from "./soundfont-loader.js";

View File

@@ -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.

View File

@@ -0,0 +1,270 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildFakeSf2Bytes, makeStubModule } from "../../tests/stub-module.js";
import {
SPESSASYNTH_CODES,
type SpessaSynthError,
} from "../error-codes/error-codes.js";
import { loadSoundFont, wrapModuleImport } from "./soundfont-loader.js";
describe("loadSoundFont", () => {
it("parses bytes with valid RIFF/sfbk magic", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const bytes = buildFakeSf2Bytes(64);
const out = await loadSoundFont({
soundFontBytes: bytes,
moduleLoader: async () => mod,
});
expect((out.bank as { stub: true }).stub).toBe(true);
expect(out.module).toBe(mod);
expect(out.bytes).toBe(bytes);
});
it("rejects unsupported magic with UNSUPPORTED_FORMAT", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const bytes = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
try {
await loadSoundFont({
soundFontBytes: bytes,
moduleLoader: async () => mod,
});
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.UNSUPPORTED_FORMAT
);
}
});
it("rejects tiny buffer with UNSUPPORTED_FORMAT", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
try {
await loadSoundFont({
soundFontBytes: new Uint8Array([1, 2, 3]),
moduleLoader: async () => mod,
});
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.UNSUPPORTED_FORMAT
);
}
});
it("surfaces parser errors as SOUNDFONT_PARSE_FAILED", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
mod.SoundBankLoader = {
fromArrayBuffer() {
throw new Error("synthetic parse failure");
},
};
try {
await loadSoundFont({
soundFontBytes: buildFakeSf2Bytes(64),
moduleLoader: async () => mod,
});
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.SOUNDFONT_PARSE_FAILED
);
}
});
it("missing source rejects with SOUNDFONT_LOAD_FAILED", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
try {
await loadSoundFont({ moduleLoader: async () => mod });
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED
);
}
});
it("fetches via url + custom fetchImpl", { timeout: 5_000 }, async () => {
const mod = makeStubModule();
const bytes = buildFakeSf2Bytes(64);
const fetchImpl = async (url: string, _init?: RequestInit) => {
expect(url).toBe("/font.sf2");
return new Response(bytes, { status: 200 });
};
const out = await loadSoundFont({
soundFontUrl: "/font.sf2",
moduleLoader: async () => mod,
fetchImpl,
});
expect(out.bytes.length).toBe(bytes.length);
});
it("propagates AbortSignal to fetch", { timeout: 5_000 }, async () => {
const mod = makeStubModule();
const ac = new AbortController();
ac.abort();
const fetchImpl = async (_url: string, init?: RequestInit) => {
if (init?.signal?.aborted) {
throw new DOMException("aborted", "AbortError");
}
return new Response(buildFakeSf2Bytes(), { status: 200 });
};
try {
await loadSoundFont({
soundFontUrl: "/x.sf2",
signal: ac.signal,
moduleLoader: async () => mod,
fetchImpl,
});
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED
);
}
});
it("propagates a live AbortSignal into the fetch init", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const ac = new AbortController();
let receivedSignal: AbortSignal | undefined;
const fetchImpl = async (_url: string, init?: RequestInit) => {
receivedSignal = init?.signal ?? undefined;
return new Response(buildFakeSf2Bytes(), { status: 200 });
};
await loadSoundFont({
soundFontUrl: "/x.sf2",
signal: ac.signal,
moduleLoader: async () => mod,
fetchImpl,
});
expect(receivedSignal).toBe(ac.signal);
});
it("uses globalThis.fetch when no fetchImpl supplied", {
timeout: 5_000,
}, async () => {
const original = globalThis.fetch;
let called = 0;
globalThis.fetch = (async () => {
called++;
return new Response(buildFakeSf2Bytes(), { status: 200 });
}) as typeof fetch;
try {
const mod = makeStubModule();
await loadSoundFont({
soundFontUrl: "/x.sf2",
moduleLoader: async () => mod,
});
expect(called).toBe(1);
} finally {
globalThis.fetch = original;
}
});
it("non-2xx http response is SOUNDFONT_LOAD_FAILED", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const fetchImpl = async () =>
new Response("not found", { status: 404 });
try {
await loadSoundFont({
soundFontUrl: "/missing.sf2",
moduleLoader: async () => mod,
fetchImpl,
});
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED
);
}
});
it("module-loader failure becomes MODULE_UNAVAILABLE only inside default path", {
timeout: 5_000,
}, async () => {
// When the consumer supplies their own moduleLoader and it
// throws, the error propagates as-is rather than being
// re-wrapped — the wrap-as-MODULE_UNAVAILABLE behaviour is
// specific to the inline default loader.
const sentinel = new Error("inject-fail");
try {
await loadSoundFont({
soundFontBytes: buildFakeSf2Bytes(),
moduleLoader: async () => {
throw sentinel;
},
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBe(sentinel);
}
});
it("wrapModuleImport rewrites a rejection as MODULE_UNAVAILABLE", {
timeout: 5_000,
}, async () => {
try {
await wrapModuleImport(Promise.reject(new Error("nope")));
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.MODULE_UNAVAILABLE
);
}
});
it("wrapModuleImport returns a successful value unchanged", {
timeout: 5_000,
}, async () => {
const out = await wrapModuleImport(Promise.resolve("ok"));
expect(out).toBe("ok");
});
it("default module loader resolves spessasynth_lib + core successfully", {
timeout: 30_000,
}, async () => {
// Drive loadSoundFont without supplying a moduleLoader so
// the default (dynamic-import) path runs. We then
// immediately catch the parser error from the upstream
// library reading our synthetic bytes — getting the parse
// failure proves the default loader successfully resolved
// both `spessasynth_lib` and `spessasynth_core`.
try {
await loadSoundFont({
soundFontBytes: buildFakeSf2Bytes(64),
});
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
SPESSASYNTH_CODES.SOUNDFONT_PARSE_FAILED
);
}
});
it("DLS magic is also accepted", { timeout: 5_000 }, async () => {
const mod = makeStubModule();
const bytes = buildFakeSf2Bytes(64);
// overwrite the sfbk marker with DLS (with trailing space)
bytes[8] = 0x44; // D
bytes[9] = 0x4c; // L
bytes[10] = 0x53; // S
bytes[11] = 0x20; // space
const out = await loadSoundFont({
soundFontBytes: bytes,
moduleLoader: async () => mod,
});
expect((out.bank as { stub: true }).stub).toBe(true);
});
});

View File

@@ -0,0 +1,221 @@
import {
SPESSASYNTH_CODES,
SpessaSynthError,
} from "../error-codes/error-codes.js";
/**
* Minimal structural view of the chunk of `spessasynth_lib` we consume —
* specifically the `SoundBankLoader.fromArrayBuffer` static and the
* `SpessaSynthProcessor` constructor. Declared structurally so the
* package does not take a static dependency on the upstream types.
*/
export interface SpessaSynthModuleLike {
readonly SoundBankLoader: {
fromArrayBuffer(buffer: ArrayBuffer): SoundBankLike;
};
readonly SpessaSynthProcessor: SpessaSynthProcessorCtor;
}
export interface SoundBankLike {
readonly soundBankInfo?: Readonly<Record<string, unknown>>;
}
export interface SpessaSynthProcessorCtor {
new (
sampleRate: number,
opts?: Record<string, unknown>
): SpessaSynthProcessorLike;
}
export interface SpessaSynthProcessorLike {
readonly sampleRate: number;
readonly voiceCount: number;
readonly processorInitialized?: Promise<boolean>;
readonly soundBankManager: {
addSoundBank(
font: SoundBankLike,
id: string,
bankOffset?: number
): void;
};
process(
left: Float32Array,
right: Float32Array,
startIndex?: number,
sampleCount?: number
): void;
noteOn(channel: number, midiNote: number, velocity: number): void;
noteOff(channel: number, midiNote: number): void;
programChange(channel: number, programNumber: number): void;
controllerChange(channel: number, controller: number, value: number): void;
pitchWheel(channel: number, pitch: number, midiNote?: number): void;
polyPressure(channel: number, midiNote: number, pressure: number): void;
channelPressure(channel: number, pressure: number): void;
systemExclusive(syx: Uint8Array | number[], channelOffset?: number): void;
stopAllChannels(force?: boolean): void;
reset(): void;
destroySynthProcessor(): void;
}
/**
* Options accepted by `loadSoundFont`. Exactly one of `soundFontUrl` /
* `soundFontBytes` must be supplied — the function rejects otherwise
* with `SPESSASYNTH.SOUNDFONT_LOAD_FAILED`.
*/
export interface LoadSoundFontOptions {
readonly soundFontUrl?: string;
readonly soundFontBytes?: Uint8Array;
readonly signal?: AbortSignal;
readonly fetchImpl?: (
input: string,
init?: RequestInit
) => Promise<Response>;
readonly moduleLoader?: () => Promise<SpessaSynthModuleLike>;
}
export interface LoadedSoundFont {
readonly bank: SoundBankLike;
readonly module: SpessaSynthModuleLike;
readonly bytes: Uint8Array;
}
const SF2_MAGIC = new Uint8Array([0x52, 0x49, 0x46, 0x46]); // "RIFF"
function hasSfMagic(bytes: Uint8Array): boolean {
if (bytes.length < 12) return false;
for (let i = 0; i < 4; i++) {
if (bytes[i] !== SF2_MAGIC[i]) return false;
}
// sfbk or DLS marker at offset 8. Indices are guaranteed in
// range by the length check above so the read is safe.
const tag = String.fromCharCode(
bytes[8] as number,
bytes[9] as number,
bytes[10] as number,
bytes[11] as number
);
return tag === "sfbk" || tag === "DLS ";
}
/**
* Wraps a dynamic-import promise so a rejection (typically because the
* optional dependency is not installed) becomes a typed
* `SPESSASYNTH.MODULE_UNAVAILABLE` error. Exported for tests; production
* callers go through `defaultModuleLoader`.
*/
export async function wrapModuleImport(
promise: Promise<unknown>
): Promise<unknown> {
try {
return await promise;
} catch (cause) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.MODULE_UNAVAILABLE,
"spessasynth.module-unavailable",
{ cause, context: { module: "spessasynth_lib" } }
);
}
}
async function defaultModuleLoader(): Promise<SpessaSynthModuleLike> {
// Lazy dynamic import — the only places `spessasynth_lib` /
// `spessasynth_core` are referenced statically anywhere in the
// package. `spessasynth_lib` is the public umbrella; it
// re-exports a curated subset and depends on `spessasynth_core`
// for the SF2 parser + audio processor primitives we need. We
// touch the lib to honour the spec's "lazy import of
// spessasynth_lib" requirement and reach through to core for
// `SoundBankLoader` + `SpessaSynthProcessor` since the lib's
// public typings omit them.
await wrapModuleImport(import("spessasynth_lib"));
const core = (await wrapModuleImport(import("spessasynth_core"))) as {
SoundBankLoader: SpessaSynthModuleLike["SoundBankLoader"];
SpessaSynthProcessor: SpessaSynthModuleLike["SpessaSynthProcessor"];
};
return {
SoundBankLoader: core.SoundBankLoader,
SpessaSynthProcessor: core.SpessaSynthProcessor,
};
}
async function readBytes(opts: LoadSoundFontOptions): Promise<Uint8Array> {
if (opts.soundFontBytes !== undefined) {
return opts.soundFontBytes;
}
if (opts.soundFontUrl === undefined) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED,
"spessasynth.no-source",
{ context: { reason: "neither soundFontUrl nor soundFontBytes" } }
);
}
const url = opts.soundFontUrl;
const fetcher = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
const init: RequestInit = {};
if (opts.signal !== undefined) {
init.signal = opts.signal;
}
try {
const res = await fetcher(url, init);
if (!res.ok) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED,
"spessasynth.http-error",
{ context: { url, status: res.status } }
);
}
const ab = await res.arrayBuffer();
return new Uint8Array(ab);
} catch (cause) {
if (cause instanceof SpessaSynthError) throw cause;
throw new SpessaSynthError(
SPESSASYNTH_CODES.SOUNDFONT_LOAD_FAILED,
"spessasynth.fetch-failed",
{ cause, context: { url } }
);
}
}
/**
* Lazy-load `spessasynth_lib`, fetch the SF2/SF3 bytes, validate the
* RIFF/sfbk (or DLS) magic, and parse the bank. Returns the parsed
* `SoundBankLike`, the resolved `SpessaSynthModuleLike` (for processor
* construction), and the raw bytes (so callers may persist them).
*
* Rejects with `SPESSASYNTH.SOUNDFONT_LOAD_FAILED` on fetch/IO errors,
* `SPESSASYNTH.UNSUPPORTED_FORMAT` when the magic bytes are wrong,
* `SPESSASYNTH.SOUNDFONT_PARSE_FAILED` when the bank parser throws,
* and `SPESSASYNTH.MODULE_UNAVAILABLE` if the optional dependency is
* not installed.
*/
export async function loadSoundFont(
opts: LoadSoundFontOptions
): Promise<LoadedSoundFont> {
const moduleLoader = opts.moduleLoader ?? defaultModuleLoader;
const [module, bytes] = await Promise.all([
moduleLoader(),
readBytes(opts),
]);
if (!hasSfMagic(bytes)) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.UNSUPPORTED_FORMAT,
"spessasynth.unsupported-format",
{ context: { byteLength: bytes.length } }
);
}
let bank: SoundBankLike;
try {
const ab = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
bank = module.SoundBankLoader.fromArrayBuffer(ab);
} catch (cause) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.SOUNDFONT_PARSE_FAILED,
"spessasynth.parse-failed",
{ cause, context: { byteLength: bytes.length } }
);
}
return { bank, module, bytes };
}

View File

@@ -0,0 +1,5 @@
export type {
MidiRouterOptions,
ParameterChangeEvent,
} from "./midi-router.js";
export { MidiRouter } from "./midi-router.js";

View File

@@ -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.

View File

@@ -0,0 +1,343 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeStubModule } from "../../tests/stub-module.js";
import type { MidiPortLike } from "../types/midi.types.js";
import { VoiceManager } from "../voice-manager/voice-manager.js";
import { MidiRouter, type ParameterChangeEvent } from "./midi-router.js";
function setup() {
const mod = makeStubModule();
const proc = new mod.SpessaSynthProcessor(48_000);
const vm = new VoiceManager();
vm.attach(proc);
const events: ParameterChangeEvent[] = [];
const router = new MidiRouter({
onParameterChange(e) {
events.push(e);
},
});
router.attach(proc, vm);
return { mod, proc, vm, router, events };
}
describe("MidiRouter", () => {
it("noteOn / noteOff round-trip via voice manager", {
timeout: 5_000,
}, () => {
const { router, proc } = setup();
router.handle({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
expect(proc.voiceCount).toBe(1);
router.handle({ type: "noteOff", channel: 0, note: 60 });
expect(proc.voiceCount).toBe(0);
});
it("noteOn velocity 0 maps to noteOff", { timeout: 5_000 }, () => {
const { router, proc } = setup();
router.handle({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
router.handle({ type: "noteOn", channel: 0, note: 60, velocity: 0 });
expect(proc.voiceCount).toBe(0);
});
it("programChange forwards to processor", { timeout: 5_000 }, () => {
const { router, proc } = setup();
router.handle({ type: "programChange", channel: 0, program: 24 });
expect(proc.observer.programs.get(0)).toBe(24);
});
it("controlChange forwards to processor", { timeout: 5_000 }, () => {
const { router, proc } = setup();
router.handle({
type: "controlChange",
channel: 1,
controller: 7,
value: 64,
});
expect(
proc.observer.cc.some(
(e) => e.channel === 1 && e.controller === 7 && e.value === 64
)
).toBe(true);
});
it("NRPN MSB+LSB+data fires parameter-change event", {
timeout: 5_000,
}, () => {
const { router, events } = setup();
router.handle({
type: "controlChange",
channel: 0,
controller: 99,
value: 0x65,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 98,
value: 0x4c,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 6,
value: 80,
});
expect(
events.some(
(e) =>
e.kind === "nrpn" &&
e.paramHigh === 0x65 &&
e.paramLow === 0x4c &&
e.dataHigh === 80
)
).toBe(true);
});
it("RPN sequence fires rpn-kind event", { timeout: 5_000 }, () => {
const { router, events } = setup();
router.handle({
type: "controlChange",
channel: 0,
controller: 101,
value: 0,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 100,
value: 2,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 6,
value: 64,
});
expect(
events.some(
(e) =>
e.kind === "rpn" &&
e.paramHigh === 0 &&
e.paramLow === 2 &&
e.dataHigh === 64
)
).toBe(true);
});
it("CC#38 data-LSB fires a parameter-change event", {
timeout: 5_000,
}, () => {
const { router, events } = setup();
router.handle({
type: "controlChange",
channel: 0,
controller: 99,
value: 1,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 38,
value: 7,
});
const evt = events.find((e) => e.dataLow === 7);
expect(evt?.kind).toBe("nrpn");
});
it("data inc / dec adjust the data high byte", { timeout: 5_000 }, () => {
const { router, events } = setup();
router.handle({
type: "controlChange",
channel: 0,
controller: 99,
value: 1,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 96,
value: 0,
}); // inc
router.handle({
type: "controlChange",
channel: 0,
controller: 96,
value: 0,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 97,
value: 0,
}); // dec
const lastNrpn = events.filter((e) => e.kind === "nrpn").at(-1)!;
expect(lastNrpn.dataHigh).toBe(1);
});
it("pitchBend, channelPressure, polyPressure forward correctly", {
timeout: 5_000,
}, () => {
const { router, proc } = setup();
router.handle({ type: "pitchBend", channel: 0, value: 9000 });
router.handle({ type: "channelPressure", channel: 1, pressure: 80 });
router.handle({
type: "polyPressure",
channel: 2,
note: 60,
pressure: 64,
});
expect(proc.observer.pitchBend).toEqual([{ channel: 0, value: 9000 }]);
expect(proc.observer.channelPressure).toEqual([
{ channel: 1, pressure: 80 },
]);
expect(proc.observer.polyPressure).toEqual([
{ channel: 2, note: 60, pressure: 64 },
]);
});
it("sysex forwards bytes to processor", { timeout: 5_000 }, () => {
const { router, proc } = setup();
router.handle({
type: "sysex",
data: new Uint8Array([0xf0, 0x7e, 0x7f, 0x09, 0x01, 0xf7]),
});
expect(proc.observer.sysex.length).toBe(1);
expect(proc.observer.sysex[0]!.length).toBe(6);
});
it("connectPort subscribes + unsubscribe stops delivery", {
timeout: 5_000,
}, () => {
const { router, proc } = setup();
const listeners: Array<
(m: import("../types/midi.types.js").MidiMessage) => void
> = [];
const port: MidiPortLike = {
onMessage(l) {
listeners.push(l);
return () => {
const idx = listeners.indexOf(l);
if (idx >= 0) listeners.splice(idx, 1);
};
},
};
const disconnect = router.connectPort(port);
listeners[0]!({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
expect(proc.voiceCount).toBe(1);
disconnect();
expect(listeners.length).toBe(0);
});
it("CC on an out-of-range channel is forwarded without NRPN bookkeeping", {
timeout: 5_000,
}, () => {
const { router, proc } = setup();
router.handle({
type: "controlChange",
channel: 17,
controller: 7,
value: 64,
});
expect(proc.observer.cc.length).toBe(1);
});
it("CC#6 data-MSB with no prior NRPN/RPN does not fire observer", {
timeout: 5_000,
}, () => {
const { router, events } = setup();
router.handle({
type: "controlChange",
channel: 0,
controller: 6,
value: 12,
});
expect(events.length).toBe(0);
});
it("connectPort unsubscribe twice is idempotent", {
timeout: 5_000,
}, () => {
const { router } = setup();
const port: MidiPortLike = {
onMessage() {
return () => undefined;
},
};
const off = router.connectPort(port);
off();
off();
});
it("messages dropped silently before attach", { timeout: 5_000 }, () => {
const router = new MidiRouter();
router.handle({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
});
it("detach clears the connected ports", { timeout: 5_000 }, () => {
const { router, proc } = setup();
const port: MidiPortLike = {
onMessage() {
return () => undefined;
},
};
router.connectPort(port);
router.detach();
// After detach handle() is a no-op
router.handle({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
expect(proc.voiceCount).toBe(0);
});
it("RPN sequence without onParameterChange option is a no-op observer-side", {
timeout: 5_000,
}, () => {
const mod = makeStubModule();
const proc = new mod.SpessaSynthProcessor(48_000);
const vm = new VoiceManager();
vm.attach(proc);
const router = new MidiRouter();
router.attach(proc, vm);
router.handle({
type: "controlChange",
channel: 0,
controller: 101,
value: 0,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 100,
value: 2,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 6,
value: 64,
});
// No callback throws; processor still sees three CCs.
expect(proc.observer.cc.length).toBe(3);
});
it("router without onParameterChange option still handles NRPN", {
timeout: 5_000,
}, () => {
const mod = makeStubModule();
const proc = new mod.SpessaSynthProcessor(48_000);
const vm = new VoiceManager();
vm.attach(proc);
const router = new MidiRouter();
router.attach(proc, vm);
router.handle({
type: "controlChange",
channel: 0,
controller: 99,
value: 1,
});
router.handle({
type: "controlChange",
channel: 0,
controller: 6,
value: 64,
});
// no throw, processor sees both CCs
expect(proc.observer.cc.length).toBe(2);
});
});

View File

@@ -0,0 +1,234 @@
import type { SpessaSynthProcessorLike } from "../loader/soundfont-loader.js";
import type { MidiMessage, MidiPortLike } from "../types/midi.types.js";
import type { VoiceManager } from "../voice-manager/voice-manager.js";
/** CC numbers used to assemble NRPN / RPN parameter selects + data. */
const CC_NRPN_MSB = 99;
const CC_NRPN_LSB = 98;
const CC_RPN_MSB = 101;
const CC_RPN_LSB = 100;
const CC_DATA_MSB = 6;
const CC_DATA_LSB = 38;
const CC_DATA_INC = 96;
const CC_DATA_DEC = 97;
/**
* NRPN / RPN observation event surfaced to consumers that want to log
* or react to high-resolution parameter writes. The router still
* forwards every individual CC to the synth — these observations are
* an additional read-only signal layered on top.
*/
export interface ParameterChangeEvent {
readonly kind: "nrpn" | "rpn";
readonly channel: number;
readonly paramHigh: number;
readonly paramLow: number;
readonly dataHigh: number;
readonly dataLow: number;
}
export interface MidiRouterOptions {
readonly onParameterChange?: (event: ParameterChangeEvent) => void;
}
interface ChannelParamState {
nrpnHigh: number;
nrpnLow: number;
rpnHigh: number;
rpnLow: number;
dataHigh: number;
dataLow: number;
/** Which kind the next data write should fire against. */
active: "nrpn" | "rpn" | "none";
}
function defaultChannelParamState(): ChannelParamState {
return {
nrpnHigh: 0x7f,
nrpnLow: 0x7f,
rpnHigh: 0x7f,
rpnLow: 0x7f,
dataHigh: 0,
dataLow: 0,
active: "none",
};
}
/**
* Routes `MidiMessage` events from any `MidiPortLike` into a
* `SpessaSynthProcessor`, with voice-manager-mediated note-on /
* note-off so the polyphony cap and per-channel voice tracking stay
* coherent.
*
* NRPN / RPN reassembly is performed in software so consumers can
* observe high-resolution parameter writes (per-note tuning, master
* fine-tune, etc.); the raw CCs are also forwarded to the synth so the
* upstream library handles the actual sound-side effect.
*/
export class MidiRouter {
private processor: SpessaSynthProcessorLike | undefined;
private voiceManager: VoiceManager | undefined;
private readonly options: MidiRouterOptions;
private readonly channels: ChannelParamState[] = Array.from(
{ length: 16 },
defaultChannelParamState
);
private readonly portUnsubscribes: Array<() => void> = [];
public constructor(options: MidiRouterOptions = {}) {
this.options = options;
}
public attach(
processor: SpessaSynthProcessorLike,
voiceManager: VoiceManager
): void {
this.processor = processor;
this.voiceManager = voiceManager;
}
public detach(): void {
for (const off of this.portUnsubscribes) off();
this.portUnsubscribes.length = 0;
this.processor = undefined;
this.voiceManager = undefined;
}
/** Subscribe the router to a `MidiPortLike`'s message stream. */
public connectPort(port: MidiPortLike): () => void {
const off = port.onMessage((m) => this.handle(m));
this.portUnsubscribes.push(off);
return () => {
const idx = this.portUnsubscribes.indexOf(off);
if (idx >= 0) this.portUnsubscribes.splice(idx, 1);
off();
};
}
/** Direct entry point — usable without a `MidiPortLike`. */
public handle(message: MidiMessage): void {
if (!this.processor || !this.voiceManager) {
// Drop messages received before attach(); the plugin's
// setup() guarantees the call ordering.
return;
}
switch (message.type) {
case "noteOn":
if (message.velocity === 0) {
this.voiceManager.noteOff(message.channel, message.note);
} else {
this.voiceManager.noteOn(
message.channel,
message.note,
message.velocity
);
}
return;
case "noteOff":
this.voiceManager.noteOff(message.channel, message.note);
return;
case "controlChange":
this.handleControlChange(
message.channel,
message.controller,
message.value
);
return;
case "programChange":
this.processor.programChange(message.channel, message.program);
return;
case "pitchBend":
this.processor.pitchWheel(message.channel, message.value);
return;
case "channelPressure":
this.processor.channelPressure(
message.channel,
message.pressure
);
return;
case "polyPressure":
this.processor.polyPressure(
message.channel,
message.note,
message.pressure
);
return;
case "sysex":
this.processor.systemExclusive(message.data, 0);
return;
}
}
private handleControlChange(
channel: number,
controller: number,
value: number
): void {
const state = this.channels[channel];
if (state) {
switch (controller) {
case CC_NRPN_MSB:
state.nrpnHigh = value;
state.active = "nrpn";
break;
case CC_NRPN_LSB:
state.nrpnLow = value;
state.active = "nrpn";
break;
case CC_RPN_MSB:
state.rpnHigh = value;
state.active = "rpn";
break;
case CC_RPN_LSB:
state.rpnLow = value;
state.active = "rpn";
break;
case CC_DATA_MSB:
state.dataHigh = value;
this.fireParameterChange(channel, state);
break;
case CC_DATA_LSB:
state.dataLow = value;
this.fireParameterChange(channel, state);
break;
case CC_DATA_INC:
state.dataHigh = Math.min(127, state.dataHigh + 1);
this.fireParameterChange(channel, state);
break;
case CC_DATA_DEC:
state.dataHigh = Math.max(0, state.dataHigh - 1);
this.fireParameterChange(channel, state);
break;
default:
break;
}
}
this.processor!.controllerChange(channel, controller, value);
}
private fireParameterChange(
channel: number,
state: ChannelParamState
): void {
if (!this.options.onParameterChange) return;
if (state.active === "nrpn") {
this.options.onParameterChange({
kind: "nrpn",
channel,
paramHigh: state.nrpnHigh,
paramLow: state.nrpnLow,
dataHigh: state.dataHigh,
dataLow: state.dataLow,
});
} else if (state.active === "rpn") {
this.options.onParameterChange({
kind: "rpn",
channel,
paramHigh: state.rpnHigh,
paramLow: state.rpnLow,
dataHigh: state.dataHigh,
dataLow: state.dataLow,
});
}
}
}

View File

@@ -0,0 +1,11 @@
export type { ChannelParamKind } from "./parameters.js";
export {
assertChannel,
buildParameterDeclarations,
CHANNEL_COUNT,
CHANNEL_PARAM_KINDS,
channelParameterId,
MASTER_GAIN_ID,
MAX_CHANNEL_INDEX,
parseChannelParameterId,
} from "./parameters.js";

View File

@@ -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.

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SpessaSynthError } from "../error-codes/error-codes.js";
import {
assertChannel,
buildParameterDeclarations,
CHANNEL_COUNT,
CHANNEL_PARAM_KINDS,
channelParameterId,
MASTER_GAIN_ID,
MAX_CHANNEL_INDEX,
parseChannelParameterId,
} from "./parameters.js";
describe("parameters", () => {
it("declares 16 × 5 + master = 81 parameters", { timeout: 5_000 }, () => {
const params = buildParameterDeclarations();
expect(params.length).toBe(CHANNEL_COUNT * 5 + 1);
expect(params[params.length - 1]!.id).toBe(MASTER_GAIN_ID);
});
it("every per-channel id parses back to channel + kind", {
timeout: 5_000,
}, () => {
for (let ch = 0; ch < CHANNEL_COUNT; ch++) {
for (const kind of CHANNEL_PARAM_KINDS) {
const id = channelParameterId(ch, kind);
expect(parseChannelParameterId(id)).toEqual({
channel: ch,
kind,
});
}
}
});
it("rejects non-channel ids", { timeout: 5_000 }, () => {
expect(parseChannelParameterId("master/gain")).toBeUndefined();
expect(parseChannelParameterId("channel/")).toBeUndefined();
expect(parseChannelParameterId("channel/0")).toBeUndefined();
expect(parseChannelParameterId("channel/abc/volume")).toBeUndefined();
expect(parseChannelParameterId("channel/99/volume")).toBeUndefined();
expect(parseChannelParameterId("channel/0/unknown")).toBeUndefined();
});
it("program parameter is non-automatable", { timeout: 5_000 }, () => {
const params = buildParameterDeclarations();
const program = params.find(
(p) => p.id === channelParameterId(0, "program")
)!;
expect(program.automatable).toBe(false);
});
it("volume and pan parameters are automatable", { timeout: 5_000 }, () => {
const params = buildParameterDeclarations();
for (let ch = 0; ch < CHANNEL_COUNT; ch++) {
expect(
params.find((p) => p.id === channelParameterId(ch, "volume"))!
.automatable
).toBe(true);
expect(
params.find((p) => p.id === channelParameterId(ch, "pan"))!
.automatable
).toBe(true);
}
});
it("master gain has range [0, 4]", { timeout: 5_000 }, () => {
const params = buildParameterDeclarations();
const master = params.find((p) => p.id === MASTER_GAIN_ID)!;
expect(master.min).toBe(0);
expect(master.max).toBe(4);
});
it("assertChannel passes valid channels", { timeout: 5_000 }, () => {
for (let ch = 0; ch <= MAX_CHANNEL_INDEX; ch++) {
assertChannel(ch);
}
});
it("assertChannel rejects out-of-range and non-integer", {
timeout: 5_000,
}, () => {
for (const bad of [-1, 16, 16.5, Number.NaN]) {
try {
assertChannel(bad);
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.INVALID_CHANNEL"
);
}
}
});
});

View File

@@ -0,0 +1,149 @@
import type { PluginParameter } from "@suaveplan/audio-fx-plugin-base/base";
import {
SPESSASYNTH_CODES,
SpessaSynthError,
} from "../error-codes/error-codes.js";
export const CHANNEL_COUNT = 16;
export const MAX_CHANNEL_INDEX = CHANNEL_COUNT - 1;
/** Per-channel parameter kind. */
export type ChannelParamKind =
| "volume"
| "pan"
| "program"
| "reverbSend"
| "chorusSend";
/** Closed enum of per-channel kinds in declaration order. */
export const CHANNEL_PARAM_KINDS: ReadonlyArray<ChannelParamKind> = [
"volume",
"pan",
"program",
"reverbSend",
"chorusSend",
];
/** Build a deterministic parameter id from a channel + kind. */
export function channelParameterId(
channel: number,
kind: ChannelParamKind
): string {
return `channel/${channel}/${kind}`;
}
/** Master-gain parameter id. */
export const MASTER_GAIN_ID = "master/gain";
/**
* Build the full 16-channel × 5-kind + master parameter declaration
* the `AudioFxPlugin` constructor consumes. Every parameter is
* `automatable: true` so the host chain can drive it.
*/
export function buildParameterDeclarations(): ReadonlyArray<PluginParameter> {
const out: PluginParameter[] = [];
for (let ch = 0; ch < CHANNEL_COUNT; ch++) {
out.push({
id: channelParameterId(ch, "volume"),
name: `Ch ${ch + 1} Volume`,
value: 1,
min: 0,
max: 1,
default: 1,
unit: "ratio",
automatable: true,
});
out.push({
id: channelParameterId(ch, "pan"),
name: `Ch ${ch + 1} Pan`,
value: 0,
min: -1,
max: 1,
default: 0,
unit: "ratio",
automatable: true,
});
out.push({
id: channelParameterId(ch, "program"),
name: `Ch ${ch + 1} Program`,
value: 0,
min: 0,
max: 127,
default: 0,
unit: "enum",
automatable: false,
});
out.push({
id: channelParameterId(ch, "reverbSend"),
name: `Ch ${ch + 1} Reverb Send`,
value: 0,
min: 0,
max: 1,
default: 0,
unit: "ratio",
automatable: true,
});
out.push({
id: channelParameterId(ch, "chorusSend"),
name: `Ch ${ch + 1} Chorus Send`,
value: 0,
min: 0,
max: 1,
default: 0,
unit: "ratio",
automatable: true,
});
}
out.push({
id: MASTER_GAIN_ID,
name: "Master Gain",
value: 1,
min: 0,
max: 4,
default: 1,
unit: "ratio",
automatable: true,
});
return out;
}
/**
* Parse a `channel/<n>/<kind>` parameter id into its components. Returns
* `undefined` for the master id or any unrecognised pattern; the caller
* decides whether that's an error.
*/
export function parseChannelParameterId(
id: string
): { channel: number; kind: ChannelParamKind } | undefined {
if (!id.startsWith("channel/")) return undefined;
const rest = id.slice("channel/".length);
const slash = rest.indexOf("/");
if (slash < 0) return undefined;
const channelStr = rest.slice(0, slash);
const kind = rest.slice(slash + 1) as ChannelParamKind;
const channel = Number.parseInt(channelStr, 10);
if (
!Number.isInteger(channel) ||
channel < 0 ||
channel > MAX_CHANNEL_INDEX
) {
return undefined;
}
if (!CHANNEL_PARAM_KINDS.includes(kind)) return undefined;
return { channel, kind };
}
/** Throws `SPESSASYNTH.INVALID_CHANNEL` when channel is out of range. */
export function assertChannel(channel: number): void {
if (
!Number.isInteger(channel) ||
channel < 0 ||
channel > MAX_CHANNEL_INDEX
) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.INVALID_CHANNEL,
"spessasynth.invalid-channel",
{ context: { channel } }
);
}
}

View File

@@ -0,0 +1,9 @@
export {
nowMicros,
parseChannelParameterId,
type SpessaSynthInitOptions,
SpessaSynthPlugin,
type SpessaSynthPluginOptions,
type SpessaSynthPresetData,
SpessaSynthPresetDataSchema,
} from "./spessasynth-plugin.js";

View File

@@ -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.

View File

@@ -0,0 +1,489 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type {
ChainContext,
ChainHandle,
} from "@suaveplan/audio-fx-plugin-base/base";
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildFakeSf2Bytes, makeStubModule } from "../../tests/stub-module.js";
import type { SpessaSynthError } from "../error-codes/error-codes.js";
import { nowMicros, SpessaSynthPlugin } from "./spessasynth-plugin.js";
const SR = 48_000;
const BLOCK = 64;
function newCtx(): ChainContext {
const chain: ChainHandle = {
id: "test",
name: "test",
channels: 2,
sampleRate: SR,
blockSize: BLOCK,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
function newConfig(): ProcessorConfig {
return { sampleRate: SR, channels: 2, blockSize: BLOCK };
}
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: SR,
channels: 2,
opts: {},
};
}
async function newInitedPlugin(): Promise<{
plugin: SpessaSynthPlugin;
mod: ReturnType<typeof makeStubModule>;
}> {
const mod = makeStubModule();
const plugin = new SpessaSynthPlugin({ moduleLoader: async () => mod });
await plugin.init({
soundFontBytes: buildFakeSf2Bytes(64),
sampleRate: SR,
});
await plugin.setup(newConfig(), newCtx());
return { plugin, mod };
}
describe("nowMicros", () => {
it("uses performance.now() when available", { timeout: 5_000 }, () => {
const out = nowMicros({ performance: { now: () => 5 } });
expect(out).toBe(5_000);
});
it("falls back to Date.now() when performance is absent", {
timeout: 5_000,
}, () => {
const out = nowMicros({});
expect(out).toBeGreaterThan(0);
});
});
describe("SpessaSynthPlugin: contract", () => {
it("declares 81 parameters and the source IO shape", {
timeout: 5_000,
}, () => {
const p = new SpessaSynthPlugin();
expect(p.metadata.id).toBe("audio-fx.spessasynth");
expect(p.metadata.category).toBe("synthesis");
expect(p.io.inputs[0]?.count).toBe(0);
expect(p.io.outputs[0]?.count).toBe(2);
expect(p.parameters.length).toBe(16 * 5 + 1);
});
it("initialMaxPolyphony option is honoured", { timeout: 5_000 }, () => {
const p = new SpessaSynthPlugin({ initialMaxPolyphony: 12 });
expect(p.getMaxPolyphony()).toBe(12);
});
it("process before init throws NOT_INITIALIZED", { timeout: 5_000 }, () => {
const p = new SpessaSynthPlugin();
try {
p.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.NOT_INITIALIZED"
);
}
});
});
describe("SpessaSynthPlugin: lifecycle", () => {
it("init + setup + process renders without throwing", {
timeout: 5_000,
}, async () => {
const { plugin } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, blockCtx());
let rms = 0;
for (let i = 0; i < BLOCK; i++) rms += out[0]![i]! ** 2;
rms = Math.sqrt(rms / BLOCK);
expect(rms).toBeGreaterThan(0);
});
it("allNotesOff global stops everything", { timeout: 5_000 }, async () => {
const { plugin } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
plugin.sendMidi({
type: "noteOn",
channel: 1,
note: 64,
velocity: 100,
});
plugin.allNotesOff();
expect(plugin.getActiveVoiceCount()).toBe(0);
});
it("allNotesOff per-channel only stops that channel", {
timeout: 5_000,
}, async () => {
const { plugin } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
plugin.sendMidi({
type: "noteOn",
channel: 1,
note: 64,
velocity: 100,
});
plugin.allNotesOff(1);
expect(plugin.getActiveVoiceCount()).toBe(1);
});
it("setMaxPolyphony forwards through the plugin", {
timeout: 5_000,
}, () => {
const p = new SpessaSynthPlugin();
p.setMaxPolyphony(7);
expect(p.getMaxPolyphony()).toBe(7);
});
it("reset stops voices and re-initialises the synth", {
timeout: 5_000,
}, async () => {
const { plugin, mod } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
plugin.reset();
const proc = (
mod as {
lastProcessor?: {
observer: { resetCount: number; stopAllCount: number };
};
}
).lastProcessor;
expect(proc?.observer.resetCount).toBeGreaterThanOrEqual(1);
expect(proc?.observer.stopAllCount).toBeGreaterThanOrEqual(1);
});
it("init without sampleRate defaults to 48_000", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const plugin = new SpessaSynthPlugin({ moduleLoader: async () => mod });
await plugin.init({
soundFontBytes: buildFakeSf2Bytes(64),
});
const proc = (mod as { lastProcessor?: { sampleRate: number } })
.lastProcessor;
expect(proc?.sampleRate).toBe(48_000);
});
it("reset is safe to call before init (no processor attached)", {
timeout: 5_000,
}, async () => {
const p = new SpessaSynthPlugin();
await p.setup(newConfig(), newCtx());
p.reset();
});
it("dispose is safe to call before init", { timeout: 5_000 }, () => {
const p = new SpessaSynthPlugin();
p.dispose();
});
it("dispose tears down the processor", { timeout: 5_000 }, async () => {
const { plugin, mod } = await newInitedPlugin();
plugin.dispose();
const proc = (
mod as { lastProcessor?: { observer: { destroyed: boolean } } }
).lastProcessor;
expect(proc?.observer.destroyed).toBe(true);
});
});
describe("SpessaSynthPlugin: snapshot/restore", () => {
it("snapshot includes parameters + plugin-specific data", {
timeout: 5_000,
}, async () => {
const { plugin } = await newInitedPlugin();
plugin.setMaxPolyphony(32);
plugin.setParameter("channel/0/volume", 0.5);
plugin.setParameter("channel/3/pan", 0.5);
const snap = plugin.getPreset();
expect(snap.pluginType).toBe("audio-fx.spessasynth");
expect(snap.parameters["channel/0/volume"]).toBe(0.5);
expect(snap.parameters["channel/3/pan"]).toBe(0.5);
expect((snap.data as { maxPolyphony: number }).maxPolyphony).toBe(32);
});
it("restore replays parameters and polyphony", {
timeout: 5_000,
}, async () => {
const { plugin: a } = await newInitedPlugin();
a.setMaxPolyphony(48);
a.setParameter("channel/0/volume", 0.25);
const snap = a.getPreset();
const { plugin: b } = await newInitedPlugin();
b.applyPreset(snap);
expect(b.getMaxPolyphony()).toBe(48);
expect(b.getParameter("channel/0/volume")).toBe(0.25);
});
it("snapshot captures soundFontUrl when init used URL", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const plugin = new SpessaSynthPlugin({ moduleLoader: async () => mod });
await plugin.init({
soundFontUrl: "/fixture.sf2",
sampleRate: SR,
fetchImpl: async () =>
new Response(buildFakeSf2Bytes(), { status: 200 }),
});
await plugin.setup(newConfig(), newCtx());
const snap = plugin.getPreset();
expect((snap.data as { soundFontUrl?: string }).soundFontUrl).toBe(
"/fixture.sf2"
);
expect(plugin.getSoundFontUrl()).toBe("/fixture.sf2");
});
});
describe("SpessaSynthPlugin: process loop", () => {
it("flushes parameters to the synth on every block", {
timeout: 5_000,
}, async () => {
const { plugin, mod } = await newInitedPlugin();
plugin.setParameter("channel/0/volume", 0.5);
plugin.setParameter("channel/0/pan", -0.5);
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, blockCtx());
const proc = (
mod as {
lastProcessor?: {
observer: {
cc: Array<{
channel: number;
controller: number;
value: number;
}>;
};
};
}
).lastProcessor;
const ch0Volume = proc?.observer.cc.find(
(e) => e.channel === 0 && e.controller === 7
);
expect(ch0Volume?.value).toBe(64); // 0.5 * 127 = 63.5 → round 64
});
it("master gain scales the output amplitude", {
timeout: 5_000,
}, async () => {
const { plugin } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
const out1: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out1, blockCtx());
let rms1 = 0;
for (let i = 0; i < BLOCK; i++) rms1 += out1[0]![i]! ** 2;
rms1 = Math.sqrt(rms1 / BLOCK);
plugin.setParameter("master/gain", 2);
const out2: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out2, blockCtx());
let rms2 = 0;
for (let i = 0; i < BLOCK; i++) rms2 += out2[0]![i]! ** 2;
rms2 = Math.sqrt(rms2 / BLOCK);
expect(rms2).toBeGreaterThan(rms1);
});
it("stereo channels carry identical content from the synth", {
timeout: 5_000,
}, async () => {
const { plugin } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, blockCtx());
// Stub synth writes the same signal to L and R.
for (let i = 0; i < BLOCK; i++) {
expect(out[1]![i]).toBe(out[0]![i]);
}
});
it("scratch buffer resizes when blockSize changes between blocks", {
timeout: 5_000,
}, async () => {
const { plugin } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
const out: Float32Array[] = [
new Float32Array(128),
new Float32Array(128),
];
plugin.process([], out, blockCtx());
expect(out[0]!.some((v) => v !== 0)).toBe(true);
});
it("setParameter on a program control flushes program-change to synth", {
timeout: 5_000,
}, async () => {
const { plugin, mod } = await newInitedPlugin();
plugin.setParameter("channel/0/program", 24);
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, blockCtx());
const proc = (
mod as {
lastProcessor?: {
observer: { programs: Map<number, number> };
};
}
).lastProcessor;
expect(proc?.observer.programs.get(0)).toBe(24);
});
it("setParameter on non-program control does NOT mark program dirty", {
timeout: 5_000,
}, async () => {
const { plugin, mod } = await newInitedPlugin();
plugin.setParameter("channel/0/volume", 0.5);
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, blockCtx());
const proc = (
mod as {
lastProcessor?: {
observer: { programs: Map<number, number> };
};
}
).lastProcessor;
expect(proc?.observer.programs.has(0)).toBe(false);
});
it("applyPreset with empty plugin-specific data is a no-op", {
timeout: 5_000,
}, () => {
const p = new SpessaSynthPlugin();
const initialMax = p.getMaxPolyphony();
p.applyPreset({
schemaVersion: 1,
pluginType: "audio-fx.spessasynth",
parameters: {},
data: {},
});
expect(p.getMaxPolyphony()).toBe(initialMax);
});
it("applyPreset with omitted data falls through to defaults", {
timeout: 5_000,
}, () => {
const p = new SpessaSynthPlugin();
p.applyPreset({
schemaVersion: 1,
pluginType: "audio-fx.spessasynth",
parameters: {},
});
expect(p.getSoundFontUrl()).toBeUndefined();
});
it("applyPreset restores soundFontUrl and hasInlineSoundFont fields", {
timeout: 5_000,
}, async () => {
const { plugin: a } = await newInitedPlugin();
a.setParameter("channel/0/volume", 0.5);
const snap = a.getPreset();
const data = snap.data as Record<string, unknown>;
const mutated = {
...snap,
data: {
...data,
soundFontUrl: "/restored.sf2",
hasInlineSoundFont: true,
},
};
const { plugin: b } = await newInitedPlugin();
b.applyPreset(mutated);
expect(b.getSoundFontUrl()).toBe("/restored.sf2");
});
it("sendMidi noteOn updates voice count + telemetry", {
timeout: 5_000,
}, async () => {
const { plugin } = await newInitedPlugin();
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 64,
velocity: 100,
});
expect(plugin.getActiveVoiceCount()).toBe(2);
});
});

View File

@@ -0,0 +1,345 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type { ChainContext } from "@suaveplan/audio-fx-plugin-base/base";
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
import z from "zod";
import {
SPESSASYNTH_CODES,
SpessaSynthError,
} from "../error-codes/error-codes.js";
import {
type LoadSoundFontOptions,
loadSoundFont,
type SpessaSynthModuleLike,
type SpessaSynthProcessorLike,
} from "../loader/soundfont-loader.js";
import { MidiRouter } from "../midi-router/midi-router.js";
import {
buildParameterDeclarations,
CHANNEL_COUNT,
channelParameterId,
MASTER_GAIN_ID,
parseChannelParameterId,
} from "../parameters/parameters.js";
import {
recordProcessDuration,
recordVoicesTriggered,
setActiveVoices,
} from "../telemetry/telemetry.js";
import type { MidiMessage } from "../types/midi.types.js";
import { VoiceManager } from "../voice-manager/voice-manager.js";
const PLUGIN_ID = "audio-fx.spessasynth";
const SCHEMA_VERSION = 1;
/**
* Plugin-specific data persisted inside the `PresetSnapshot.data` field.
* Captures the loaded SoundFont URL (or a marker that bytes were used)
* and the polyphony cap so a restored instance can re-load and re-cap.
* Per-channel parameter values are persisted in the standard
* `PresetSnapshot.parameters` map by the base class.
*/
export const SpessaSynthPresetDataSchema = z
.object({
soundFontUrl: z.string().optional(),
hasInlineSoundFont: z.boolean().optional(),
maxPolyphony: z.number().int().min(1).max(1024).optional(),
})
.strict();
export type SpessaSynthPresetData = z.infer<typeof SpessaSynthPresetDataSchema>;
export interface SpessaSynthPluginOptions {
readonly initialMaxPolyphony?: number;
readonly moduleLoader?: () => Promise<SpessaSynthModuleLike>;
}
export interface SpessaSynthInitOptions extends LoadSoundFontOptions {
/** Optional explicit sample rate; otherwise inferred from `setup()`. */
readonly sampleRate?: number;
/**
* Upper bound on the number of samples a single `process()` block may
* write into the output buffers. Defaults to 4096 — sufficient for
* any standard chain block size; raise it for offline batch renders.
*/
readonly maxBufferSize?: number;
}
/**
* `AudioFxPlugin` implementation wrapping the SpessaSynth SF2/SF3 engine.
* Source plugin (no inputs, stereo output). The plugin must be `init()`-ed
* with a SoundFont before the first `process()` block; calls before init
* raise `SPESSASYNTH.NOT_INITIALIZED`.
*/
export class SpessaSynthPlugin extends AbstractAudioFxPlugin {
private readonly voiceManager = new VoiceManager();
private readonly router: MidiRouter;
private readonly options: SpessaSynthPluginOptions;
private module: SpessaSynthModuleLike | undefined;
private processor: SpessaSynthProcessorLike | undefined;
private soundFontUrl: string | undefined;
private hadInlineSoundFont = false;
private scratchLeft: Float32Array | undefined;
private scratchRight: Float32Array | undefined;
private readonly dirtyProgramChannels = new Set<number>();
public constructor(options: SpessaSynthPluginOptions = {}) {
super({
metadata: {
id: PLUGIN_ID,
name: "SpessaSynth SF2 Synth",
version: "0.1.0",
category: "synthesis",
},
schema: SpessaSynthPresetDataSchema,
io: {
inputs: [{ count: 0 }],
outputs: [{ count: 2 }],
},
parameters: buildParameterDeclarations(),
supportedSchemaVersion: SCHEMA_VERSION,
});
this.options = options;
this.router = new MidiRouter();
if (options.initialMaxPolyphony !== undefined) {
this.voiceManager.setMaxPolyphony(options.initialMaxPolyphony);
}
}
/**
* Lazy-load the optional dependency, fetch + parse the SoundFont,
* and stand up the upstream processor. Must be awaited before
* `setup()` / `process()`.
*/
public async init(opts: SpessaSynthInitOptions): Promise<void> {
const loaded = await loadSoundFont({
...opts,
...(this.options.moduleLoader !== undefined
? { moduleLoader: this.options.moduleLoader }
: {}),
});
this.module = loaded.module;
this.soundFontUrl = opts.soundFontUrl;
this.hadInlineSoundFont = opts.soundFontBytes !== undefined;
const sampleRate = opts.sampleRate ?? this.config?.sampleRate ?? 48_000;
const processor = new this.module.SpessaSynthProcessor(sampleRate, {
maxBufferSize: opts.maxBufferSize ?? 4096,
});
if (processor.processorInitialized !== undefined) {
await processor.processorInitialized;
}
processor.soundBankManager.addSoundBank(loaded.bank, "main", 0);
this.processor = processor;
this.voiceManager.attach(processor);
this.router.attach(processor, this.voiceManager);
}
/** Feed a single MIDI message into the synth. */
public sendMidi(message: MidiMessage): void {
this.requireProcessor();
this.router.handle(message);
if (message.type === "noteOn" && message.velocity > 0) {
recordVoicesTriggered(1);
}
}
public getActiveVoiceCount(): number {
return this.voiceManager.getActiveVoiceCount();
}
public setMaxPolyphony(n: number): void {
this.voiceManager.setMaxPolyphony(n);
}
public getMaxPolyphony(): number {
return this.voiceManager.getMaxPolyphony();
}
public allNotesOff(channel?: number): void {
this.requireProcessor();
if (channel === undefined) {
this.voiceManager.allNotesOff();
} else {
this.voiceManager.allNotesOff(channel);
}
}
public getSoundFontUrl(): string | undefined {
return this.soundFontUrl;
}
protected override setupState(
config: ProcessorConfig,
_chain: ChainContext
): void {
this.scratchLeft = new Float32Array(config.blockSize);
this.scratchRight = new Float32Array(config.blockSize);
}
protected override resetState(): void {
if (this.processor) {
this.processor.stopAllChannels(true);
this.processor.reset();
}
}
protected override disposeState(): void {
this.router.detach();
this.voiceManager.detach();
if (this.processor) {
this.processor.destroySynthProcessor();
}
this.processor = undefined;
this.module = undefined;
this.scratchLeft = undefined;
this.scratchRight = undefined;
}
protected override getPresetData(): SpessaSynthPresetData {
const data: SpessaSynthPresetData = {
maxPolyphony: this.voiceManager.getMaxPolyphony(),
hasInlineSoundFont: this.hadInlineSoundFont,
};
if (this.soundFontUrl !== undefined) {
return { ...data, soundFontUrl: this.soundFontUrl };
}
return data;
}
protected override applyPresetData(raw: unknown): void {
const parsed = SpessaSynthPresetDataSchema.parse(raw ?? {});
if (parsed.maxPolyphony !== undefined) {
this.voiceManager.setMaxPolyphony(parsed.maxPolyphony);
}
if (parsed.soundFontUrl !== undefined) {
this.soundFontUrl = parsed.soundFontUrl;
}
if (parsed.hasInlineSoundFont !== undefined) {
this.hadInlineSoundFont = parsed.hasInlineSoundFont;
}
// Apply per-channel parameter snapshots via the synth as well so
// the upstream channel state (program, volume CC) reflects them.
this.flushParametersToSynth();
}
protected processBlock(
_input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
this.requireProcessor();
const left = output[0]!;
// Stereo IO is guaranteed by `assertChannelMatch` (declared
// outputs[0].count === 2), so `output[1]` is always present.
const right = output[1]!;
const blockSize = left.length;
const sLeft = this.ensureScratch(this.scratchLeft, blockSize, "left");
const sRight = this.ensureScratch(
this.scratchRight,
blockSize,
"right"
);
this.flushParametersToSynth();
const t0 = nowMicros();
this.processor!.process(sLeft, sRight, 0, blockSize);
const master = this.parameterValue(MASTER_GAIN_ID);
for (let i = 0; i < blockSize; i++) {
const chVol = this.perSampleVolume(i, blockSize);
const g = master * chVol;
left[i] = sLeft[i]! * g;
right[i] = sRight[i]! * g;
}
recordProcessDuration(nowMicros() - t0);
setActiveVoices(this.voiceManager.getActiveVoiceCount());
}
private perSampleVolume(_i: number, _blockSize: number): number {
// Channel-volume automation is forwarded to the synth via CC#7
// on every block — the underlying processor mixes the channels
// for us. So at the master mix we just apply the master gain.
return 1;
}
private flushParametersToSynth(): void {
if (!this.processor) return;
for (let ch = 0; ch < CHANNEL_COUNT; ch++) {
const vol = this.getParameter(channelParameterId(ch, "volume"));
const pan = this.getParameter(channelParameterId(ch, "pan"));
const program = Math.round(
this.getParameter(channelParameterId(ch, "program"))
);
const reverb = this.getParameter(
channelParameterId(ch, "reverbSend")
);
const chorus = this.getParameter(
channelParameterId(ch, "chorusSend")
);
// CC#7 volume, CC#10 pan (mapped to 0..127), CC#91 reverb, CC#93 chorus.
this.processor.controllerChange(ch, 7, Math.round(vol * 127));
this.processor.controllerChange(
ch,
10,
Math.round((pan + 1) * 63.5)
);
this.processor.controllerChange(ch, 91, Math.round(reverb * 127));
this.processor.controllerChange(ch, 93, Math.round(chorus * 127));
// Only push an explicit program-change when the channel's
// program parameter is non-default — touching the synth's
// program selection on every block would override the
// bank/preset the soundfont actually exposes for channels
// we haven't customised, leading to silent renders when
// the loaded SoundFont's first preset isn't at bank 0
// program 0 (which is common for boutique SF2 files).
if (this.dirtyProgramChannels.has(ch)) {
this.processor.programChange(
ch,
Math.max(0, Math.min(127, program))
);
}
}
}
public override setParameter(id: string, value: number): void {
super.setParameter(id, value);
const parsed = parseChannelParameterId(id);
if (parsed && parsed.kind === "program") {
this.dirtyProgramChannels.add(parsed.channel);
}
}
private ensureScratch(
existing: Float32Array | undefined,
blockSize: number,
side: "left" | "right"
): Float32Array {
if (existing && existing.length === blockSize) return existing;
const fresh = new Float32Array(blockSize);
if (side === "left") this.scratchLeft = fresh;
else this.scratchRight = fresh;
return fresh;
}
private requireProcessor(): void {
if (!this.processor) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.NOT_INITIALIZED,
"spessasynth.process-before-init"
);
}
}
}
/** Resolve a monotonic-ish microsecond clock; exported for tests. */
export function nowMicros(
source: { performance?: { now?: () => number } } = globalThis as {
performance?: { now?: () => number };
}
): number {
const perf = source.performance;
if (perf?.now) return perf.now() * 1000;
return Date.now() * 1000;
}
// Re-export the parameter-id parser so consumers building a UI binding
// (e.g. control-surface knobs) can route automation lanes back to the
// canonical channel + kind pair without a second import.
export { parseChannelParameterId };

View File

@@ -0,0 +1,4 @@
export {
registerSpessaSynthPlugin,
SPESSASYNTH_PLUGIN_TYPE,
} from "./register.js";

View File

@@ -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.

View File

@@ -0,0 +1,36 @@
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
registerSpessaSynthPlugin,
SPESSASYNTH_PLUGIN_TYPE,
} from "./register.js";
describe("registerSpessaSynthPlugin", () => {
it("registers under the canonical type string", { timeout: 5_000 }, () => {
const r = new PluginRegistry();
registerSpessaSynthPlugin(r);
expect(r.has(SPESSASYNTH_PLUGIN_TYPE)).toBe(true);
});
it("factory produces a plugin with the right metadata id", {
timeout: 5_000,
}, () => {
const r = new PluginRegistry();
registerSpessaSynthPlugin(r);
const plugin = r.create(SPESSASYNTH_PLUGIN_TYPE);
expect(plugin.metadata.id).toBe(SPESSASYNTH_PLUGIN_TYPE);
expect(plugin.metadata.category).toBe("synthesis");
});
it("re-registration overwrites the previous factory", {
timeout: 5_000,
}, () => {
const r = new PluginRegistry();
registerSpessaSynthPlugin(r, { initialMaxPolyphony: 8 });
registerSpessaSynthPlugin(r, { initialMaxPolyphony: 16 });
const plugin = r.create(SPESSASYNTH_PLUGIN_TYPE) as unknown as {
getMaxPolyphony(): number;
};
expect(plugin.getMaxPolyphony()).toBe(16);
});
});

View File

@@ -0,0 +1,25 @@
import type { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
import {
SpessaSynthPlugin,
type SpessaSynthPluginOptions,
} from "../plugin/spessasynth-plugin.js";
/** Stable plugin-type string for registry lookups. */
export const SPESSASYNTH_PLUGIN_TYPE = "audio-fx.spessasynth";
/**
* Register the SpessaSynth plugin factory with a `PluginRegistry` so
* the chain's snapshot-restore path can re-instantiate the plugin
* from its serialised `pluginType` string. The factory closes over
* the supplied options so callers can pin a custom module loader for
* tests or a fixed polyphony cap for embedded targets.
*/
export function registerSpessaSynthPlugin(
registry: PluginRegistry,
options: SpessaSynthPluginOptions = {}
): void {
registry.register(
SPESSASYNTH_PLUGIN_TYPE,
() => new SpessaSynthPlugin(options)
);
}

View File

@@ -0,0 +1,8 @@
export {
getActiveVoicesValue,
observeActiveVoices,
recordProcessDuration,
recordVoicesTriggered,
resetForTesting,
setActiveVoices,
} from "./telemetry.js";

View File

@@ -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.

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getActiveVoicesValue,
observeActiveVoices,
recordProcessDuration,
recordVoicesTriggered,
resetForTesting,
setActiveVoices,
} from "./telemetry.js";
describe("telemetry", () => {
it("recordVoicesTriggered does not throw without an SDK", {
timeout: 5_000,
}, () => {
resetForTesting();
recordVoicesTriggered();
recordVoicesTriggered(5);
});
it("recordProcessDuration does not throw without an SDK", {
timeout: 5_000,
}, () => {
resetForTesting();
recordProcessDuration(123);
});
it("setActiveVoices latches the value the gauge reads", {
timeout: 5_000,
}, () => {
resetForTesting();
setActiveVoices(7);
expect(getActiveVoicesValue()).toBe(7);
});
it("resetForTesting clears the latched gauge value", {
timeout: 5_000,
}, () => {
setActiveVoices(99);
resetForTesting();
expect(getActiveVoicesValue()).toBe(0);
});
it("observeActiveVoices forwards the latched value to the SDK callback", {
timeout: 5_000,
}, () => {
resetForTesting();
setActiveVoices(42);
let observed = -1;
observeActiveVoices({
observe(value) {
observed = value;
},
});
expect(observed).toBe(42);
});
it("repeated record calls are cheap and idempotent", {
timeout: 5_000,
}, () => {
resetForTesting();
for (let i = 0; i < 100; i++) {
recordProcessDuration(i);
}
});
});

View File

@@ -0,0 +1,92 @@
import {
type Counter,
type Histogram,
metrics,
type ObservableGauge,
} from "@opentelemetry/api";
const METER_NAME = "@suaveplan/audio-fx-plugin-spessasynth";
let _voicesTriggered: Counter | undefined;
let _processDuration: Histogram | undefined;
let _voicesActive: ObservableGauge | undefined;
let _activeVoicesValue = 0;
function getVoicesTriggered(): Counter {
if (!_voicesTriggered) {
_voicesTriggered = metrics
.getMeter(METER_NAME)
.createCounter("spessasynth_voices_triggered_total", {
description:
"MIDI noteOn events forwarded to the SpessaSynth processor",
});
}
return _voicesTriggered;
}
function getProcessDuration(): Histogram {
if (!_processDuration) {
_processDuration = metrics
.getMeter(METER_NAME)
.createHistogram("spessasynth_process_duration_us", {
description:
"Duration of a single processor.process() block, microseconds",
unit: "us",
});
}
return _processDuration;
}
/** Observable-gauge callback; exported so it is exercised by tests. */
export function observeActiveVoices(observable: {
observe(value: number): void;
}): void {
observable.observe(_activeVoicesValue);
}
function getVoicesActive(): ObservableGauge {
if (!_voicesActive) {
_voicesActive = metrics
.getMeter(METER_NAME)
.createObservableGauge("spessasynth_voices_active", {
description: "Live voice count read from the synth processor",
});
_voicesActive.addCallback(observeActiveVoices);
}
return _voicesActive;
}
/** Increment the trigger counter by `n` (default 1). */
export function recordVoicesTriggered(n = 1): void {
getVoicesTriggered().add(n);
}
/** Record one block's processing duration in microseconds. */
export function recordProcessDuration(durationUs: number): void {
getProcessDuration().record(durationUs);
}
/** Update the latched active-voice value that the gauge observes. */
export function setActiveVoices(count: number): void {
_activeVoicesValue = count;
// Touch the gauge so the callback gets registered on first call —
// before any observation the meter SDK never invokes the callback.
getVoicesActive();
}
/** Sample the latched active-voice value (useful for diagnostics + tests). */
export function getActiveVoicesValue(): number {
return _activeVoicesValue;
}
/**
* Reset the cached meter instruments. Test-only — production code
* keeps the singletons across the process lifetime so the SDK
* receives consistent instrument identities.
*/
export function resetForTesting(): void {
_voicesTriggered = undefined;
_processDuration = undefined;
_voicesActive = undefined;
_activeVoicesValue = 0;
}

View File

@@ -0,0 +1,12 @@
export type {
MidiChannelPressure,
MidiControlChange,
MidiMessage,
MidiNoteOff,
MidiNoteOn,
MidiPitchBend,
MidiPolyPressure,
MidiPortLike,
MidiProgramChange,
MidiSystemExclusive,
} from "./midi.types.js";

View File

@@ -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.

View File

@@ -0,0 +1,77 @@
/**
* Structural shape of a MIDI message accepted by the router.
* Mirrors the `MidiMessage` discriminated union that
* `@suaveplan/midi` will export when it lands (parallel work
* on `feat/midi`). Defining the shape locally lets this package
* compile and ship before `@suaveplan/midi` is published.
*/
export type MidiMessage =
| MidiNoteOn
| MidiNoteOff
| MidiControlChange
| MidiProgramChange
| MidiPitchBend
| MidiChannelPressure
| MidiPolyPressure
| MidiSystemExclusive;
export interface MidiNoteOn {
readonly type: "noteOn";
readonly channel: number;
readonly note: number;
readonly velocity: number;
}
export interface MidiNoteOff {
readonly type: "noteOff";
readonly channel: number;
readonly note: number;
readonly velocity?: number;
}
export interface MidiControlChange {
readonly type: "controlChange";
readonly channel: number;
readonly controller: number;
readonly value: number;
}
export interface MidiProgramChange {
readonly type: "programChange";
readonly channel: number;
readonly program: number;
}
export interface MidiPitchBend {
readonly type: "pitchBend";
readonly channel: number;
/** 14-bit 0..16383, 8192 = centre. */
readonly value: number;
}
export interface MidiChannelPressure {
readonly type: "channelPressure";
readonly channel: number;
readonly pressure: number;
}
export interface MidiPolyPressure {
readonly type: "polyPressure";
readonly channel: number;
readonly note: number;
readonly pressure: number;
}
export interface MidiSystemExclusive {
readonly type: "sysex";
readonly data: Uint8Array;
}
/**
* Structural read-side handle to a MIDI port: subscribe to incoming
* `MidiMessage` events, get an unsubscribe function back. Compatible
* with the `MidiPortLike` interface that `@suaveplan/midi` exposes.
*/
export interface MidiPortLike {
onMessage(listener: (message: MidiMessage) => void): () => void;
}

View File

@@ -0,0 +1,6 @@
export {
ABSOLUTE_MAX_POLYPHONY,
DEFAULT_MAX_POLYPHONY,
MAX_CHANNEL_INDEX,
VoiceManager,
} from "./voice-manager.js";

View File

@@ -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.

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeStubModule } from "../../tests/stub-module.js";
import type { SpessaSynthError } from "../error-codes/error-codes.js";
import {
ABSOLUTE_MAX_POLYPHONY,
DEFAULT_MAX_POLYPHONY,
VoiceManager,
} from "./voice-manager.js";
function makeProc() {
const mod = makeStubModule();
return new mod.SpessaSynthProcessor(48_000);
}
describe("VoiceManager", () => {
it("getActiveVoiceCount returns 0 when detached", {
timeout: 5_000,
}, () => {
const vm = new VoiceManager();
expect(vm.getActiveVoiceCount()).toBe(0);
});
it("tracks active voices via processor.voiceCount", {
timeout: 5_000,
}, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
vm.noteOn(0, 60, 100);
vm.noteOn(0, 64, 100);
expect(vm.getActiveVoiceCount()).toBe(2);
expect(vm.getTriggeredTotal()).toBe(2);
vm.noteOff(0, 60);
expect(vm.getActiveVoiceCount()).toBe(1);
});
it("polyphony cap steals oldest", { timeout: 5_000 }, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
vm.setMaxPolyphony(4);
for (const n of [60, 61, 62, 63, 64]) {
vm.noteOn(0, n, 100);
}
expect(vm.getActiveVoiceCount()).toBe(4);
});
it("setMaxPolyphony shrinks existing pool", { timeout: 5_000 }, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
for (const n of [60, 61, 62, 63]) vm.noteOn(0, n, 100);
vm.setMaxPolyphony(2);
expect(vm.getActiveVoiceCount()).toBe(2);
});
it("setMaxPolyphony rejects bad values", { timeout: 5_000 }, () => {
const vm = new VoiceManager();
for (const bad of [0, -1, 1.5, ABSOLUTE_MAX_POLYPHONY + 1]) {
try {
vm.setMaxPolyphony(bad);
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.INVALID_PARAMETER"
);
}
}
});
it("allNotesOff global clears every channel", { timeout: 5_000 }, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
vm.noteOn(0, 60, 100);
vm.noteOn(3, 64, 100);
vm.allNotesOff();
expect(vm.getActiveVoiceCount()).toBe(0);
expect(proc.observer.stopAllCount).toBe(1);
});
it("allNotesOff channel-targeted emits CC#123", { timeout: 5_000 }, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
vm.noteOn(0, 60, 100);
vm.noteOn(3, 64, 100);
vm.allNotesOff(3);
expect(
proc.observer.cc.some(
(e) => e.channel === 3 && e.controller === 123
)
).toBe(true);
});
it("rejects out-of-range notes and channels", { timeout: 5_000 }, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
try {
vm.noteOn(0, 999, 100);
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.INVALID_NOTE"
);
}
try {
vm.noteOn(99, 60, 100);
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.INVALID_CHANNEL"
);
}
try {
vm.noteOff(0, 999);
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.INVALID_NOTE"
);
}
});
it("operations before attach raise NOT_INITIALIZED", {
timeout: 5_000,
}, () => {
const vm = new VoiceManager();
try {
vm.noteOn(0, 60, 100);
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.NOT_INITIALIZED"
);
}
try {
vm.allNotesOff();
throw new Error("expected throw");
} catch (err) {
expect((err as SpessaSynthError).code).toBe(
"SPESSASYNTH.NOT_INITIALIZED"
);
}
});
it("detach clears active list and processor reference", {
timeout: 5_000,
}, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
vm.noteOn(0, 60, 100);
vm.detach();
expect(vm.getActiveVoiceCount()).toBe(0);
});
it("noteOff on an unknown (channel, note) is a no-op", {
timeout: 5_000,
}, () => {
const vm = new VoiceManager();
const proc = makeProc();
vm.attach(proc);
vm.noteOn(0, 60, 100);
vm.noteOff(0, 99); // not active — manager-side splice never matches
expect(vm.getActiveVoiceCount()).toBe(1);
});
it("default polyphony matches the upstream cap", { timeout: 5_000 }, () => {
const vm = new VoiceManager();
expect(vm.getMaxPolyphony()).toBe(DEFAULT_MAX_POLYPHONY);
});
});

View File

@@ -0,0 +1,172 @@
import {
SPESSASYNTH_CODES,
SpessaSynthError,
} from "../error-codes/error-codes.js";
import type { SpessaSynthProcessorLike } from "../loader/soundfont-loader.js";
import { assertChannel, MAX_CHANNEL_INDEX } from "../parameters/parameters.js";
/** Default voice cap, mirrors the upstream `VOICE_CAP`. */
export const DEFAULT_MAX_POLYPHONY = 350;
/** Absolute upper bound — anything higher is rejected as a typo. */
export const ABSOLUTE_MAX_POLYPHONY = 1024;
/**
* Tracks (channel, note) -> insertion-order sequence so we can identify
* the oldest active voice when stealing. The processor's own `voiceCount`
* is authoritative for the live count; this manager owns the cap and the
* stealing decision.
*/
interface ActiveVoice {
readonly channel: number;
readonly note: number;
readonly sequence: number;
}
/**
* Voice management surface bolted onto a `SpessaSynthProcessorLike`.
* The processor itself tracks live voices, but the plugin exposes the
* polyphony cap + voice-stealing policy + bulk-off operations on top.
*/
export class VoiceManager {
private processor: SpessaSynthProcessorLike | undefined;
private maxPolyphony: number = DEFAULT_MAX_POLYPHONY;
private readonly active: ActiveVoice[] = [];
private sequenceCounter = 0;
private triggeredTotal = 0;
public attach(processor: SpessaSynthProcessorLike): void {
this.processor = processor;
}
public detach(): void {
this.active.length = 0;
this.processor = undefined;
}
public getActiveVoiceCount(): number {
return this.processor?.voiceCount ?? this.active.length;
}
public getTriggeredTotal(): number {
return this.triggeredTotal;
}
public getMaxPolyphony(): number {
return this.maxPolyphony;
}
public setMaxPolyphony(n: number): void {
if (!Number.isInteger(n) || n < 1 || n > ABSOLUTE_MAX_POLYPHONY) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.INVALID_PARAMETER,
"spessasynth.invalid-polyphony",
{ context: { polyphony: n } }
);
}
this.maxPolyphony = n;
while (this.active.length > n) {
this.stealOldest();
}
}
/**
* Note-on path: steals the oldest voice if the cap is reached, then
* forwards to the processor. Returns whether the note was triggered
* (always true once the steal completes — the cap is a soft ceiling
* enforced by stealing, not a reject).
*/
public noteOn(channel: number, note: number, velocity: number): void {
this.requireProcessor();
assertChannel(channel);
assertNote(note);
if (this.active.length >= this.maxPolyphony) {
this.stealOldest();
}
this.active.push({
channel,
note,
sequence: ++this.sequenceCounter,
});
this.triggeredTotal++;
this.processor!.noteOn(channel, note, velocity);
}
public noteOff(channel: number, note: number): void {
this.requireProcessor();
assertChannel(channel);
assertNote(note);
// Remove the most-recent voice matching channel+note.
for (let i = this.active.length - 1; i >= 0; i--) {
const v = this.active[i]!;
if (v.channel === channel && v.note === note) {
this.active.splice(i, 1);
break;
}
}
this.processor!.noteOff(channel, note);
}
public allNotesOff(channel?: number): void {
this.requireProcessor();
if (channel === undefined) {
this.active.length = 0;
this.processor!.stopAllChannels(true);
return;
}
assertChannel(channel);
for (let i = this.active.length - 1; i >= 0; i--) {
if (this.active[i]!.channel === channel) {
this.active.splice(i, 1);
}
}
// CC 123 (All Notes Off) on the channel.
this.processor!.controllerChange(channel, 123, 0);
}
private stealOldest(): void {
// Both invariants — non-empty active list AND attached processor
// — are guaranteed by the call sites (`noteOn` requires the
// processor; `setMaxPolyphony` shrinking only runs while the
// pool contains entries that the processor produced).
// Scan for the smallest sequence number. `active` is pushed in
// order so element 0 is normally oldest, but a defensive scan
// means out-of-order entries (priority queues in the future,
// restored snapshots) still steal the genuine oldest.
let idx = 0;
let bestSeq = Number.POSITIVE_INFINITY;
for (let i = 0; i < this.active.length; i++) {
const seq = this.active[i]!.sequence;
if (seq < bestSeq) {
bestSeq = seq;
idx = i;
}
}
const stolen = this.active[idx]!;
this.active.splice(idx, 1);
this.processor!.noteOff(stolen.channel, stolen.note);
}
private requireProcessor(): void {
if (!this.processor) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.NOT_INITIALIZED,
"spessasynth.voice-manager-not-attached"
);
}
}
}
function assertNote(note: number): void {
if (!Number.isInteger(note) || note < 0 || note > 127) {
throw new SpessaSynthError(
SPESSASYNTH_CODES.INVALID_NOTE,
"spessasynth.invalid-note",
{ context: { note, max: 127 } }
);
}
}
// Re-export the channel ceiling so consumers of voice-manager don't need
// a second import from parameters.
export { MAX_CHANNEL_INDEX };

View File

@@ -0,0 +1,157 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type {
ChainContext,
ChainHandle,
} from "@suaveplan/audio-fx-plugin-base/base";
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SpessaSynthPlugin } from "../src/plugin/spessasynth-plugin.js";
import { buildFakeSf2Bytes, makeStubModule } from "./stub-module.js";
const SR = 48_000;
const BLOCK = 64;
function newCtx(): ChainContext {
const chain: ChainHandle = {
id: "auto",
name: "auto",
channels: 2,
sampleRate: SR,
blockSize: BLOCK,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
describe("automation: master gain ramp affects rendered output", () => {
it("master gain set to 0 produces silence; set to 2 produces louder output", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const plugin = new SpessaSynthPlugin({
moduleLoader: async () => mod,
});
await plugin.init({
soundFontBytes: buildFakeSf2Bytes(64),
sampleRate: SR,
});
const config: ProcessorConfig = {
sampleRate: SR,
channels: 2,
blockSize: BLOCK,
};
await plugin.setup(config, newCtx());
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
const ctx: BlockContext = {
blockOffsetSamples: 0,
sampleRate: SR,
channels: 2,
opts: {},
};
// Render with master gain 1 (default).
const outBaseline: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], outBaseline, ctx);
let baselineRms = 0;
for (let i = 0; i < BLOCK; i++) baselineRms += outBaseline[0]![i]! ** 2;
baselineRms = Math.sqrt(baselineRms / BLOCK);
expect(baselineRms).toBeGreaterThan(0);
// Silence: master 0.
plugin.setParameter("master/gain", 0);
const outSilent: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], outSilent, ctx);
let silentRms = 0;
for (let i = 0; i < BLOCK; i++) {
silentRms += outSilent[0]![i]! ** 2;
}
expect(silentRms).toBe(0);
// Loud: master 2.
plugin.setParameter("master/gain", 2);
const outLoud: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], outLoud, ctx);
let loudRms = 0;
for (let i = 0; i < BLOCK; i++) loudRms += outLoud[0]![i]! ** 2;
loudRms = Math.sqrt(loudRms / BLOCK);
// Should be roughly 2× the baseline.
expect(loudRms).toBeGreaterThan(baselineRms);
});
it("channel volume parameter flushes to CC#7 each block", {
timeout: 5_000,
}, async () => {
const mod = makeStubModule();
const plugin = new SpessaSynthPlugin({
moduleLoader: async () => mod,
});
await plugin.init({
soundFontBytes: buildFakeSf2Bytes(64),
sampleRate: SR,
});
await plugin.setup(
{ sampleRate: SR, channels: 2, blockSize: BLOCK },
newCtx()
);
plugin.setParameter("channel/0/volume", 1);
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, {
blockOffsetSamples: 0,
sampleRate: SR,
channels: 2,
opts: {},
});
plugin.setParameter("channel/0/volume", 0);
plugin.process([], out, {
blockOffsetSamples: 0,
sampleRate: SR,
channels: 2,
opts: {},
});
const proc = (
mod as {
lastProcessor?: {
observer: {
cc: Array<{
channel: number;
controller: number;
value: number;
}>;
};
};
}
).lastProcessor!;
const ch0Vol = proc.observer.cc.filter(
(e) => e.channel === 0 && e.controller === 7
);
// First flush wrote 127 (volume=1), second wrote 0.
expect(ch0Vol[0]?.value).toBe(127);
expect(ch0Vol[ch0Vol.length - 1]?.value).toBe(0);
});
});

View File

@@ -0,0 +1,93 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type {
ChainContext,
ChainHandle,
} from "@suaveplan/audio-fx-plugin-base/base";
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SpessaSynthPlugin } from "../src/plugin/spessasynth-plugin.js";
const FIXTURE_URL = new URL("./fixtures/tiny-piano.sf2", import.meta.url);
const FIXTURE_PATH = fileURLToPath(FIXTURE_URL);
const SR = 48_000;
const BLOCK = 512;
function newCtx(): ChainContext {
const chain: ChainHandle = {
id: "integration",
name: "integration",
channels: 2,
sampleRate: SR,
blockSize: BLOCK,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
function newConfig(): ProcessorConfig {
return { sampleRate: SR, channels: 2, blockSize: BLOCK };
}
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: SR,
channels: 2,
opts: {},
};
}
function rms(buf: Float32Array): number {
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i]! ** 2;
return Math.sqrt(sum / buf.length);
}
describe("integration: render via the vendored tiny-piano SF2 fixture", () => {
it("renders a C-major chord with non-zero RMS through process()", {
timeout: 30_000,
}, async () => {
const bytes = new Uint8Array(await readFile(FIXTURE_PATH));
const plugin = new SpessaSynthPlugin();
await plugin.init({
soundFontBytes: bytes,
sampleRate: SR,
});
await plugin.setup(newConfig(), newCtx());
// C major: C4, E4, G4.
for (const note of [60, 64, 67]) {
plugin.sendMidi({
type: "noteOn",
channel: 0,
note,
velocity: 100,
});
}
// Render half a second.
const blocks = Math.ceil(SR / 2 / BLOCK);
const merged = new Float32Array(blocks * BLOCK);
for (let b = 0; b < blocks; b++) {
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, blockCtx());
merged.set(out[0]!, b * BLOCK);
}
const r = rms(merged);
expect(r).toBeGreaterThan(0);
plugin.dispose();
});
});

View File

@@ -0,0 +1,154 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type {
ChainContext,
ChainHandle,
} from "@suaveplan/audio-fx-plugin-base/base";
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SpessaSynthPlugin } from "../src/plugin/spessasynth-plugin.js";
import { buildFakeSf2Bytes, makeStubModule } from "./stub-module.js";
const SR = 48_000;
const BLOCK = 64;
function newCtx(): ChainContext {
const chain: ChainHandle = {
id: "midi",
name: "midi",
channels: 2,
sampleRate: SR,
blockSize: BLOCK,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
function newConfig(): ProcessorConfig {
return { sampleRate: SR, channels: 2, blockSize: BLOCK };
}
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: SR,
channels: 2,
opts: {},
};
}
async function newPlugin() {
const mod = makeStubModule();
const plugin = new SpessaSynthPlugin({ moduleLoader: async () => mod });
await plugin.init({
soundFontBytes: buildFakeSf2Bytes(64),
sampleRate: SR,
});
await plugin.setup(newConfig(), newCtx());
return { mod, plugin };
}
describe("midi-routing: 4-bar sequence drives correct voice activations", () => {
it("voiceCount tracks noteOn/noteOff across a 4-bar sequence", {
timeout: 5_000,
}, async () => {
const { plugin } = await newPlugin();
const sequence: Array<{
offsetBlocks: number;
on: boolean;
note: number;
}> = [];
// 4 bars × 4 quarter notes × C major arpeggio.
for (let bar = 0; bar < 4; bar++) {
for (let beat = 0; beat < 4; beat++) {
const offset = bar * 16 + beat * 4;
const note = 60 + ((beat * 4) % 12);
sequence.push({ offsetBlocks: offset, on: true, note });
sequence.push({ offsetBlocks: offset + 2, on: false, note });
}
}
let active = 0;
let maxObservedActive = 0;
for (let b = 0; b < 64; b++) {
for (const event of sequence) {
if (event.offsetBlocks === b) {
if (event.on) {
plugin.sendMidi({
type: "noteOn",
channel: 0,
note: event.note,
velocity: 100,
});
active++;
} else {
plugin.sendMidi({
type: "noteOff",
channel: 0,
note: event.note,
});
active--;
}
}
}
const out: Float32Array[] = [
new Float32Array(BLOCK),
new Float32Array(BLOCK),
];
plugin.process([], out, blockCtx());
maxObservedActive = Math.max(
maxObservedActive,
plugin.getActiveVoiceCount()
);
}
expect(maxObservedActive).toBeGreaterThan(0);
expect(active).toBe(0); // sequence is balanced
});
it("programChange on channel 0 updates the underlying preset", {
timeout: 5_000,
}, async () => {
const { plugin, mod } = await newPlugin();
plugin.sendMidi({ type: "programChange", channel: 0, program: 24 });
const proc = (
mod as {
lastProcessor?: { observer: { programs: Map<number, number> } };
}
).lastProcessor;
// Block-level parameter flush writes program 0 by default, then
// our explicit program-change writes 24.
expect(proc?.observer.programs.get(0)).toBe(24);
});
it("NRPN per-note tuning sequence triggers the parameter observer", {
timeout: 5_000,
}, async () => {
const { plugin } = await newPlugin();
plugin.sendMidi({
type: "controlChange",
channel: 0,
controller: 99,
value: 0x65,
});
plugin.sendMidi({
type: "controlChange",
channel: 0,
controller: 98,
value: 0x4c,
});
plugin.sendMidi({
type: "controlChange",
channel: 0,
controller: 6,
value: 80,
});
// The processor receives the raw CCs; verified by no throw +
// active voice count remaining stable across the sequence.
expect(plugin.getActiveVoiceCount()).toBe(0);
});
});

View File

@@ -0,0 +1,202 @@
import type {
SoundBankLike,
SpessaSynthModuleLike,
SpessaSynthProcessorLike,
} from "../src/loader/soundfont-loader.js";
interface ActiveStubNote {
channel: number;
note: number;
velocity: number;
phase: number;
}
export interface StubProcessorObserver {
notes: ActiveStubNote[];
programs: Map<number, number>;
cc: Array<{ channel: number; controller: number; value: number }>;
pitchBend: Array<{ channel: number; value: number }>;
channelPressure: Array<{ channel: number; pressure: number }>;
polyPressure: Array<{
channel: number;
note: number;
pressure: number;
}>;
sysex: Array<Uint8Array>;
destroyed: boolean;
resetCount: number;
stopAllCount: number;
}
export interface StubBank extends SoundBankLike {
readonly stub: true;
}
class StubProcessor implements SpessaSynthProcessorLike {
public readonly sampleRate: number;
public readonly observer: StubProcessorObserver = {
notes: [],
programs: new Map(),
cc: [],
pitchBend: [],
channelPressure: [],
polyPressure: [],
sysex: [],
destroyed: false,
resetCount: 0,
stopAllCount: 0,
};
public readonly soundBankManager = {
addSoundBank: (_font: SoundBankLike, _id: string) => {
this.bankAdded = true;
},
};
private bankAdded = false;
public constructor(sampleRate: number) {
this.sampleRate = sampleRate;
}
public get voiceCount(): number {
return this.observer.notes.length;
}
public process(
left: Float32Array,
right: Float32Array,
_startIndex?: number,
_sampleCount?: number
): void {
const len = left.length;
for (let i = 0; i < len; i++) {
let sum = 0;
for (const v of this.observer.notes) {
const freq = 440 * 2 ** ((v.note - 69) / 12);
const phaseInc = (2 * Math.PI * freq) / this.sampleRate;
v.phase += phaseInc;
sum += Math.sin(v.phase) * (v.velocity / 127);
}
left[i] = sum;
right[i] = sum;
}
}
public noteOn(channel: number, midiNote: number, velocity: number): void {
if (velocity === 0) {
this.noteOff(channel, midiNote);
return;
}
this.observer.notes.push({
channel,
note: midiNote,
velocity,
phase: 0,
});
}
public noteOff(channel: number, midiNote: number): void {
for (let i = this.observer.notes.length - 1; i >= 0; i--) {
const v = this.observer.notes[i]!;
if (v.channel === channel && v.note === midiNote) {
this.observer.notes.splice(i, 1);
return;
}
}
}
public programChange(channel: number, programNumber: number): void {
this.observer.programs.set(channel, programNumber);
}
public controllerChange(
channel: number,
controller: number,
value: number
): void {
this.observer.cc.push({ channel, controller, value });
if (controller === 123) {
for (let i = this.observer.notes.length - 1; i >= 0; i--) {
if (this.observer.notes[i]!.channel === channel) {
this.observer.notes.splice(i, 1);
}
}
}
}
public pitchWheel(channel: number, pitch: number): void {
this.observer.pitchBend.push({ channel, value: pitch });
}
public polyPressure(
channel: number,
midiNote: number,
pressure: number
): void {
this.observer.polyPressure.push({ channel, note: midiNote, pressure });
}
public channelPressure(channel: number, pressure: number): void {
this.observer.channelPressure.push({ channel, pressure });
}
public systemExclusive(syx: Uint8Array | number[]): void {
this.observer.sysex.push(
syx instanceof Uint8Array ? syx : new Uint8Array(syx)
);
}
public stopAllChannels(_force?: boolean): void {
this.observer.stopAllCount++;
this.observer.notes.length = 0;
}
public reset(): void {
this.observer.resetCount++;
}
public destroySynthProcessor(): void {
this.observer.destroyed = true;
}
public wasBankAdded(): boolean {
return this.bankAdded;
}
}
export function makeStubModule(): SpessaSynthModuleLike & {
lastProcessor?: StubProcessor;
} {
const mod = {
SoundBankLoader: {
fromArrayBuffer(buffer: ArrayBuffer): StubBank {
if (buffer.byteLength < 12) {
throw new Error("stub-parse-fail");
}
return { stub: true };
},
},
SpessaSynthProcessor: class extends StubProcessor {
constructor(sampleRate: number) {
super(sampleRate);
(mod as { lastProcessor?: StubProcessor }).lastProcessor = this;
}
} as unknown as SpessaSynthModuleLike["SpessaSynthProcessor"],
} as SpessaSynthModuleLike & { lastProcessor?: StubProcessor };
return mod;
}
/** Build a minimal valid RIFF/sfbk header so the magic sniff passes. */
export function buildFakeSf2Bytes(payloadSize = 32): Uint8Array {
const total = 12 + payloadSize;
const bytes = new Uint8Array(total);
bytes[0] = 0x52; // R
bytes[1] = 0x49; // I
bytes[2] = 0x46; // F
bytes[3] = 0x46; // F
// size field at offset 4..7 — ignored by our magic check
bytes[8] = 0x73; // s
bytes[9] = 0x66; // f
bytes[10] = 0x62; // b
bytes[11] = 0x6b; // k
return bytes;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-spessasynth",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: { index: "src/index.ts" },
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
"tslib",
"zod",
"spessasynth_lib",
"spessasynth_core",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });