Initial Commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
BIN
packages/media/audio-fx/audio-fx-plugin-spessasynth/tests/fixtures/tiny-piano.sf2
vendored
Normal file
BIN
packages/media/audio-fx/audio-fx-plugin-spessasynth/tests/fixtures/tiny-piano.sf2
vendored
Normal file
Binary file not shown.
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user