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 } }; } ).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); }); });