// Deterministic node-side tests: MIDI round trip, tempo math, editing // ops, DSP determinism, and mixing rules. Run with: npm test import assert from 'node:assert/strict'; import { TempoMap } from '../src/midi/time.ts'; import { parseMidi } from '../src/midi/parse.ts'; import { writeMidi } from '../src/midi/write.ts'; import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes, transposeNotes } from '../src/midi/ops.ts'; import { drumNote, epianoNote, leadNote, pluckNote } from '../src/instruments/dsp.ts'; import { builtinInstruments } from '../src/instruments/builtins.ts'; import { audibleTracks, encodeWavMono16, mixTracks, trackRenderKey } from '../src/audio/render.ts'; import { parseBundle } from '../src/instruments/numbl/bundle.ts'; import { bucketDuration, bucketVelocity, buildProgram, noteCacheKey } from '../src/instruments/numbl/render.ts'; import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Note, Song, Track } from '../src/types.ts'; let failed = 0; let passed = 0; function test(name: string, fn: () => void) { try { fn(); passed++; console.log(`ok - ${name}`); } catch (err) { failed++; console.error(`FAIL - ${name}`); console.error(err); } } function close(a: number, b: number, tol = 1e-9) { assert.ok(Math.abs(a - b) <= tol, `${a} !~ ${b}`); } function hash(buf: Float32Array): number { let h = 0x811c9dc5; for (let i = 0; i < buf.length; i++) { h ^= Math.round(buf[i] * 1e6) & 0xffff; h = Math.imul(h, 0x01000193); } return h >>> 0; } function mkNote(tick: number, duration: number, pitch: number, velocity: number, id: string): Note { return { id, tick, duration, pitch, velocity }; } function mkTrack(id: string, instrumentId: string, notes: Note[], extra: Partial = {}): Track { return { id, name: id, instrumentId, volume: 1, mute: false, solo: false, notes, ...extra }; } // --- tempo map --- test('tempo map: constant 120 bpm', () => { const tm = new TempoMap(480, [{ tick: 0, usPerQuarter: 500000 }]); close(tm.tickToSec(480), 0.5); close(tm.tickToSec(1920), 2); close(tm.secToTick(1), 960); close(tm.bpmAt(0), 120); }); test('tempo map: tempo change mid-song', () => { const tm = new TempoMap(480, [ { tick: 0, usPerQuarter: 500000 }, { tick: 960, usPerQuarter: 250000 }, ]); close(tm.tickToSec(960), 1); close(tm.tickToSec(1920), 1.5); close(tm.secToTick(1.25), 1440); close(tm.bpmAt(1000), 240); }); test('tempo map: bar.beat display in 4/4 and 3/4', () => { const tm44 = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]); assert.deepEqual(tm44.barBeatAt(0), { bar: 1, beat: 1 }); assert.deepEqual(tm44.barBeatAt(480 * 5), { bar: 2, beat: 2 }); const tm34 = new TempoMap(480, [], [{ tick: 0, numerator: 3, denominator: 4 }]); assert.deepEqual(tm34.barBeatAt(480 * 3), { bar: 2, beat: 1 }); }); test('tempo map: grid lines mark bars', () => { const tm = new TempoMap(480, [], [{ tick: 0, numerator: 4, denominator: 4 }]); const lines = tm.gridLines(0, 1920); assert.equal(lines.length, 4); assert.deepEqual(lines[0], { tick: 0, isBar: true }); assert.deepEqual(lines[1], { tick: 480, isBar: false }); }); // --- MIDI round trip --- function sortedNotes(t: Track): [number, number, number, number][] { return t.notes .map((n) => [n.tick, n.duration, n.pitch, n.velocity] as [number, number, number, number]) .sort((a, b) => a[0] - b[0] || a[2] - b[2]); } test('MIDI write/parse round trip preserves notes, tempo, and drums', () => { const song: Song = { ppq: 480, tempo: [ { tick: 0, usPerQuarter: 500000 }, { tick: 1920, usPerQuarter: 400000 }, ], timeSig: [{ tick: 0, numerator: 3, denominator: 4 }], tracks: [ mkTrack('melody', 'pluck', [ mkNote(0, 480, 60, 100, 'a'), mkNote(480, 240, 64, 90, 'b'), mkNote(100000, 960, 72, 127, 'c'), // multi-byte delta times mkNote(480, 240, 67, 1, 'd'), // chord + min velocity ]), mkTrack('kit', 'drumkit', [ mkNote(0, 120, 36, 110, 'e'), mkNote(480, 120, 38, 80, 'f'), ]), ], }; const bytes = writeMidi(song); const back = parseMidi(bytes); assert.equal(back.ppq, 480); assert.equal(back.tracks.length, 2); assert.deepEqual(sortedNotes(back.tracks[0]), sortedNotes(song.tracks[0])); assert.deepEqual(sortedNotes(back.tracks[1]), sortedNotes(song.tracks[1])); assert.equal(back.tracks[0].name, 'melody'); assert.equal(back.tracks[1].instrumentId, 'drumkit'); // came back on channel 10 assert.equal(back.tracks[0].instrumentId, 'pluck'); assert.deepEqual(back.tempo, song.tempo); assert.equal(back.timeSig[0].numerator, 3); assert.equal(back.timeSig[0].denominator, 4); }); test('parse splits a multi-channel track by channel', () => { const song: Song = { ppq: 480, tempo: [{ tick: 0, usPerQuarter: 500000 }], timeSig: [{ tick: 0, numerator: 4, denominator: 4 }], tracks: [ mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]), mkTrack('b', 'drumkit', [mkNote(0, 120, 42, 100, 'b')]), ], }; // write produces separate MTrks; merging them into one channel-mixed // stream is what a format 0 file would look like, so parse the format 1 // bytes and just confirm channels landed apart. const back = parseMidi(writeMidi(song)); assert.equal(back.tracks.length, 2); assert.equal(back.tracks[1].instrumentId, 'drumkit'); }); // --- editing ops --- test('quantize rounds note starts to the grid', () => { const notes = [mkNote(130, 100, 60, 100, 'a'), mkNote(59, 100, 61, 100, 'b')]; const q = quantizeNotes(notes, 120); assert.equal(q[0].tick, 120); assert.equal(q[1].tick, 0); // 59 rounds down to the grid origin const onlyA = quantizeNotes(notes, 120, new Set(['a'])); assert.equal(onlyA[0].tick, 120); assert.equal(onlyA[1].tick, 59); }); test('transpose clamps to the MIDI range', () => { const notes = [mkNote(0, 100, 126, 100, 'a')]; assert.equal(transposeNotes(notes, 12)[0].pitch, 127); assert.equal(transposeNotes(notes, -300)[0].pitch, 0); }); test('clearRange trims, splits, and drops notes against the range', () => { const notes = [ mkNote(0, 100, 60, 100, 'before'), // ends before the range mkNote(150, 100, 61, 100, 'inside'), // fully inside: dropped mkNote(50, 100, 62, 100, 'crossStart'), // trimmed to end at 100 mkNote(250, 100, 63, 100, 'crossEnd'), // start moves to 300 mkNote(50, 400, 64, 100, 'spans'), // split in two ]; const out = clearRange(notes, 100, 300); const by = (id: string) => out.find((n) => n.id === id); assert.equal(out.length, 5); // before, crossStart, crossEnd, spans-left, spans-right assert.deepEqual([by('before')!.tick, by('before')!.duration], [0, 100]); assert.equal(by('inside'), undefined); assert.deepEqual([by('crossStart')!.tick, by('crossStart')!.duration], [50, 50]); assert.deepEqual([by('crossEnd')!.tick, by('crossEnd')!.duration], [300, 50]); assert.deepEqual([by('spans')!.tick, by('spans')!.duration], [50, 50]); const right = out.find((n) => n.pitch === 64 && n.id !== 'spans')!; assert.deepEqual([right.tick, right.duration], [300, 150]); }); test('deleteTimeRange closes the gap', () => { const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(150, 50, 61, 100, 'b'), mkNote(400, 50, 62, 100, 'c')]; const out = deleteTimeRange(notes, 100, 300); assert.equal(out.length, 2); // 'b' was inside assert.equal(out.find((n) => n.id === 'a')!.tick, 0); assert.equal(out.find((n) => n.id === 'c')!.tick, 200); }); test('insertTime shifts notes at and after the point', () => { const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(100, 50, 61, 100, 'b')]; const out = insertTime(notes, 100, 480); assert.equal(out.find((n) => n.id === 'a')!.tick, 0); assert.equal(out.find((n) => n.id === 'b')!.tick, 580); }); test('extractRange copies the in-range parts with fresh ids', () => { const notes = [mkNote(50, 400, 64, 100, 'spans')]; const out = extractRange(notes, 100, 300); assert.equal(out.length, 1); assert.deepEqual([out[0].tick, out[0].duration, out[0].pitch], [100, 200, 64]); assert.notEqual(out[0].id, 'spans'); }); test('event lists ripple with time edits and keep tick 0', () => { const tempo = [ { tick: 0, usPerQuarter: 500000 }, { tick: 200, usPerQuarter: 400000 }, { tick: 600, usPerQuarter: 300000 }, ]; const cut = deleteTimeFromEvents(tempo, 100, 500); assert.deepEqual(cut.map((e) => e.tick), [0, 200]); assert.equal(cut[1].usPerQuarter, 300000); // the 600-tick event moved to 200 const ins = insertTimeIntoEvents(tempo, 200, 100); assert.deepEqual(ins.map((e) => e.tick), [0, 300, 700]); }); // --- DSP --- test('DSP is deterministic and finite', () => { for (const [name, fn] of [ ['pluck', pluckNote], ['epiano', epianoNote], ['lead', leadNote], ['drum', drumNote], ] as const) { const a = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4); const b = fn(44100, name === 'drum' ? 38 : 60, 0.8, 0.4); assert.equal(hash(a), hash(b), `${name} not deterministic`); assert.ok(a.length > 1000, `${name} too short`); let peak = 0; for (let i = 0; i < a.length; i++) { assert.ok(Number.isFinite(a[i]), `${name} produced non-finite sample`); peak = Math.max(peak, Math.abs(a[i])); } assert.ok(peak > 0.01, `${name} is silent`); assert.ok(peak <= 1.5, `${name} peak too hot: ${peak}`); } }); test('builtin instrument render places notes at the right time', () => { const pluck = builtinInstruments.find((i) => i.id === 'pluck')!; const sr = 44100; const out = pluck.render({ notes: [{ timeSec: 1, durationSec: 0.25, pitch: 69, velocity: 0.8 }], sampleRate: sr, lengthSamples: sr * 3, }) as Float32Array; assert.equal(out.length, sr * 3); let before = 0; for (let i = 0; i < sr - 10; i++) before = Math.max(before, Math.abs(out[i])); let after = 0; for (let i = sr; i < sr * 2; i++) after = Math.max(after, Math.abs(out[i])); assert.equal(before, 0); assert.ok(after > 0.01); }); // --- mixing --- test('mute and solo select audible tracks', () => { const a = mkTrack('a', 'pluck', []); const b = mkTrack('b', 'pluck', [], { mute: true }); const c = mkTrack('c', 'pluck', []); assert.deepEqual([...audibleTracks([a, b, c])].sort(), ['a', 'c']); const cSolo = { ...c, solo: true }; assert.deepEqual([...audibleTracks([a, b, cSolo])], ['c']); }); test('mixTracks applies volume and excludes muted tracks', () => { const a = mkTrack('a', 'pluck', [], { volume: 0.5 }); const b = mkTrack('b', 'pluck', [], { mute: true }); const buffers = new Map([ ['a', new Float32Array([0.4, 0.4])], ['b', new Float32Array([0.9, 0.9])], ]); const mix = mixTracks([a, b], buffers); close(mix[0], 0.2, 1e-6); }); test('trackRenderKey changes when the notes change', () => { const song: Song = { ppq: 480, tempo: [{ tick: 0, usPerQuarter: 500000 }], timeSig: [{ tick: 0, numerator: 4, denominator: 4 }], tracks: [], }; const t1 = mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'a')]); const t2 = mkTrack('a', 'pluck', [mkNote(0, 480, 61, 100, 'a')]); const t3 = mkTrack('a', 'epiano', [mkNote(0, 480, 60, 100, 'a')]); assert.equal(trackRenderKey(song, t1), trackRenderKey(song, mkTrack('a', 'pluck', [mkNote(0, 480, 60, 100, 'zzz')]))); assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t2)); assert.notEqual(trackRenderKey(song, t1), trackRenderKey(song, t3)); }); test('WAV encoding writes a well-formed header', () => { const wav = encodeWavMono16(new Float32Array([0, 0.5, -0.5, 2]), 44100); assert.equal(wav.length, 44 + 8); assert.equal(String.fromCharCode(...wav.subarray(0, 4)), 'RIFF'); assert.equal(String.fromCharCode(...wav.subarray(8, 12)), 'WAVE'); const dv = new DataView(wav.buffer); assert.equal(dv.getUint32(24, true), 44100); assert.equal(dv.getInt16(44 + 6, true), 32767); // clipped to full scale }); // --- exported instrument bundles --- const SIM_ROOT = join(import.meta.dirname, '../../../simulated_instruments'); test('every committed bundle in ../simulated_instruments validates', () => { if (!existsSync(SIM_ROOT)) return; // vault layout absent; nothing to check const dirs = readdirSync(SIM_ROOT).filter((d) => existsSync(join(SIM_ROOT, d, 'exports'))); assert.ok(dirs.length >= 1, 'no exports found'); let count = 0; for (const d of dirs) { for (const f of readdirSync(join(SIM_ROOT, d, 'exports')).filter((f) => f.endsWith('.json'))) { const b = parseBundle(readFileSync(join(SIM_ROOT, d, 'exports', f), 'utf8')); assert.equal(`${b.id}.json`, f, `bundle id '${b.id}' should match its filename '${f}'`); count++; } } assert.ok(count >= 7, `expected at least 7 bundles, found ${count}`); }); const TEST_BUNDLE = { format: 'numbl-instrument-v1', id: 'test', name: 'Test', knobs: [{ key: 'k', label: 'K', min: 1, max: 5, default: 2 }], files: [{ name: 'render_note.m', content: 'function y = render_note(note)\ny = [note.pitch];\nend' }], }; test('buildProgram assigns knobs clamped and omits prepare without prepare.m', () => { const b = parseBundle(JSON.stringify(TEST_BUNDLE)); const code = buildProgram(b, [{ pitch: 60, velocity: 0.5, durationSec: 1 }], { sampleRate: 22050, quality: 'preview', settings: { k: 99 }, }); assert.ok(code.includes('params.k = 5;'), 'knob clamped to its max'); assert.ok(code.includes('note.pitch = 60;')); assert.ok(code.includes('params.preview = 1;')); assert.ok(!code.includes('prepare('), 'no prepare stage without prepare.m'); const withPrep = parseBundle(JSON.stringify({ ...TEST_BUNDLE, files: [...TEST_BUNDLE.files, { name: 'prepare.m', content: 'function state = prepare(params)\nstate = struct();\nend' }], })); const code2 = buildProgram(withPrep, [{ pitch: 60, velocity: 0.5, durationSec: 1 }], { sampleRate: 44100, quality: 'full', settings: {}, }); assert.ok(code2.includes('state = prepare(params);')); assert.ok(code2.includes('note.state = state;')); assert.ok(code2.includes('params.k = 2;'), 'missing knob takes its default'); assert.ok(code2.includes('params.preview = 0;')); }); test('velocity and duration bucketing quantize into few levels', () => { assert.equal(bucketVelocity(1), 1); assert.equal(bucketVelocity(0.999), 1); assert.equal(bucketVelocity(0.51), 0.625); assert.equal(bucketVelocity(0.01), 0.125); assert.ok(bucketVelocity(0) > 0); assert.equal(bucketDuration(0.9), 1); assert.equal(bucketDuration(0.05), 0.25); }); test('noteCacheKey separates settings, pitch, and quality', () => { const b = parseBundle(JSON.stringify(TEST_BUNDLE)); const k1 = noteCacheKey(b, 'preview', 22050, {}, 60, 1, 0.5); const k2 = noteCacheKey(b, 'preview', 22050, { k: 3 }, 60, 1, 0.5); const k3 = noteCacheKey(b, 'preview', 22050, {}, 62, 1, 0.5); const k4 = noteCacheKey(b, 'full', 22050, {}, 60, 1, 0.5); assert.ok(k1 !== k2 && k1 !== k3 && k1 !== k4 && k2 !== k3); }); test('bundle validation rejects malformed input', () => { assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, format: 'wrong' }))); assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, id: 'Bad Id' }))); assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, files: [] }))); assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, files: [{ name: 'other.m', content: 'x = 1;' }], })), /render_note/); assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, knobs: [{ key: 'k', label: 'K', min: 5, max: 1, default: 2 }], }))); }); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);