/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / scripts / test-node.ts
253 lines · 9.0 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 { 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 type { Note, Song, Track } from '../src/types.ts';
14let failed = 0;
15let passed = 0;
16function test(name: string, fn: () => void) {
17 try {
18 fn();
19 passed++;
20 console.log(`ok - ${name}`);
21 } catch (err) {
22 failed++;
23 console.error(`FAIL - ${name}`);
24 console.error(err);
25 }
28function close(a: number, b: number, tol = 1e-9) {
29 assert.ok(Math.abs(a - b) <= tol, `${a} !~ ${b}`);
32function hash(buf: Float32Array): number {
33 let h = 0x811c9dc5;
34 for (let i = 0; i < buf.length; i++) {
35 h ^= Math.round(buf[i] * 1e6) & 0xffff;
36 h = Math.imul(h, 0x01000193);
37 }
38 return h >>> 0;
41function mkNote(tick: number, duration: number, pitch: number, velocity: number, id: string): Note {
42 return { id, tick, duration, pitch, velocity };
45function mkTrack(id: string, instrumentId: string, notes: Note[], extra: Partial<Track> = {}): Track {
46 return { id, name: id, instrumentId, volume: 1, mute: false, solo: false, notes, ...extra };
49// --- tempo map ---
51test('tempo map: constant 120 bpm', () => {
52 const tm = new TempoMap(480, [{ tick: 0, usPerQuarter: 500000 }]);
53 close(tm.tickToSec(480), 0.5);
54 close(tm.tickToSec(1920), 2);
55 close(tm.secToTick(1), 960);
56 close(tm.bpmAt(0), 120);
57});
59test('tempo map: tempo change mid-song', () => {
60 const tm = new TempoMap(480, [
61 { tick: 0, usPerQuarter: 500000 },
62 { tick: 960, usPerQuarter: 250000 },
63 ]);
64 close(tm.tickToSec(960), 1);
65 close(tm.tickToSec(1920), 1.5);
66 close(tm.secToTick(1.25), 1440);
67 close(tm.bpmAt(1000), 240);
68});
70test('tempo map: bar.beat display in 4/4 and 3/4', () => {
71 const tm44 = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
72 assert.deepEqual(tm44.barBeatAt(0), { bar: 1, beat: 1 });
73 assert.deepEqual(tm44.barBeatAt(480 * 5), { bar: 2, beat: 2 });
74 const tm34 = new TempoMap(480, [], [{ tick: 0, numerator: 3, denominator: 4 }]);
75 assert.deepEqual(tm34.barBeatAt(480 * 3), { bar: 2, beat: 1 });
76});
78test('tempo map: grid lines mark bars', () => {
79 const tm = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]);
80 const lines = tm.gridLines(0, 1920);
81 assert.equal(lines.length, 4);
82 assert.deepEqual(lines[0], { tick: 0, isBar: true });
83 assert.deepEqual(lines[1], { tick: 480, isBar: false });
84});
86// --- MIDI round trip ---
88function sortedNotes(t: Track): [number, number, number, number][] {
89 return t.notes
90 .map((n) => [n.tick, n.duration, n.pitch, n.velocity] as [number, number, number, number])
91 .sort((a, b) => a[0] - b[0] || a[2] - b[2]);
94test('MIDI write/parse round trip preserves notes, tempo, and drums', () => {
95 const song: Song = {
96 ppq: 480,
97 tempo: [
98 { tick: 0, usPerQuarter: 500000 },
99 { tick: 1920, usPerQuarter: 400000 },
100 ],
101 timeSig: [{ tick: 0, numerator: 3, denominator: 4 }],
102 tracks: [
103 mkTrack('melody', 'pluck', [
104 mkNote(0, 480, 60, 100, 'a'),
105 mkNote(480, 240, 64, 90, 'b'),
106 mkNote(100000, 960, 72, 127, 'c'), // multi-byte delta times
107 mkNote(480, 240, 67, 1, 'd'), // chord + min velocity
108 ]),
109 mkTrack('kit', 'drumkit', [
110 mkNote(0, 120, 36, 110, 'e'),
111 mkNote(480, 120, 38, 80, 'f'),
112 ]),
113 ],
114 };
115 const bytes = writeMidi(song);
116 const back = parseMidi(bytes);
117 assert.equal(back.ppq, 480);
118 assert.equal(back.tracks.length, 2);
119 assert.deepEqual(sortedNotes(back.tracks[0]), sortedNotes(song.tracks[0]));
120 assert.deepEqual(sortedNotes(back.tracks[1]), sortedNotes(song.tracks[1]));
121 assert.equal(back.tracks[0].name, 'melody');
122 assert.equal(back.tracks[1].instrumentId, 'drumkit'); // came back on channel 10
123 assert.equal(back.tracks[0].instrumentId, 'pluck');
124 assert.deepEqual(back.tempo, song.tempo);
125 assert.equal(back.timeSig[0].numerator, 3);
126 assert.equal(back.timeSig[0].denominator, 4);
127});
129test('parse splits a multi-channel track by channel', () => {
130 const song: Song = {
131 ppq: 480,
132 tempo: [{ tick: 0, usPerQuarter: 500000 }],
133 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
134 tracks: [
135 mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]),
136 mkTrack('b', 'drumkit', [mkNote(0, 120, 42, 100, 'b')]),
137 ],
138 };
139 // write produces separate MTrks; merging them into one channel-mixed
140 // stream is what a format 0 file would look like, so parse the format 1
141 // bytes and just confirm channels landed apart.
142 const back = parseMidi(writeMidi(song));
143 assert.equal(back.tracks.length, 2);
144 assert.equal(back.tracks[1].instrumentId, 'drumkit');
145});
147// --- editing ops ---
149test('quantize rounds note starts to the grid', () => {
150 const notes = [mkNote(130, 100, 60, 100, 'a'), mkNote(59, 100, 61, 100, 'b')];
151 const q = quantizeNotes(notes, 120);
152 assert.equal(q[0].tick, 120);
153 assert.equal(q[1].tick, 0); // 59 rounds down to the grid origin
154 const onlyA = quantizeNotes(notes, 120, new Set(['a']));
155 assert.equal(onlyA[0].tick, 120);
156 assert.equal(onlyA[1].tick, 59);
157});
159test('transpose clamps to the MIDI range', () => {
160 const notes = [mkNote(0, 100, 126, 100, 'a')];
161 assert.equal(transposeNotes(notes, 12)[0].pitch, 127);
162 assert.equal(transposeNotes(notes, -300)[0].pitch, 0);
163});
165// --- DSP ---
167test('DSP is deterministic and finite', () => {
168 for (const [name, fn] of [
169 ['pluck', pluckNote],
170 ['epiano', epianoNote],
171 ['lead', leadNote],
172 ['drum', drumNote],
173 ] as const) {
174 const a = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
175 const b = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4);
176 assert.equal(hash(a), hash(b), `${name} not deterministic`);
177 assert.ok(a.length > 1000, `${name} too short`);
178 let peak = 0;
179 for (let i = 0; i < a.length; i++) {
180 assert.ok(Number.isFinite(a[i]), `${name} produced non-finite sample`);
181 peak = Math.max(peak, Math.abs(a[i]));
182 }
183 assert.ok(peak > 0.01, `${name} is silent`);
184 assert.ok(peak <= 1.5, `${name} peak too hot: ${peak}`);
185 }
186});
188test('builtin instrument render places notes at the right time', () => {
189 const pluck = builtinInstruments.find((i) => i.id === 'pluck')!;
190 const sr = 44100;
191 const out = pluck.render({
192 notes: [{ timeSec: 1, durationSec: 0.25, pitch: 69, velocity: 0.8 }],
193 sampleRate: sr,
194 lengthSamples: sr * 3,
195 }) as Float32Array;
196 assert.equal(out.length, sr * 3);
197 let before = 0;
198 for (let i = 0; i < sr - 10; i++) before = Math.max(before, Math.abs(out[i]));
199 let after = 0;
200 for (let i = sr; i < sr * 2; i++) after = Math.max(after, Math.abs(out[i]));
201 assert.equal(before, 0);
202 assert.ok(after > 0.01);
203});
205// --- mixing ---
207test('mute and solo select audible tracks', () => {
208 const a = mkTrack('a', 'pluck', []);
209 const b = mkTrack('b', 'pluck', [], { mute: true });
210 const c = mkTrack('c', 'pluck', []);
211 assert.deepEqual([...audibleTracks([a, b, c])].sort(), ['a', 'c']);
212 const cSolo = { ...c, solo: true };
213 assert.deepEqual([...audibleTracks([a, b, cSolo])], ['c']);
214});
216test('mixTracks applies volume and excludes muted tracks', () => {
217 const a = mkTrack('a', 'pluck', [], { volume: 0.5 });
218 const b = mkTrack('b', 'pluck', [], { mute: true });
219 const buffers = new Map<string, Float32Array>([
220 ['a', new Float32Array([0.4, 0.4])],
221 ['b', new Float32Array([0.9, 0.9])],
222 ]);
223 const mix = mixTracks([a, b], buffers);
224 close(mix[0], 0.2, 1e-6);
225});
227test('trackRenderKey changes when the notes change', () => {
228 const song: Song = {
229 ppq: 480,
230 tempo: [{ tick: 0, usPerQuarter: 500000 }],
231 timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
232 tracks: [],
233 };
234 const t1 = mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]);
235 const t2 = mkTrack('a', 'pluck', [mkNote(0, 480, 61, 100, 'a')]);
236 const t3 = mkTrack('a', 'epiano', [mkNote(0, 480, 60, 100, 'a')]);
237 assert.equal(trackRenderKey(song, t1), trackRenderKey(song, mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'zzz')])));
238 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t2));
239 assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t3));
240});
242test('WAV encoding writes a well-formed header', () => {
243 const wav = encodeWavMono16(new Float32Array([0, 0.5, -0.5, 2]), 44100);
244 assert.equal(wav.length, 44 + 8);
245 assert.equal(String.fromCharCode(...wav.subarray(0, 4)), 'RIFF');
246 assert.equal(String.fromCharCode(...wav.subarray(8, 12)), 'WAVE');
247 const dv = new DataView(wav.buffer);
248 assert.equal(dv.getUint32(24, true), 44100);
249 assert.equal(dv.getInt16(44 + 6, true), 32767); // clipped to full scale
250});
252console.log(`\n${passed} passed, ${failed} failed`);
253process.exit(failed > 0 ? 1 : 0);
↑↓ move↵openescclose