Files
suaveplan/packages/media/audio-fx/audio-fx-plugin-spessasynth/tests/midi-routing.test.ts
2026-05-27 23:47:53 +01:00

155 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});