0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 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';
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 8import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes, transposeNotes } from '../src/midi/ops.ts';
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 9import { brassNote, drumNote, epianoNote, leadNote, organNote, pianoNote, pluckNote, stringsNote, synthBassNote } from '../src/instruments/dsp.ts';
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 10import { builtinInstruments } from '../src/instruments/builtins.ts';
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 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';
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 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}
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 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}
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 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
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 137 assert.equal(back.tracks[0].instrumentId, 'piano'); // GM family mapping of the default program
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 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('parse splits a multi-channel track by channel', () => {
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('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]),
150 mkTrack('b', 'drumkit', [mkNote(0, 120, 42, 100, 'b')]),
151 ],
152 };
153 // write produces separate MTrks; merging them into one channel-mixed
154 // stream is what a format 0 file would look like, so parse the format 1
155 // bytes and just confirm channels landed apart.
156 const back = parseMidi(writeMidi(song));
157 assert.equal(back.tracks.length, 2);
158 assert.equal(back.tracks[1].instrumentId, 'drumkit');
159});
161// --- editing ops ---
163test('quantize rounds note starts to the grid', () => {
164 const notes = [mkNote(130, 100, 60, 100, 'a'), mkNote(59, 100, 61, 100, 'b')];
165 const q = quantizeNotes(notes, 120);
166 assert.equal(q[0].tick, 120);
167 assert.equal(q[1].tick, 0); // 59 rounds down to the grid origin
168 const onlyA = quantizeNotes(notes, 120, new Set(['a']));
169 assert.equal(onlyA[0].tick, 120);
170 assert.equal(onlyA[1].tick, 59);
171});
173test('transpose clamps to the MIDI range', () => {
174 const notes = [mkNote(0, 100, 126, 100, 'a')];
175 assert.equal(transposeNotes(notes, 12)[0].pitch, 127);
176 assert.equal(transposeNotes(notes, -300)[0].pitch, 0);
177});
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 179test('clearRange trims, splits, and drops notes against the range', () => {
180 const notes = [
181 mkNote(0, 100, 60, 100, 'before'), // ends before the range
182 mkNote(150, 100, 61, 100, 'inside'), // fully inside: dropped
183 mkNote(50, 100, 62, 100, 'crossStart'), // trimmed to end at 100
184 mkNote(250, 100, 63, 100, 'crossEnd'), // start moves to 300
185 mkNote(50, 400, 64, 100, 'spans'), // split in two
186 ];
187 const out = clearRange(notes, 100, 300);
188 const by = (id: string) => out.find((n) => n.id === id);
189 assert.equal(out.length, 5); // before, crossStart, crossEnd, spans-left, spans-right
190 assert.deepEqual([by('before')!.tick, by('before')!.duration], [0, 100]);
191 assert.equal(by('inside'), undefined);
192 assert.deepEqual([by('crossStart')!.tick, by('crossStart')!.duration], [50, 50]);
193 assert.deepEqual([by('crossEnd')!.tick, by('crossEnd')!.duration], [300, 50]);
194 assert.deepEqual([by('spans')!.tick, by('spans')!.duration], [50, 50]);
195 const right = out.find((n) => n.pitch === 64 && n.id !== 'spans')!;
196 assert.deepEqual([right.tick, right.duration], [300, 150]);
197});
199test('deleteTimeRange closes the gap', () => {
200 const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(150, 50, 61, 100, 'b'), mkNote(400, 50, 62, 100, 'c')];
201 const out = deleteTimeRange(notes, 100, 300);
202 assert.equal(out.length, 2); // 'b' was inside
203 assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
204 assert.equal(out.find((n) => n.id === 'c')!.tick, 200);
205});
207test('insertTime shifts notes at and after the point', () => {
208 const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(100, 50, 61, 100, 'b')];
209 const out = insertTime(notes, 100, 480);
210 assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
211 assert.equal(out.find((n) => n.id === 'b')!.tick, 580);
212});
214test('extractRange copies the in-range parts with fresh ids', () => {
215 const notes = [mkNote(50, 400, 64, 100, 'spans')];
216 const out = extractRange(notes, 100, 300);
217 assert.equal(out.length, 1);
218 assert.deepEqual([out[0].tick, out[0].duration, out[0].pitch], [100, 200, 64]);
219 assert.notEqual(out[0].id, 'spans');
220});
222test('event lists ripple with time edits and keep tick 0', () => {
223 const tempo = [
224 { tick: 0, usPerQuarter: 500000 },
225 { tick: 200, usPerQuarter: 400000 },
226 { tick: 600, usPerQuarter: 300000 },
227 ];
228 const cut = deleteTimeFromEvents(tempo, 100, 500);
229 assert.deepEqual(cut.map((e) => e.tick), [0, 200]);
230 assert.equal(cut[1].usPerQuarter, 300000); // the 600-tick event moved to 200
231 const ins = insertTimeIntoEvents(tempo, 200, 100);
232 assert.deepEqual(ins.map((e) => e.tick), [0, 300, 700]);
233});
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 235// --- DSP ---
237test('DSP is deterministic and finite', () => {
238 for (const [name, fn] of [
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 239 ['piano', pianoNote],
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 240 ['epiano', epianoNote],
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 241 ['organ', organNote],
242 ['pluck', pluckNote],
243 ['synthbass', synthBassNote],
244 ['strings', stringsNote],
245 ['brass', brassNote],
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 246 ['lead', leadNote],
247 ['drum', drumNote],
248 ] as const) {
249 const a = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
250 const b = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
251 assert.equal(hash(a), hash(b), `${name} not deterministic`);
252 assert.ok(a.length > 1000, `${name} too short`);
253 let peak = 0;
254 for (let i = 0; i < a.length; i++) {
255 assert.ok(Number.isFinite(a[i]), `${name} produced non-finite sample`);
256 peak = Math.max(peak, Math.abs(a[i]));
257 }
258 assert.ok(peak > 0.01, `${name} is silent`);
259 assert.ok(peak <= 1.5, `${name} peak too hot: ${peak}`);
260 }
261});
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 263await testAsync('builtin instrument renderTrack places notes at the right time', async () => {
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 264 const pluck = builtinInstruments.find((i) => i.id === 'pluck')!;
265 const sr = 44100;
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 266 const { audio: out } = await pluck.renderTrack({
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 267 notes: [{ timeSec: 1, durationSec: 0.25, pitch: 69, velocity: 0.8 }],
268 sampleRate: sr,
269 lengthSamples: sr * 3,
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 270 quality: 'preview',
271 });
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 272 assert.equal(out.length, sr * 3);
273 let before = 0;
274 for (let i = 0; i < sr - 10; i++) before = Math.max(before, Math.abs(out[i]));
275 let after = 0;
276 for (let i = sr; i < sr * 2; i++) after = Math.max(after, Math.abs(out[i]));
277 assert.equal(before, 0);
278 assert.ok(after > 0.01);
279});
281// --- mixing ---
283test('mute and solo select audible tracks', () => {
284 const a = mkTrack('a', 'pluck', []);
285 const b = mkTrack('b', 'pluck', [], { mute: true });
286 const c = mkTrack('c', 'pluck', []);
287 assert.deepEqual([...audibleTracks([a, b, c])].sort(), ['a', 'c']);
288 const cSolo = { ...c, solo: true };
289 assert.deepEqual([...audibleTracks([a, b, cSolo])], ['c']);
290});
292test('mixTracks applies volume and excludes muted tracks', () => {
293 const a = mkTrack('a', 'pluck', [], { volume: 0.5 });
294 const b = mkTrack('b', 'pluck', [], { mute: true });
295 const buffers = new Map<string, Float32Array>([
296 ['a', new Float32Array([0.4, 0.4])],
297 ['b', new Float32Array([0.9, 0.9])],
298 ]);
299 const mix = mixTracks([a, b], buffers);
300 close(mix[0], 0.2, 1e-6);
301});
303test('trackRenderKey changes when the notes change', () => {
304 const song: Song = {
305 ppq: 480,
306 tempo: [{ tick: 0, usPerQuarter: 500000 }],
307 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
308 tracks: [],
309 };
310 const t1 = mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]);
311 const t2 = mkTrack('a', 'pluck', [mkNote(0, 480, 61, 100, 'a')]);
312 const t3 = mkTrack('a', 'epiano', [mkNote(0, 480, 60, 100, 'a')]);
313 assert.equal(trackRenderKey(song, t1), trackRenderKey(song, mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'zzz')])));
314 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t2));
315 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t3));
316});
318test('WAV encoding writes a well-formed header', () => {
319 const wav = encodeWavMono16(new Float32Array([0, 0.5, -0.5, 2]), 44100);
320 assert.equal(wav.length, 44 + 8);
321 assert.equal(String.fromCharCode(...wav.subarray(0, 4)), 'RIFF');
322 assert.equal(String.fromCharCode(...wav.subarray(8, 12)), 'WAVE');
323 const dv = new DataView(wav.buffer);
324 assert.equal(dv.getUint32(24, true), 44100);
325 assert.equal(dv.getInt16(44 + 6, true), 32767); // clipped to full scale
326});
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 328// --- the track-render instrument contract ---
330await testAsync('renderTrackBuffer drives renderTrack with progress, settings, and leveling', async () => {
331 const seen: TrackRenderRequest[] = [];
332 const progress: number[] = [];
333 const fake: Instrument = {
334 id: 'fake',
335 name: 'Fake',
336 renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
337 seen.push(req);
338 req.onProgress?.({ frac: 0.5, stage: 'half' });
339 const audio = new Float32Array(req.lengthSamples);
340 audio[0] = 0.1; // quiet, so leveling must scale it up to 0.5
341 return Promise.resolve({ audio });
342 },
343 };
344 const song: Song = {
345 ppq: 480,
346 tempo: [{ tick: 0, usPerQuarter: 500000 }],
347 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
348 tracks: [],
349 };
350 const track = mkTrack('a', 'fake', [mkNote(0, 480, 60, 100, 'n1')], { settings: { k: 3 } });
351 const buf = await renderTrackBuffer(song, track, fake, 44100, 'preview', { onProgress: (p) => progress.push(p.frac) });
352 assert.equal(seen.length, 1);
353 assert.equal(seen[0].notes.length, 1);
354 close(seen[0].notes[0].durationSec, 0.5, 1e-6);
355 assert.equal(seen[0].settings?.k, 3);
356 assert.deepEqual(progress, [0.5]);
357 close(buf[0], 0.5, 1e-6); // leveled to the common peak
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 360await testAsync('an aborted signal reaches the instrument', async () => {
361 const ctrl = new AbortController();
362 const fake: Instrument = {
363 id: 'fake',
364 name: 'Fake',
365 renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
366 if (req.signal?.aborted) return Promise.reject(new DOMException('render cancelled', 'AbortError'));
367 return Promise.resolve({ audio: new Float32Array(req.lengthSamples) });
368 },
369 };
370 const song: Song = { ppq: 480, tempo: [{ tick: 0, usPerQuarter: 500000 }], timeSig: [{ tick: 0, numerator: 4, denominator: 4 }], tracks: [] };
371 ctrl.abort();
372 await assert.rejects(
373 renderTrackBuffer(song, mkTrack('a', 'fake', []), fake, 44100, 'preview', { signal: ctrl.signal }),
374 (e: unknown) => e instanceof DOMException && e.name === 'AbortError',
375 );
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 378test('every builtin id used by the GM mapping exists', () => {
379 const ids = new Set(builtinInstruments.map((i) => i.id));
380 for (let p = 0; p < 128; p++) {
381 assert.ok(ids.has(gmProgramInstrument(p)), `program ${p} maps to missing instrument`);
382 }
383 assert.equal(gmProgramInstrument(0), 'piano');
384 assert.equal(gmProgramInstrument(33), 'synthbass');
385 assert.equal(gmProgramInstrument(48), 'strings');
386 assert.equal(gmProgramInstrument(56), 'brass');
387 assert.equal(gmProgramInstrument(undefined), 'piano');
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 390console.log(`\n${passed} passed, ${failed} failed`);
391process.exit(failed > 0 ? 1 : 0);