/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / scripts / test-node.ts
376 lines · 14.9 KBBlameHistoryRaw
1// Deterministic node-side tests: MIDI round trip, tempo math, editing
2// ops, DSP determinism, and mixing rules. Run with: npm test
4import assert from 'node:assert/strict';
5import { TempoMap } from '../src/midi/time.ts';
6import { parseMidi } from '../src/midi/parse.ts';
7import { writeMidi } from '../src/midi/write.ts';
8import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes, transposeNotes } from '../src/midi/ops.ts';
9import { drumNote, epianoNote, leadNote, pluckNote } from '../src/instruments/dsp.ts';
10import { builtinInstruments } from '../src/instruments/builtins.ts';
11import { audibleTracks, encodeWavMono16, mixTracks, trackRenderKey } from '../src/audio/render.ts';
12import { bucketVelocity, mapPitch, mapVelocity, parseManifest } from '../src/instruments/simulated/manifest.ts';
13import { readdirSync, readFileSync, existsSync } from 'node:fs';
14import { join } from 'node:path';
15import type { Note, Song, Track } from '../src/types.ts';
17let failed = 0;
18let passed = 0;
19function test(name: string, fn: () => void) {
20 try {
21 fn();
22 passed++;
23 console.log(`ok - ${name}`);
24 } catch (err) {
25 failed++;
26 console.error(`FAIL - ${name}`);
27 console.error(err);
28 }
31function close(a: number, b: number, tol = 1e-9) {
32 assert.ok(Math.abs(a - b) <= tol, `${a} !~ ${b}`);
35function hash(buf: Float32Array): number {
36 let h = 0x811c9dc5;
37 for (let i = 0; i < buf.length; i++) {
38 h ^= Math.round(buf[i] * 1e6) & 0xffff;
39 h = Math.imul(h, 0x01000193);
40 }
41 return h >>> 0;
44function mkNote(tick: number, duration: number, pitch: number, velocity: number, id: string): Note {
45 return { id, tick, duration, pitch, velocity };
48function mkTrack(id: string, instrumentId: string, notes: Note[], extra: Partial<Track> = {}): Track {
49 return { id, name: id, instrumentId, volume: 1, mute: false, solo: false, notes, ...extra };
52// --- tempo map ---
54test('tempo map: constant 120 bpm', () => {
55 const tm = new TempoMap(480, [{ tick: 0, usPerQuarter: 500000 }]);
56 close(tm.tickToSec(480), 0.5);
57 close(tm.tickToSec(1920), 2);
58 close(tm.secToTick(1), 960);
59 close(tm.bpmAt(0), 120);
60});
62test('tempo map: tempo change mid-song', () => {
63 const tm = new TempoMap(480, [
64 { tick: 0, usPerQuarter: 500000 },
65 { tick: 960, usPerQuarter: 250000 },
66 ]);
67 close(tm.tickToSec(960), 1);
68 close(tm.tickToSec(1920), 1.5);
69 close(tm.secToTick(1.25), 1440);
70 close(tm.bpmAt(1000), 240);
71});
73test('tempo map: bar.beat display in 4/4 and 3/4', () => {
74 const tm44 = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
75 assert.deepEqual(tm44.barBeatAt(0), { bar: 1, beat: 1 });
76 assert.deepEqual(tm44.barBeatAt(480 * 5), { bar: 2, beat: 2 });
77 const tm34 = new TempoMap(480, [], [{ tick: 0, numerator: 3, denominator: 4 }]);
78 assert.deepEqual(tm34.barBeatAt(480 * 3), { bar: 2, beat: 1 });
79});
81test('tempo map: grid lines mark bars', () => {
82 const tm = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
83 const lines = tm.gridLines(0, 1920);
84 assert.equal(lines.length, 4);
85 assert.deepEqual(lines[0], { tick: 0, isBar: true });
86 assert.deepEqual(lines[1], { tick: 480, isBar: false });
87});
89// --- MIDI round trip ---
91function sortedNotes(t: Track): [number, number, number, number][] {
92 return t.notes
93 .map((n) => [n.tick, n.duration, n.pitch, n.velocity] as [number, number, number, number])
94 .sort((a, b) => a[0] - b[0] || a[2] - b[2]);
97test('MIDI write/parse round trip preserves notes, tempo, and drums', () => {
98 const song: Song = {
99 ppq: 480,
100 tempo: [
101 { tick: 0, usPerQuarter: 500000 },
102 { tick: 1920, usPerQuarter: 400000 },
103 ],
104 timeSig: [{ tick: 0, numerator: 3, denominator: 4 }],
105 tracks: [
106 mkTrack('melody', 'pluck', [
107 mkNote(0, 480, 60, 100, 'a'),
108 mkNote(480, 240, 64, 90, 'b'),
109 mkNote(100000, 960, 72, 127, 'c'), // multi-byte delta times
110 mkNote(480, 240, 67, 1, 'd'), // chord + min velocity
111 ]),
112 mkTrack('kit', 'drumkit', [
113 mkNote(0, 120, 36, 110, 'e'),
114 mkNote(480, 120, 38, 80, 'f'),
115 ]),
116 ],
117 };
118 const bytes = writeMidi(song);
119 const back = parseMidi(bytes);
120 assert.equal(back.ppq, 480);
121 assert.equal(back.tracks.length, 2);
122 assert.deepEqual(sortedNotes(back.tracks[0]), sortedNotes(song.tracks[0]));
123 assert.deepEqual(sortedNotes(back.tracks[1]), sortedNotes(song.tracks[1]));
124 assert.equal(back.tracks[0].name, 'melody');
125 assert.equal(back.tracks[1].instrumentId, 'drumkit'); // came back on channel 10
126 assert.equal(back.tracks[0].instrumentId, 'pluck');
127 assert.deepEqual(back.tempo, song.tempo);
128 assert.equal(back.timeSig[0].numerator, 3);
129 assert.equal(back.timeSig[0].denominator, 4);
130});
132test('parse splits a multi-channel track by channel', () => {
133 const song: Song = {
134 ppq: 480,
135 tempo: [{ tick: 0, usPerQuarter: 500000 }],
136 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
137 tracks: [
138 mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]),
139 mkTrack('b', 'drumkit', [mkNote(0, 120, 42, 100, 'b')]),
140 ],
141 };
142 // write produces separate MTrks; merging them into one channel-mixed
143 // stream is what a format 0 file would look like, so parse the format 1
144 // bytes and just confirm channels landed apart.
145 const back = parseMidi(writeMidi(song));
146 assert.equal(back.tracks.length, 2);
147 assert.equal(back.tracks[1].instrumentId, 'drumkit');
148});
150// --- editing ops ---
152test('quantize rounds note starts to the grid', () => {
153 const notes = [mkNote(130, 100, 60, 100, 'a'), mkNote(59, 100, 61, 100, 'b')];
154 const q = quantizeNotes(notes, 120);
155 assert.equal(q[0].tick, 120);
156 assert.equal(q[1].tick, 0); // 59 rounds down to the grid origin
157 const onlyA = quantizeNotes(notes, 120, new Set(['a']));
158 assert.equal(onlyA[0].tick, 120);
159 assert.equal(onlyA[1].tick, 59);
160});
162test('transpose clamps to the MIDI range', () => {
163 const notes = [mkNote(0, 100, 126, 100, 'a')];
164 assert.equal(transposeNotes(notes, 12)[0].pitch, 127);
165 assert.equal(transposeNotes(notes, -300)[0].pitch, 0);
166});
168test('clearRange trims, splits, and drops notes against the range', () => {
169 const notes = [
170 mkNote(0, 100, 60, 100, 'before'), // ends before the range
171 mkNote(150, 100, 61, 100, 'inside'), // fully inside: dropped
172 mkNote(50, 100, 62, 100, 'crossStart'), // trimmed to end at 100
173 mkNote(250, 100, 63, 100, 'crossEnd'), // start moves to 300
174 mkNote(50, 400, 64, 100, 'spans'), // split in two
175 ];
176 const out = clearRange(notes, 100, 300);
177 const by = (id: string) => out.find((n) => n.id === id);
178 assert.equal(out.length, 5); // before, crossStart, crossEnd, spans-left, spans-right
179 assert.deepEqual([by('before')!.tick, by('before')!.duration], [0, 100]);
180 assert.equal(by('inside'), undefined);
181 assert.deepEqual([by('crossStart')!.tick, by('crossStart')!.duration], [50, 50]);
182 assert.deepEqual([by('crossEnd')!.tick, by('crossEnd')!.duration], [300, 50]);
183 assert.deepEqual([by('spans')!.tick, by('spans')!.duration], [50, 50]);
184 const right = out.find((n) => n.pitch === 64 && n.id !== 'spans')!;
185 assert.deepEqual([right.tick, right.duration], [300, 150]);
186});
188test('deleteTimeRange closes the gap', () => {
189 const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(150, 50, 61, 100, 'b'), mkNote(400, 50, 62, 100, 'c')];
190 const out = deleteTimeRange(notes, 100, 300);
191 assert.equal(out.length, 2); // 'b' was inside
192 assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
193 assert.equal(out.find((n) => n.id === 'c')!.tick, 200);
194});
196test('insertTime shifts notes at and after the point', () => {
197 const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(100, 50, 61, 100, 'b')];
198 const out = insertTime(notes, 100, 480);
199 assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
200 assert.equal(out.find((n) => n.id === 'b')!.tick, 580);
201});
203test('extractRange copies the in-range parts with fresh ids', () => {
204 const notes = [mkNote(50, 400, 64, 100, 'spans')];
205 const out = extractRange(notes, 100, 300);
206 assert.equal(out.length, 1);
207 assert.deepEqual([out[0].tick, out[0].duration, out[0].pitch], [100, 200, 64]);
208 assert.notEqual(out[0].id, 'spans');
209});
211test('event lists ripple with time edits and keep tick 0', () => {
212 const tempo = [
213 { tick: 0, usPerQuarter: 500000 },
214 { tick: 200, usPerQuarter: 400000 },
215 { tick: 600, usPerQuarter: 300000 },
216 ];
217 const cut = deleteTimeFromEvents(tempo, 100, 500);
218 assert.deepEqual(cut.map((e) => e.tick), [0, 200]);
219 assert.equal(cut[1].usPerQuarter, 300000); // the 600-tick event moved to 200
220 const ins = insertTimeIntoEvents(tempo, 200, 100);
221 assert.deepEqual(ins.map((e) => e.tick), [0, 300, 700]);
222});
224// --- DSP ---
226test('DSP is deterministic and finite', () => {
227 for (const [name, fn] of [
228 ['pluck', pluckNote],
229 ['epiano', epianoNote],
230 ['lead', leadNote],
231 ['drum', drumNote],
232 ] as const) {
233 const a = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
234 const b = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
235 assert.equal(hash(a), hash(b), `${name} not deterministic`);
236 assert.ok(a.length > 1000, `${name} too short`);
237 let peak = 0;
238 for (let i = 0; i < a.length; i++) {
239 assert.ok(Number.isFinite(a[i]), `${name} produced non-finite sample`);
240 peak = Math.max(peak, Math.abs(a[i]));
241 }
242 assert.ok(peak > 0.01, `${name} is silent`);
243 assert.ok(peak <= 1.5, `${name} peak too hot: ${peak}`);
244 }
245});
247test('builtin instrument render places notes at the right time', () => {
248 const pluck = builtinInstruments.find((i) => i.id === 'pluck')!;
249 const sr = 44100;
250 const out = pluck.render({
251 notes: [{ timeSec: 1, durationSec: 0.25, pitch: 69, velocity: 0.8 }],
252 sampleRate: sr,
253 lengthSamples: sr * 3,
254 }) as Float32Array;
255 assert.equal(out.length, sr * 3);
256 let before = 0;
257 for (let i = 0; i < sr - 10; i++) before = Math.max(before, Math.abs(out[i]));
258 let after = 0;
259 for (let i = sr; i < sr * 2; i++) after = Math.max(after, Math.abs(out[i]));
260 assert.equal(before, 0);
261 assert.ok(after > 0.01);
262});
264// --- mixing ---
266test('mute and solo select audible tracks', () => {
267 const a = mkTrack('a', 'pluck', []);
268 const b = mkTrack('b', 'pluck', [], { mute: true });
269 const c = mkTrack('c', 'pluck', []);
270 assert.deepEqual([...audibleTracks([a, b, c])].sort(), ['a', 'c']);
271 const cSolo = { ...c, solo: true };
272 assert.deepEqual([...audibleTracks([a, b, cSolo])], ['c']);
273});
275test('mixTracks applies volume and excludes muted tracks', () => {
276 const a = mkTrack('a', 'pluck', [], { volume: 0.5 });
277 const b = mkTrack('b', 'pluck', [], { mute: true });
278 const buffers = new Map<string, Float32Array>([
279 ['a', new Float32Array([0.4, 0.4])],
280 ['b', new Float32Array([0.9, 0.9])],
281 ]);
282 const mix = mixTracks([a, b], buffers);
283 close(mix[0], 0.2, 1e-6);
284});
286test('trackRenderKey changes when the notes change', () => {
287 const song: Song = {
288 ppq: 480,
289 tempo: [{ tick: 0, usPerQuarter: 500000 }],
290 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
291 tracks: [],
292 };
293 const t1 = mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]);
294 const t2 = mkTrack('a', 'pluck', [mkNote(0, 480, 61, 100, 'a')]);
295 const t3 = mkTrack('a', 'epiano', [mkNote(0, 480, 60, 100, 'a')]);
296 assert.equal(trackRenderKey(song, t1), trackRenderKey(song, mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'zzz')])));
297 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t2));
298 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t3));
299});
301test('WAV encoding writes a well-formed header', () => {
302 const wav = encodeWavMono16(new Float32Array([0, 0.5, -0.5, 2]), 44100);
303 assert.equal(wav.length, 44 + 8);
304 assert.equal(String.fromCharCode(...wav.subarray(0, 4)), 'RIFF');
305 assert.equal(String.fromCharCode(...wav.subarray(8, 12)), 'WAVE');
306 const dv = new DataView(wav.buffer);
307 assert.equal(dv.getUint32(24, true), 44100);
308 assert.equal(dv.getInt16(44 + 6, true), 32767); // clipped to full scale
309});
311// --- simulated-instrument manifests ---
313const SIM_ROOT = join(import.meta.dirname, '../../../simulated_instruments');
315test('every instrument.json in ../simulated_instruments validates', () => {
316 if (!existsSync(SIM_ROOT)) return; // vault layout absent; nothing to check
317 const dirs = readdirSync(SIM_ROOT).filter((d) => existsSync(join(SIM_ROOT, d, 'instrument.json')));
318 assert.ok(dirs.length >= 1, 'no manifests found');
319 for (const d of dirs) {
320 const m = parseManifest(readFileSync(join(SIM_ROOT, d, 'instrument.json'), 'utf8'));
321 assert.equal(m.name, d, `manifest name '${m.name}' should match its directory '${d}'`);
322 assert.ok(existsSync(join(SIM_ROOT, d, 'matlab', `${m.entry}.m`)), `${d}: entry ${m.entry}.m missing`);
323 assert.ok(existsSync(join(SIM_ROOT, d, 'matlab', `${m.defaults}.m`)), `${d}: defaults ${m.defaults}.m missing`);
324 }
325});
327test('manifest pitch and velocity mapping', () => {
328 const m = parseManifest(JSON.stringify({
329 format: 'simulated-instrument-v1', name: 't', label: 'T', entry: 'e', defaults: 'd',
330 fixed: {}, preview: {},
331 pitch: { param: 'root_midi', mode: 'midi', min: 28, max: 55 },
332 velocity: { param: 'force', min: 2, max: 12 },
333 knobs: [],
334 }));
335 assert.deepEqual(mapPitch(m, 40), { key: 'root_midi', value: 40 });
336 assert.deepEqual(mapPitch(m, 10), { key: 'root_midi', value: 28 }); // clamped
337 assert.deepEqual(mapVelocity(m, 0), { key: 'force', value: 2 });
338 assert.deepEqual(mapVelocity(m, 1), { key: 'force', value: 12 });
339 const t = parseManifest(JSON.stringify({
340 format: 'simulated-instrument-v1', name: 't2', label: 'T', entry: 'e', defaults: 'd',
341 fixed: {}, preview: {},
342 pitch: { param: 'note1', mode: 'table', table: [{ midi: 72, value: 1 }, { midi: 74, value: 2 }] },
343 velocity: { param: 'v', min: 0, max: 1 },
344 knobs: [],
345 }));
346 assert.deepEqual(mapPitch(t, 73), { key: 'note1', value: 1 }); // nearest, ties go low
347 assert.deepEqual(mapPitch(t, 90), { key: 'note1', value: 2 });
348 const un = parseManifest(JSON.stringify({
349 format: 'simulated-instrument-v1', name: 't3', label: 'T', entry: 'e', defaults: 'd',
350 fixed: {}, preview: {}, velocity: { param: 'v', min: 0, max: 1 }, knobs: [],
351 }));
352 assert.equal(mapPitch(un, 60), null);
353});
355test('velocity bucketing quantizes into few levels', () => {
356 assert.equal(bucketVelocity(1), 1);
357 assert.equal(bucketVelocity(0.999), 1);
358 assert.equal(bucketVelocity(0.51), 0.625);
359 assert.equal(bucketVelocity(0.01), 0.125);
360 assert.ok(bucketVelocity(0) > 0);
361});
363test('manifest validation rejects malformed knobs', () => {
364 assert.throws(() => parseManifest(JSON.stringify({
365 format: 'simulated-instrument-v1', name: 'x', label: 'X', entry: 'e', defaults: 'd',
366 fixed: {}, preview: {}, velocity: { param: 'v', min: 0, max: 1 },
367 knobs: [{ key: 'k', label: 'K', min: 5, max: 1, default: 2 }],
368 })));
369 assert.throws(() => parseManifest(JSON.stringify({
370 format: 'wrong', name: 'x', label: 'X', entry: 'e', defaults: 'd',
371 fixed: {}, preview: {}, velocity: { param: 'v', min: 0, max: 1 }, knobs: [],
372 })));
373});
375console.log(`\n${passed} passed, ${failed} failed`);
376process.exit(failed > 0 ? 1 : 0);
↑↓ move↵openescclose