Files
Lucas WintherandClaude Opus 5.5 894fd9b9ef feat(align): alignment web page
Token-protected Bun.serve server (bun run align [--host] [--port]
[--url-host] [--root]) with the layout-A page: recording and warped-MIDI
lanes on one zoomable timeline, draggable anchors (recording end / MIDI
end), stretch tempo labels with warnings, fit-flagged windows, Web Audio
playback with mix choice, auto-align, save, reset, undo. API per the task
brief; transcode/peaks cache under reference/<id>/. Adds bun run align
auto <id>, writeSmf (tests), encodeWav, recordingOnsets/fitFromOnsets,
fitSummary and stretch tempo helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:38:36 +01:00

138 lines
6.7 KiB
TypeScript

import { test, expect, describe } from "bun:test";
import { existsSync } from "node:fs";
import { parseSmf, writeSmf } from "../../src/align/smf.ts";
import { guessRole } from "../../src/align/roles.ts";
import type { RefTrack } from "../../src/align/smf.ts";
const vlq = (v: number): number[] => {
const out = [v & 0x7f];
while ((v >>= 7) > 0) out.unshift((v & 0x7f) | 0x80);
return out;
};
const u32 = (v: number) => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
const u16 = (v: number) => [(v >> 8) & 255, v & 255];
const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
const chunk = (id: string, body: number[]) => [...ascii(id), ...u32(body.length), ...body];
const meta = (dt: number, type: number, data: number[]) => [...vlq(dt), 0xff, type, ...vlq(data.length), ...data];
const name = (n: string) => meta(0, 3, ascii(n));
const tempo = (dt: number, bpm: number) => { const us = Math.round(60e6 / bpm); return meta(dt, 0x51, [(us >> 16) & 255, (us >> 8) & 255, us & 255]); };
const end = meta(0, 0x2f, []);
const smf = (tracks: number[][], ppq = 480) => new Uint8Array([...chunk("MThd", [...u16(1), ...u16(tracks.length), ...u16(ppq)]), ...tracks.flatMap((t) => chunk("MTrk", t))]);
function build() {
const conductor = [...name("tempo"), ...meta(0, 0x58, [3, 2, 24, 8]), ...tempo(0, 120), ...tempo(1920, 60), ...end];
// guitar: program 25; note 60 at tick 0 for 480 ticks, then running-status note 62 using velocity-0 note-off
const guitar = [
...name("Guitar 1"), ...vlq(0), 0xc0, 25,
...vlq(0), 0x90, 60, 100,
...vlq(480), 0x80, 60, 0,
...vlq(0), 0x90, 62, 90, // new status
...vlq(480), 62, 0, // running status note-on vel 0 = off, at tick 960
...vlq(0), 0xf0, 3, 1, 2, 0xf7, // sysex skipped
...vlq(960), 0x90, 64, 80, // tick 1920 (tempo change point) -> 2.0 s
...vlq(480), 64, 0, // tick 2400 -> 2.0 + 1.0 s
...end,
];
const drums = [...name("Drums"), ...vlq(0), 0x99, 36, 100, ...vlq(240), 0x99, 36, 0, ...end];
return smf([conductor, guitar, drums]);
}
describe("parseSmf", () => {
const s = parseSmf(build());
test("tempo map and time signature", () => {
expect(s.tempoMap).toEqual([{ sec: 0, bpm: 120 }, { sec: 2, bpm: 60 }]);
expect(s.timeSigs).toEqual([{ sec: 0, num: 3, den: 4 }]);
});
test("note times in seconds across tempo change", () => {
const g = s.tracks.find((t) => t.name === "Guitar 1")!;
expect(g.program).toBe(25);
expect(g.isDrums).toBe(false);
expect(g.notes.map((n) => [n.onset, n.offset, n.pitch, n.velocity])).toEqual([
[0, 0.5, 60, 100], [0.5, 1, 62, 90], [2, 3, 64, 80],
]);
expect(s.durationSec).toBeCloseTo(3, 9);
});
test("drums on channel 10", () => {
const d = s.tracks.find((t) => t.name === "Drums")!;
expect(d.isDrums).toBe(true);
expect(d.notes).toHaveLength(1);
expect(d.notes[0]!.channel).toBe(9);
});
test("conductor track without notes is dropped; index is SMF track order", () => {
expect(s.tracks.map((t) => t.index)).toEqual([1, 2]);
});
test("overlapping same-pitch notes on one channel pair FIFO", () => {
const t = [...name("x"), 0, 0x90, 60, 50, ...vlq(100), 0x90, 60, 60, ...vlq(100), 0x80, 60, 0, ...vlq(100), 0x80, 60, 0, ...end];
const n = parseSmf(smf([t], 480)).tracks[0]!.notes;
expect(n.map((x) => [x.velocity, x.offset > x.onset])).toEqual([[50, true], [60, true]]);
expect(n[0]!.offset).toBeLessThan(n[1]!.offset);
expect(n[0]!.onset).toBeLessThan(n[1]!.onset);
});
test("same pitch on different channels are independent", () => {
const t = [0, 0x90, 60, 50, 0, 0x91, 60, 50, ...vlq(480), 0x81, 60, 0, ...vlq(480), 0x80, 60, 0, ...end];
const n = parseSmf(smf([t])).tracks[0]!.notes;
expect(n.map((x) => [x.channel, x.offset]).sort()).toEqual([[0, 1], [1, 0.5]]);
});
test("rejects non-SMF", () => {
expect(() => parseSmf(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]))).toThrow();
});
});
describe("guessRole", () => {
const t = (o: Partial<RefTrack>): RefTrack => ({ index: 0, name: "", program: -1, isDrums: false, notes: [], ...o });
test("by drums / name / program", () => {
expect(guessRole(t({ isDrums: true }))).toBe("drums");
expect(guessRole(t({ name: "Lead Vocals" }))).toBe("vocals");
expect(guessRole(t({ name: "Bass" }))).toBe("bass");
expect(guessRole(t({ name: "Billie Joe Armstrong | Rhythm Guitar" }))).toBe("guitar");
expect(guessRole(t({ name: "Acoustic Guitar " }))).toBe("guitar");
expect(guessRole(t({ name: "Delay Lead" }))).toBe("guitar");
expect(guessRole(t({ name: "Piano" }))).toBe("keys");
expect(guessRole(t({ name: "Synth Pad" }))).toBe("keys");
expect(guessRole(t({ name: "", program: 25 }))).toBe("guitar");
expect(guessRole(t({ name: "", program: 33 }))).toBe("bass");
});
test("unknown -> null", () => {
expect(guessRole(t({ name: "Brass", program: 61 }))).toBeNull();
expect(guessRole(t({ name: "Crowd", program: 126 }))).toBeNull();
expect(guessRole(t({ name: "" }))).toBeNull();
});
test("integration with parsed SMF", () => {
const s = parseSmf(build());
expect(s.tracks.map(guessRole)).toEqual(["guitar", "drums"]);
});
});
const REAL = "Passenger-Holes-10-24-2016.mid";
describe("real GP export", () => {
test.skipIf(!existsSync(REAL))("Holes parses; Brass/Crowd unmapped", async () => {
const s = parseSmf(new Uint8Array(await Bun.file(REAL).arrayBuffer()));
expect(s.tracks).toHaveLength(7);
for (const t of s.tracks) {
if (/^(brass|crowd)/i.test(t.name.trim())) expect(guessRole(t)).toBeNull();
}
expect(s.durationSec).toBeGreaterThan(60);
});
});
describe("writeSmf", () => {
test("round-trips tracks, programs, drums, tempo and note times through parseSmf", () => {
const tracks = [
{ name: "Guitar", program: 25, isDrums: false, notes: [{ onset: 0, offset: 0.5, pitch: 60, velocity: 100 }, { onset: 0.25, offset: 1.5, pitch: 64, velocity: 80 }] },
{ name: "Drums", program: 0, isDrums: true, notes: [{ onset: 1, offset: 1.1, pitch: 36, velocity: 110 }] },
];
const s = parseSmf(writeSmf(tracks, 90));
expect(s.tempoMap).toEqual([{ sec: 0, bpm: expect.closeTo(90, 3) }]);
expect(s.tracks.map((t) => [t.name, t.program, t.isDrums])).toEqual([["Guitar", 25, false], ["Drums", 0, true]]);
const g = s.tracks[0]!.notes;
expect(g.map((n) => n.pitch)).toEqual([60, 64]);
for (const [k, n] of tracks[0]!.notes.entries()) {
expect(g[k]!.onset).toBeCloseTo(n.onset, 3);
expect(g[k]!.offset).toBeCloseTo(n.offset, 3);
expect(g[k]!.velocity).toBe(n.velocity);
}
expect(s.tracks[1]!.notes[0]!.channel).toBe(9);
expect(s.durationSec).toBeCloseTo(1.5, 3);
});
});