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,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;
}