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]>
92 lines
3.9 KiB
TypeScript
92 lines
3.9 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import { timeToX, xToTime, fitView, zoomAt, scrollBy, clampView, follow, formatTime, rulerStep, buildPyramid, columnPeaks, MAX_PX_PER_SEC, type View } from "../../../src/align/page/timeline.ts";
|
|
|
|
const DUR = 200;
|
|
|
|
describe("view mapping", () => {
|
|
const v: View = { start: 10, pxPerSec: 50, width: 1000 };
|
|
test("time <-> x are inverse", () => {
|
|
expect(timeToX(v, 12)).toBe(100);
|
|
expect(xToTime(v, 100)).toBe(12);
|
|
for (const t of [0, 7.3, 31]) expect(xToTime(v, timeToX(v, t))).toBeCloseTo(t, 12);
|
|
});
|
|
test("fitView shows the whole song", () => {
|
|
const f = fitView(DUR, 1000);
|
|
expect(xToTime(f, 0)).toBeLessThanOrEqual(0);
|
|
expect(xToTime(f, 1000)).toBeGreaterThanOrEqual(DUR);
|
|
expect(xToTime(f, 1000)).toBeLessThan(DUR * 1.05);
|
|
});
|
|
});
|
|
|
|
describe("zoom and scroll", () => {
|
|
const v: View = { start: 50, pxPerSec: 20, width: 1000 };
|
|
test("zoomAt keeps the time under the mouse fixed", () => {
|
|
const t = xToTime(v, 300);
|
|
const z = zoomAt(v, 300, 2, DUR);
|
|
expect(z.pxPerSec).toBe(40);
|
|
expect(xToTime(z, 300)).toBeCloseTo(t, 9);
|
|
const o = zoomAt(v, 700, 0.8, DUR);
|
|
expect(xToTime(o, 700)).toBeCloseTo(xToTime(v, 700), 9);
|
|
});
|
|
test("zoom is clamped: cannot zoom out past the whole song or in past the maximum", () => {
|
|
const out = zoomAt(v, 500, 1e-6, DUR);
|
|
expect(out.pxPerSec).toBeCloseTo(fitView(DUR, 1000).pxPerSec, 9);
|
|
const inn = zoomAt(v, 500, 1e9, DUR);
|
|
expect(inn.pxPerSec).toBe(MAX_PX_PER_SEC);
|
|
});
|
|
test("scrollBy moves by pixels (drag right = earlier time) and is clamped to the song", () => {
|
|
expect(scrollBy(v, -100, DUR).start).toBeCloseTo(55, 9);
|
|
expect(scrollBy(v, 100, DUR).start).toBeCloseTo(45, 9);
|
|
expect(xToTime(scrollBy(v, 1e7, DUR), 0)).toBeGreaterThanOrEqual(-DUR * 0.05);
|
|
expect(xToTime(scrollBy(v, -1e7, DUR), 1000)).toBeLessThanOrEqual(DUR * 1.05 + 1e-9);
|
|
});
|
|
test("clampView leaves a valid view unchanged", () => {
|
|
expect(clampView(v, DUR)).toEqual(v);
|
|
});
|
|
test("follow pages the view when the playhead leaves it, and not otherwise", () => {
|
|
expect(follow(v, 60, DUR)).toEqual(v);
|
|
const f = follow(v, 120, DUR); // visible range is 50..100
|
|
expect(f.start).toBeLessThan(120);
|
|
expect(xToTime(f, 1000)).toBeGreaterThan(120);
|
|
expect(f.pxPerSec).toBe(v.pxPerSec);
|
|
});
|
|
});
|
|
|
|
test("formatTime and ruler step", () => {
|
|
expect(formatTime(58.24)).toBe("0:58.2");
|
|
expect(formatTime(130)).toBe("2:10.0");
|
|
expect(formatTime(3.04, 2)).toBe("0:03.04");
|
|
expect(formatTime(-1.5)).toBe("-0:01.5");
|
|
// labelled ticks at least ~80 px apart
|
|
for (const pps of [0.5, 3, 20, 100, 1000]) expect(rulerStep(pps) * pps).toBeGreaterThanOrEqual(80);
|
|
expect(rulerStep(20)).toBe(5);
|
|
});
|
|
|
|
describe("peaks pyramid", () => {
|
|
const sr = 1000;
|
|
const x = new Float32Array(10 * sr);
|
|
for (let i = 0; i < x.length; i++) x[i] = i >= 2000 && i < 3000 ? (i % 2 ? 0.8 : -0.5) : 0.01;
|
|
const levels = buildPyramid(x, 16);
|
|
test("each level halves the previous", () => {
|
|
expect(levels[0]!.bucket).toBe(16);
|
|
for (let k = 1; k < levels.length; k++) expect(levels[k]!.bucket).toBe(levels[k - 1]!.bucket * 2);
|
|
expect(levels.at(-1)!.min.length).toBeLessThanOrEqual(2);
|
|
});
|
|
test("columnPeaks finds the loud second (2..3 s) at any zoom", () => {
|
|
for (const pxPerSec of [2, 30, 400, 5000]) {
|
|
const v: View = { start: 0, pxPerSec, width: Math.ceil(10 * pxPerSec) };
|
|
const { min, max } = columnPeaks(levels, sr, v);
|
|
expect(min.length).toBe(v.width);
|
|
const col = Math.floor(2.5 * pxPerSec);
|
|
expect(max[col]).toBeCloseTo(0.8, 5);
|
|
expect(min[col]).toBeCloseTo(-0.5, 5);
|
|
expect(max[Math.floor(6 * pxPerSec)]).toBeCloseTo(0.01, 5);
|
|
}
|
|
});
|
|
test("columns outside the audio are silent", () => {
|
|
const { min, max } = columnPeaks(levels, sr, { start: -5, pxPerSec: 10, width: 20 });
|
|
expect(max[5]).toBe(0);
|
|
expect(min[5]).toBe(0);
|
|
});
|
|
});
|