Files
STSheets/tests/align/page/timeline.test.ts
T
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

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);
});
});