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