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 { brassNote, drumNote, epianoNote, leadNote, organNote, pianoNote, pluckNote, stringsNote, synthBassNote } from '../src/instruments/dsp.ts';
10import { builtinInstruments } from '../src/instruments/builtins.ts';
11import { gmProgramInstrument } from '../src/midi/gm.ts';
12import { audibleTracks, encodeWavMono16, mixTracks, renderTrackBuffer, trackRenderKey } from '../src/audio/render.ts';
13import type { Instrument, TrackRenderRequest, TrackRenderResult } from '../src/instruments/types.ts';
14import type { Note, Song, Track } from '../src/types.ts';
16let failed = 0;
17let passed = 0;
18function test(name: string, fn: () => void) {
19 try {
20 fn();
21 passed++;
22 console.log(`ok - ${name}`);
23 } catch (err) {
24 failed++;
25 console.error(`FAIL - ${name}`);
26 console.error(err);
27 }
28}
30async function testAsync(name: string, fn: () => Promise<void>) {
31 try {
32 await fn();
33 passed++;
34 console.log(`ok - ${name}`);
35 } catch (err) {
36 failed++;
37 console.error(`FAIL - ${name}`);
38 console.error(err);
39 }
40}
42function close(a: number, b: number, tol = 1e-9) {
43 assert.ok(Math.abs(a - b) <= tol, `${a} !~ ${b}`);
44}
46function hash(buf: Float32Array): number {
47 let h = 0x811c9dc5;
48 for (let i = 0; i < buf.length; i++) {
49 h ^= Math.round(buf[i] * 1e6) & 0xffff;
50 h = Math.imul(h, 0x01000193);
51 }
52 return h >>> 0;
53}
55function mkNote(tick: number, duration: number, pitch: number, velocity: number, id: string): Note {
56 return { id, tick, duration, pitch, velocity };
57}
59function mkTrack(id: string, instrumentId: string, notes: Note[], extra: Partial<Track> = {}): Track {
60 return { id, name: id, instrumentId, volume: 1, mute: false, solo: false, notes, ...extra };
61}
63// --- tempo map ---
65test('tempo map: constant 120 bpm', () => {
66 const tm = new TempoMap(480, [{ tick: 0, usPerQuarter: 500000 }]);
67 close(tm.tickToSec(480), 0.5);
68 close(tm.tickToSec(1920), 2);
69 close(tm.secToTick(1), 960);
70 close(tm.bpmAt(0), 120);
71});
73test('tempo map: tempo change mid-song', () => {
74 const tm = new TempoMap(480, [
75 { tick: 0, usPerQuarter: 500000 },
76 { tick: 960, usPerQuarter: 250000 },
77 ]);
78 close(tm.tickToSec(960), 1);
79 close(tm.tickToSec(1920), 1.5);
80 close(tm.secToTick(1.25), 1440);
81 close(tm.bpmAt(1000), 240);
82});
84test('tempo map: bar.beat display in 4/4 and 3/4', () => {
85 const tm44 = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
86 assert.deepEqual(tm44.barBeatAt(0), { bar: 1, beat: 1 });
87 assert.deepEqual(tm44.barBeatAt(480 * 5), { bar: 2, beat: 2 });
88 const tm34 = new TempoMap(480, [], [{ tick: 0, numerator: 3, denominator: 4 }]);
89 assert.deepEqual(tm34.barBeatAt(480 * 3), { bar: 2, beat: 1 });
90});
92test('tempo map: grid lines mark bars', () => {
93 const tm = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
94 const lines = tm.gridLines(0, 1920);
95 assert.equal(lines.length, 4);
96 assert.deepEqual(lines[0], { tick: 0, isBar: true });
97 assert.deepEqual(lines[1], { tick: 480, isBar: false });
98});
100// --- MIDI round trip ---
102function sortedNotes(t: Track): [number, number, number, number][] {
103 return t.notes
104 .map((n) => [n.tick, n.duration, n.pitch, n.velocity] as [number, number, number, number])
105 .sort((a, b) => a[0] - b[0] || a[2] - b[2]);
106}
108test('MIDI write/parse round trip preserves notes, tempo, and drums', () => {
109 const song: Song = {
110 ppq: 480,
111 tempo: [
112 { tick: 0, usPerQuarter: 500000 },
113 { tick: 1920, usPerQuarter: 400000 },
114 ],
115 timeSig: [{ tick: 0, numerator: 3, denominator: 4 }],
116 tracks: [
117 mkTrack('melody', 'pluck', [
118 mkNote(0, 480, 60, 100, 'a'),
119 mkNote(480, 240, 64, 90, 'b'),
120 mkNote(100000, 960, 72, 127, 'c'), // multi-byte delta times
121 mkNote(480, 240, 67, 1, 'd'), // chord + min velocity
122 ]),
123 mkTrack('kit', 'drumkit', [
124 mkNote(0, 120, 36, 110, 'e'),
125 mkNote(480, 120, 38, 80, 'f'),
126 ]),
127 ],
128 };
129 const bytes = writeMidi(song);
130 const back = parseMidi(bytes);
131 assert.equal(back.ppq, 480);
132 assert.equal(back.tracks.length, 2);
133 assert.deepEqual(sortedNotes(back.tracks[0]), sortedNotes(song.tracks[0]));
134 assert.deepEqual(sortedNotes(back.tracks[1]), sortedNotes(song.tracks[1]));
135 assert.equal(back.tracks[0].name, 'melody');
136 assert.equal(back.tracks[1].instrumentId, 'drumkit'); // came back on channel 10
137 assert.equal(back.tracks[0].instrumentId, 'pluck'); // restored from the embedded meta
138 assert.deepEqual(back.tempo, song.tempo);
139 assert.equal(back.timeSig[0].numerator, 3);
140 assert.equal(back.timeSig[0].denominator, 4);
141});
143test('instrument id and knob settings survive the MIDI round trip', () => {
144 const song: Song = {
145 ppq: 480,
146 tempo: [{ tick: 0, usPerQuarter: 500000 }],
147 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
148 tracks: [
149 mkTrack('wb', 'woodblock', [mkNote(0, 240, 76, 100, 'a')], {
150 settings: { strike_position: 0.3, mallet_stiffness: 2e8 },
151 }),
152 ],
153 };
154 const bytes = writeMidi(song);
156 // midiboot's own import: the embedded meta wins over the GM mapping.
157 const known = new Set(['woodblock', 'drumkit', 'piano']);
158 const back = parseMidi(bytes, known);
159 assert.equal(back.tracks[0].instrumentId, 'woodblock');
160 assert.deepEqual(back.tracks[0].settings, { strike_position: 0.3, mallet_stiffness: 2e8 });
162 // An id the registry does not know falls back to the GM mapping of the
163 // written program change (woodblock exports program 115), and the
164 // settings are dropped with it.
165 const strangers = parseMidi(bytes, new Set(['piano', 'pluck', 'drumkit']));
166 assert.equal(strangers.tracks[0].instrumentId, 'pluck');
167 assert.equal(strangers.tracks[0].settings, undefined);
169 // A foreign file with no meta behaves as before: GM program mapping.
170 const plain: Song = { ...song, tracks: [mkTrack('m', 'piano', [mkNote(0, 240, 60, 90, 'b')])] };
171 const foreign = parseMidi(writeMidi(plain), known);
172 assert.equal(foreign.tracks[0].instrumentId, 'piano');
173 assert.equal(foreign.tracks[0].settings, undefined);
174});
176test('parse splits a multi-channel track by channel', () => {
177 const song: Song = {
178 ppq: 480,
179 tempo: [{ tick: 0, usPerQuarter: 500000 }],
180 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
181 tracks: [
182 mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]),
183 mkTrack('b', 'drumkit', [mkNote(0, 120, 42, 100, 'b')]),
184 ],
185 };
186 // write produces separate MTrks; merging them into one channel-mixed
187 // stream is what a format 0 file would look like, so parse the format 1
188 // bytes and just confirm channels landed apart.
189 const back = parseMidi(writeMidi(song));
190 assert.equal(back.tracks.length, 2);
191 assert.equal(back.tracks[1].instrumentId, 'drumkit');
192});
194// --- editing ops ---
196test('quantize rounds note starts to the grid', () => {
197 const notes = [mkNote(130, 100, 60, 100, 'a'), mkNote(59, 100, 61, 100, 'b')];
198 const q = quantizeNotes(notes, 120);
199 assert.equal(q[0].tick, 120);
200 assert.equal(q[1].tick, 0); // 59 rounds down to the grid origin
201 const onlyA = quantizeNotes(notes, 120, new Set(['a']));
202 assert.equal(onlyA[0].tick, 120);
203 assert.equal(onlyA[1].tick, 59);
204});
206test('transpose clamps to the MIDI range', () => {
207 const notes = [mkNote(0, 100, 126, 100, 'a')];
208 assert.equal(transposeNotes(notes, 12)[0].pitch, 127);
209 assert.equal(transposeNotes(notes, -300)[0].pitch, 0);
210});
212test('clearRange trims, splits, and drops notes against the range', () => {
213 const notes = [
214 mkNote(0, 100, 60, 100, 'before'), // ends before the range
215 mkNote(150, 100, 61, 100, 'inside'), // fully inside: dropped
216 mkNote(50, 100, 62, 100, 'crossStart'), // trimmed to end at 100
217 mkNote(250, 100, 63, 100, 'crossEnd'), // start moves to 300
218 mkNote(50, 400, 64, 100, 'spans'), // split in two
219 ];
220 const out = clearRange(notes, 100, 300);
221 const by = (id: string) => out.find((n) => n.id === id);
222 assert.equal(out.length, 5); // before, crossStart, crossEnd, spans-left, spans-right
223 assert.deepEqual([by('before')!.tick, by('before')!.duration], [0, 100]);
224 assert.equal(by('inside'), undefined);
225 assert.deepEqual([by('crossStart')!.tick, by('crossStart')!.duration], [50, 50]);
226 assert.deepEqual([by('crossEnd')!.tick, by('crossEnd')!.duration], [300, 50]);
227 assert.deepEqual([by('spans')!.tick, by('spans')!.duration], [50, 50]);
228 const right = out.find((n) => n.pitch === 64 && n.id !== 'spans')!;
229 assert.deepEqual([right.tick, right.duration], [300, 150]);
230});
232test('deleteTimeRange closes the gap', () => {
233 const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(150, 50, 61, 100, 'b'), mkNote(400, 50, 62, 100, 'c')];
234 const out = deleteTimeRange(notes, 100, 300);
235 assert.equal(out.length, 2); // 'b' was inside
236 assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
237 assert.equal(out.find((n) => n.id === 'c')!.tick, 200);
238});
240test('insertTime shifts notes at and after the point', () => {
241 const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(100, 50, 61, 100, 'b')];
242 const out = insertTime(notes, 100, 480);
243 assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
244 assert.equal(out.find((n) => n.id === 'b')!.tick, 580);
245});
247test('extractRange copies the in-range parts with fresh ids', () => {
248 const notes = [mkNote(50, 400, 64, 100, 'spans')];
249 const out = extractRange(notes, 100, 300);
250 assert.equal(out.length, 1);
251 assert.deepEqual([out[0].tick, out[0].duration, out[0].pitch], [100, 200, 64]);
252 assert.notEqual(out[0].id, 'spans');
253});
255test('event lists ripple with time edits and keep tick 0', () => {
256 const tempo = [
257 { tick: 0, usPerQuarter: 500000 },
258 { tick: 200, usPerQuarter: 400000 },
259 { tick: 600, usPerQuarter: 300000 },
260 ];
261 const cut = deleteTimeFromEvents(tempo, 100, 500);
262 assert.deepEqual(cut.map((e) => e.tick), [0, 200]);
263 assert.equal(cut[1].usPerQuarter, 300000); // the 600-tick event moved to 200
264 const ins = insertTimeIntoEvents(tempo, 200, 100);
265 assert.deepEqual(ins.map((e) => e.tick), [0, 300, 700]);
266});
268// --- DSP ---
270test('DSP is deterministic and finite', () => {
271 for (const [name, fn] of [
272 ['piano', pianoNote],
273 ['epiano', epianoNote],
274 ['organ', organNote],
275 ['pluck', pluckNote],
276 ['synthbass', synthBassNote],
277 ['strings', stringsNote],
278 ['brass', brassNote],
279 ['lead', leadNote],
280 ['drum', drumNote],
281 ] as const) {
282 const a = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
283 const b = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
284 assert.equal(hash(a), hash(b), `${name} not deterministic`);
285 assert.ok(a.length > 1000, `${name} too short`);
286 let peak = 0;
287 for (let i = 0; i < a.length; i++) {
288 assert.ok(Number.isFinite(a[i]), `${name} produced non-finite sample`);
289 peak = Math.max(peak, Math.abs(a[i]));
290 }
291 assert.ok(peak > 0.01, `${name} is silent`);
292 assert.ok(peak <= 1.5, `${name} peak too hot: ${peak}`);
293 }
294});
296await testAsync('builtin instrument renderTrack places notes at the right time', async () => {
297 const pluck = builtinInstruments.find((i) => i.id === 'pluck')!;
298 const sr = 44100;
299 const { audio: out } = await pluck.renderTrack({
300 notes: [{ timeSec: 1, durationSec: 0.25, pitch: 69, velocity: 0.8 }],
301 sampleRate: sr,
302 lengthSamples: sr * 3,
303 quality: 'preview',
304 });
305 assert.equal(out.length, sr * 3);
306 let before = 0;
307 for (let i = 0; i < sr - 10; i++) before = Math.max(before, Math.abs(out[i]));
308 let after = 0;
309 for (let i = sr; i < sr * 2; i++) after = Math.max(after, Math.abs(out[i]));
310 assert.equal(before, 0);
311 assert.ok(after > 0.01);
312});
314// --- mixing ---
316test('mute and solo select audible tracks', () => {
317 const a = mkTrack('a', 'pluck', []);
318 const b = mkTrack('b', 'pluck', [], { mute: true });
319 const c = mkTrack('c', 'pluck', []);
320 assert.deepEqual([...audibleTracks([a, b, c])].sort(), ['a', 'c']);
321 const cSolo = { ...c, solo: true };
322 assert.deepEqual([...audibleTracks([a, b, cSolo])], ['c']);
323});
325test('mixTracks applies volume and excludes muted tracks', () => {
326 const a = mkTrack('a', 'pluck', [], { volume: 0.5 });
327 const b = mkTrack('b', 'pluck', [], { mute: true });
328 const buffers = new Map<string, Float32Array>([
329 ['a', new Float32Array([0.4, 0.4])],
330 ['b', new Float32Array([0.9, 0.9])],
331 ]);
332 const mix = mixTracks([a, b], buffers);
333 close(mix[0], 0.2, 1e-6);
334});
336test('trackRenderKey changes when the notes change', () => {
337 const song: Song = {
338 ppq: 480,
339 tempo: [{ tick: 0, usPerQuarter: 500000 }],
340 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
341 tracks: [],
342 };
343 const t1 = mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]);
344 const t2 = mkTrack('a', 'pluck', [mkNote(0, 480, 61, 100, 'a')]);
345 const t3 = mkTrack('a', 'epiano', [mkNote(0, 480, 60, 100, 'a')]);
346 assert.equal(trackRenderKey(song, t1), trackRenderKey(song, mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'zzz')])));
347 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t2));
348 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t3));
349});
351test('WAV encoding writes a well-formed header', () => {
352 const wav = encodeWavMono16(new Float32Array([0, 0.5, -0.5, 2]), 44100);
353 assert.equal(wav.length, 44 + 8);
354 assert.equal(String.fromCharCode(...wav.subarray(0, 4)), 'RIFF');
355 assert.equal(String.fromCharCode(...wav.subarray(8, 12)), 'WAVE');
356 const dv = new DataView(wav.buffer);
357 assert.equal(dv.getUint32(24, true), 44100);
358 assert.equal(dv.getInt16(44 + 6, true), 32767); // clipped to full scale
359});
361// --- the track-render instrument contract ---
363await testAsync('renderTrackBuffer drives renderTrack with progress, settings, and leveling', async () => {
364 const seen: TrackRenderRequest[] = [];
365 const progress: number[] = [];
366 const fake: Instrument = {
367 id: 'fake',
368 name: 'Fake',
369 renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
370 seen.push(req);
371 req.onProgress?.({ frac: 0.5, stage: 'half' });
372 const audio = new Float32Array(req.lengthSamples);
373 audio[0] = 0.1; // quiet, so leveling must scale it up to 0.5
374 return Promise.resolve({ audio });
375 },
376 };
377 const song: Song = {
378 ppq: 480,
379 tempo: [{ tick: 0, usPerQuarter: 500000 }],
380 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
381 tracks: [],
382 };
383 const track = mkTrack('a', 'fake', [mkNote(0, 480, 60, 100, 'n1')], { settings: { k: 3 } });
384 const buf = await renderTrackBuffer(song, track, fake, 44100, 'preview', { onProgress: (p) => progress.push(p.frac) });
385 assert.equal(seen.length, 1);
386 assert.equal(seen[0].notes.length, 1);
387 close(seen[0].notes[0].durationSec, 0.5, 1e-6);
388 assert.equal(seen[0].settings?.k, 3);
389 assert.deepEqual(progress, [0.5]);
390 close(buf[0], 0.5, 1e-6); // leveled to the common peak
391});
393await testAsync('an aborted signal reaches the instrument', async () => {
394 const ctrl = new AbortController();
395 const fake: Instrument = {
396 id: 'fake',
397 name: 'Fake',
398 renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
399 if (req.signal?.aborted) return Promise.reject(new DOMException('render cancelled', 'AbortError'));
400 return Promise.resolve({ audio: new Float32Array(req.lengthSamples) });
401 },
402 };
403 const song: Song = { ppq: 480, tempo: [{ tick: 0, usPerQuarter: 500000 }], timeSig: [{ tick: 0, numerator: 4, denominator: 4 }], tracks: [] };
404 ctrl.abort();
405 await assert.rejects(
406 renderTrackBuffer(song, mkTrack('a', 'fake', []), fake, 44100, 'preview', { signal: ctrl.signal }),
407 (e: unknown) => e instanceof DOMException && e.name === 'AbortError',
408 );
409});
411test('every builtin id used by the GM mapping exists', () => {
412 const ids = new Set(builtinInstruments.map((i) => i.id));
413 for (let p = 0; p < 128; p++) {
414 assert.ok(ids.has(gmProgramInstrument(p)), `program ${p} maps to missing instrument`);
415 }
416 assert.equal(gmProgramInstrument(0), 'piano');
417 assert.equal(gmProgramInstrument(33), 'synthbass');
418 assert.equal(gmProgramInstrument(48), 'strings');
419 assert.equal(gmProgramInstrument(56), 'brass');
420 assert.equal(gmProgramInstrument(undefined), 'piano');
421});
423console.log(`\n${passed} passed, ${failed} failed`);
424process.exit(failed > 0 ? 1 : 0);