Files
STSheets/tests/align/server.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

162 lines
8.8 KiB
TypeScript

import { test, expect, beforeAll, afterAll, describe } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startServer, type AlignServer } from "../../src/align/server.ts";
import { addSong } from "../../src/ref/manifest.ts";
import { writeSmf } from "../../src/align/smf.ts";
import { buildSong, loadSongDir } from "../../src/bench/dataset.ts";
import type { Anchor } from "../../src/align/warp.ts";
const BIG = 120_000;
let root = "", srv: AlignServer, cookie = "";
const url = (p: string) => new URL(p, srv.origin).toString();
const api = (p: string, init: RequestInit = {}) => fetch(url(p), { ...init, headers: { cookie, ...(init.headers ?? {}) }, redirect: "manual" });
const json = (body: unknown, method = "POST"): RequestInit => ({ method, body: JSON.stringify(body), headers: { "content-type": "application/json" } });
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), "stsheets-align-"));
const dir = await buildSong("t1a", 1);
const { song, truth } = await loadSongDir(dir);
const midi = join(root, "fixture.mid");
await Bun.write(midi, writeSmf(truth.tracks.map((t, i) => ({ name: t.name, program: song.tracks[i]!.program, isDrums: t.role === "drums", notes: t.notes })), song.bpm));
await addSong({ id: "fix", title: "Fixture Song", audio: join(dir, "mix.wav"), midi, capo: {} }, root);
srv = await startServer({ root, host: "127.0.0.1", port: 0, quiet: true });
}, BIG);
afterAll(async () => { await srv?.stop(); if (root) await rm(root, { recursive: true, force: true }); });
describe("access token", () => {
test("requests without the token get 401", async () => {
for (const p of ["/", "/api/songs", "/api/song/fix", "/api/songs?key=wrong"]) expect((await fetch(url(p), { redirect: "manual" })).status).toBe(401);
expect((await fetch(url("/api/songs"), { headers: { cookie: "stsheets_align=nope" } })).status).toBe(401);
});
test("printed URL carries the token; ?key= sets an HttpOnly cookie and redirects to a clean URL", async () => {
expect(srv.url).toContain(`?key=${srv.token}`);
const r = await fetch(url(`/?key=${srv.token}`), { redirect: "manual" });
expect(r.status).toBe(302);
expect(r.headers.get("location")).toBe("/");
const sc = r.headers.get("set-cookie") ?? "";
expect(sc).toContain("HttpOnly");
cookie = sc.split(";")[0]!;
expect(cookie).toBe(`stsheets_align=${srv.token}`);
});
});
describe("page", () => {
test("GET / serves the HTML page and its bundled script", async () => {
const r = await api("/");
expect(r.status).toBe(200);
const html = await r.text();
expect(html).toContain("<canvas");
const src = html.match(/<script[^>]+src="([^"]+)"/)?.[1];
expect(src).toBeTruthy();
const js = await api(src!);
expect(js.status).toBe(200);
expect((await js.text()).length).toBeGreaterThan(1000);
expect((await fetch(url(src!))).status).toBe(401); // assets are behind the token too
});
});
describe("API", () => {
test("songs list", async () => {
expect(await (await api("/api/songs")).json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: false }]);
});
test("unknown and path-traversal ids are 404", async () => {
for (const p of ["/api/song/nope", "/api/song/%2E%2E", "/api/song/..%2F..%2Fetc", "/api/song/%2E%2E/audio", "/api/song/nope/render"]) {
expect((await api(p, p.endsWith("render") ? json({ anchors: [] }) : {})).status).toBe(404);
}
});
let first: any;
test("song: peaks, auto anchors (unsaved), stretches with MIDI and effective bpm, fit, tempo map", async () => {
const r = await api("/api/song/fix");
expect(r.status).toBe(200);
first = await r.json();
expect(first.id).toBe("fix");
expect(first.title).toBe("Fixture Song");
expect(first.audioDur).toBeGreaterThan(20);
expect(first.midiDur).toBeGreaterThan(20);
expect(first.tracks.length).toBeGreaterThan(0);
expect(first.tracks.every((t: any) => typeof t.name === "string" && "role" in t)).toBe(true);
expect(first.recordingPeaks.bucketSec).toBeGreaterThan(0);
expect(first.recordingPeaks.max.length).toBeGreaterThan(first.audioDur * 20);
expect(Math.max(...first.recordingPeaks.max)).toBeGreaterThan(0.05);
expect(first.source).toBe("auto");
expect(first.savedAt).toBeNull();
expect(first.anchors.length).toBeGreaterThan(2);
expect(first.anchors.every((a: Anchor) => a.kind === "auto")).toBe(true);
expect(first.stretches.length).toBeGreaterThanOrEqual(first.anchors.length); // +1, minus an empty edge stretch
expect(first.stretches[1].midiBpm).toBeGreaterThan(30);
expect(first.stretches[1].bpm).toBeCloseTo(first.stretches[1].midiBpm / first.stretches[1].ratio, 6);
expect(first.fit.matchRate).toBeGreaterThan(0.5);
expect(first.midiTempo[0].bpm).toBeGreaterThan(30);
expect(await Bun.file(join(root, "fix", "recording.wav")).exists()).toBe(true);
expect(await Bun.file(join(root, "fix", "peaks.json")).exists()).toBe(true);
}, BIG);
test("audio: cached Ogg Opus", async () => {
const r = await api("/api/song/fix/audio");
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toContain("audio/ogg");
const b = new Uint8Array(await r.arrayBuffer());
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("OggS");
expect(await Bun.file(join(root, "fix", "audio.ogg")).exists()).toBe(true);
}, BIG);
test("render: RIFF WAV, mono 22.05 kHz, cached by anchors", async () => {
const body = json({ anchors: first.anchors });
const r = await api("/api/song/fix/render", body);
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toBe("audio/wav");
const b = new Uint8Array(await r.arrayBuffer());
const dv = new DataView(b.buffer);
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("RIFF");
expect(dv.getUint16(22, true)).toBe(1);
expect(dv.getUint32(24, true)).toBe(22050);
expect(b.length).toBeGreaterThan(first.audioDur * 22050 * 2);
const again = await api("/api/song/fix/render", body);
expect(again.headers.get("x-cache")).toBe("hit");
expect(again.headers.get("x-anchors-hash")).toBe(r.headers.get("x-anchors-hash"));
}, BIG);
test("fit: re-scores arbitrary anchors", async () => {
const r = await api("/api/song/fix/fit", json({ anchors: [{ midiSec: 1, audioSec: 1.4, kind: "manual" }] }));
const j = await r.json();
expect(j.fit.windows.length).toBeGreaterThan(0);
expect(j.fit.matchRate).toBeLessThan(first.fit.matchRate);
expect(j.stretches.length).toBe(2);
});
test("bad anchors are rejected with 400", async () => {
const bad = [{ anchors: "x" }, { anchors: [{ midiSec: 1, audioSec: 2, kind: "manual" }, { midiSec: 2, audioSec: 1, kind: "manual" }] }, { anchors: [{ midiSec: "1", audioSec: 2, kind: "manual" }] }, { anchors: [{ midiSec: 1, audioSec: 2, kind: "weird" }] }];
for (const b of bad) expect((await api("/api/song/fix/fit", json(b))).status).toBe(400);
expect((await api("/api/song/fix/fit", { method: "POST", body: "{not json" })).status).toBe(400);
});
test("auto: keeps manual anchors and re-solves autos around them", async () => {
const manual: Anchor = { ...first.anchors[3], kind: "manual" };
const r = await api("/api/song/fix/auto", json({ anchors: [manual, ...first.anchors.slice(5)] }));
const j = await r.json();
expect(j.anchors.filter((a: Anchor) => a.kind === "manual")).toEqual([manual]);
expect(j.anchors.filter((a: Anchor) => a.kind === "auto").length).toBeGreaterThan(2);
expect(j.fit.matchRate).toBeGreaterThan(0.5);
expect(j.stretches.length).toBeGreaterThanOrEqual(j.anchors.length);
}, BIG);
test("reset: raw MIDI (no anchors)", async () => {
const j = await (await api("/api/song/fix/reset", { method: "POST" })).json();
expect(j.anchors).toEqual([]);
expect(j.stretches).toHaveLength(1);
expect(j.fit.windows.length).toBeGreaterThan(0);
});
test("PUT alignment then GET round-trips anchors; file holds anchors, savedAt, fit", async () => {
const anchors: Anchor[] = [{ midiSec: 0.5, audioSec: 0.52, kind: "manual" }, { midiSec: 10, audioSec: 10.05, kind: "auto" }];
const p = await api("/api/song/fix/alignment", json({ anchors }, "PUT"));
expect(p.status).toBe(200);
const saved = await p.json();
expect(typeof saved.savedAt).toBe("string");
const file = await Bun.file(join(root, "fix", "alignment.json")).json();
expect(file.anchors).toEqual(anchors);
expect(file.savedAt).toBe(saved.savedAt);
expect(file.fit.windows.length).toBeGreaterThan(0);
const g = await (await api("/api/song/fix")).json();
expect(g.anchors).toEqual(anchors);
expect(g.source).toBe("saved");
expect(g.savedAt).toBe(saved.savedAt);
expect(await (await api("/api/songs")).json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: true }]);
});
});