fix(align): review round 1 for the alignment page

- server: collapse leading slashes so //host/x is never proxied off-host
  or redirected cross-origin; 1 MB body limit; max 10k anchors; a corrupt
  alignment.json no longer 500s /api/songs (song falls back to auto with
  a warning); comments on cookie scope and the loopback inner server
- warp (ruling A6): adding/moving a manual anchor removes autos only
  within 10 s (MIDI) of the new/old position, never past a manual, plus
  any auto that would break audio monotonicity
- page: first anchor clamped at >= 0 s; ruler scrubbing seeks on release;
  wording for the new auto-removal rule; removed-count excludes the
  dragged anchor; shows server warnings
- manifest: unique tmp name for alignment.json; clear error for a
  corrupt file
- resample test: speed asserted as a ratio to the direct path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 14:48:32 +01:00
1 parent 894fd9b9ef
commit f0249995cf
10 files changed
+192 -45

No files matched your search

+3
View File
@@ -16,6 +16,9 @@ describe("dragAudio (recording end)", () => {
expect(dragAudio(base, 1, 5).anchors[1]!.audioSec).toBeCloseTo(11 + MIN_GAP_SEC, 9);
expect(dragAudio(base, 1, 99).anchors[1]!.audioSec).toBeCloseTo(32 - MIN_GAP_SEC, 9);
});
test("the first anchor cannot be dragged before the start of the recording", () => {
expect(dragAudio(base, 0, -3).anchors[0]!.audioSec).toBe(0);
});
test("dragging an auto anchor makes it manual and drops the autos in its stretches", () => {
const list = [m(0, 0), a(5, 5.2), a(10, 10.3), a(15, 15.1), m(20, 20)];
const r = dragAudio(list, 2, 10.5);
+61
View File
@@ -42,6 +42,51 @@ describe("access token", () => {
});
});
/** Raw HTTP/1.1 GET (Bun's fetch normalises "//" in paths, which would hide the bug under test). */
async function rawGet(path: string, cookieHeader = ""): Promise<{ status: number; headers: Record<string, string> }> {
const text = await new Promise<string>((resolve, reject) => {
let buf = "";
Bun.connect({
hostname: "127.0.0.1", port: srv.port,
socket: {
open(sk) { sk.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\n${cookieHeader ? `Cookie: ${cookieHeader}\r\n` : ""}Connection: close\r\n\r\n`); },
data(sk, d) {
buf += d.toString();
const end = buf.indexOf("\r\n\r\n"); // complete once headers and Content-Length bytes have arrived
const len = Number(buf.slice(0, end).match(/content-length:\s*(\d+)/i)?.[1] ?? 0);
if (end >= 0 && buf.length - end - 4 >= len) { sk.end(); resolve(buf); }
},
close() { resolve(buf); },
error(_, e) { reject(e); },
},
}).catch(reject);
});
const [head] = text.split("\r\n\r\n");
const [statusLine, ...lines] = head!.split("\r\n");
const headers = Object.fromEntries(lines.map((l) => { const i = l.indexOf(":"); return [l.slice(0, i).toLowerCase(), l.slice(i + 1).trim()]; }));
return { status: Number(statusLine!.split(" ")[1]), headers };
}
describe("paths with leading double slashes stay on this server", () => {
test("GET //example.invalid/ is not proxied off-host (served locally: 404)", async () => {
const r = await rawGet("//example.invalid/", cookie);
expect(r.status).toBe(404);
expect(r.headers.location).toBeUndefined();
});
test("GET //<this server>/ serves the local page, not a proxied fetch", async () => {
const r = await rawGet(`//127.0.0.1:${srv.port}/`, cookie);
expect(r.status).toBe(404); // collapsed to /127.0.0.1:<port>/ on the inner server
});
test("the ?key= redirect keeps a same-origin Location", async () => {
const r = await rawGet(`//example.invalid/x?key=${srv.token}`);
expect(r.status).toBe(302);
const loc = r.headers.location!;
expect(loc.startsWith("/")).toBe(true);
expect(loc.startsWith("//")).toBe(false);
expect(new URL(loc, srv.origin).origin).toBe(new URL(srv.origin).origin);
});
});
describe("page", () => {
test("GET / serves the HTML page and its bundled script", async () => {
const r = await api("/");
@@ -158,4 +203,20 @@ describe("API", () => {
expect(g.savedAt).toBe(saved.savedAt);
expect(await (await api("/api/songs")).json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: true }]);
});
test("more than 10k anchors is rejected with 400", async () => {
const many = Array.from({ length: 10_001 }, (_, k) => ({ midiSec: k * 0.01, audioSec: k * 0.01, kind: "auto" }));
expect((await api("/api/song/fix/fit", json({ anchors: many }))).status).toBe(400);
});
test("a corrupt alignment.json does not break the song list; the song falls back to auto with a warning", async () => {
await Bun.write(join(root, "fix", "alignment.json"), "{ not json");
const list = await api("/api/songs");
expect(list.status).toBe(200);
expect(await list.json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: false, alignmentError: expect.stringContaining("alignment.json") }]);
const g = await api("/api/song/fix");
expect(g.status).toBe(200);
const j = await g.json();
expect(j.source).toBe("auto");
expect(j.warning).toContain("alignment.json");
expect(j.anchors.length).toBeGreaterThan(2);
});
});
+35 -3
View File
@@ -108,8 +108,8 @@ describe("addManual", () => {
expect(addManual([], { midiSec: 2, audioSec: 2.5 })).toEqual([M(2, 2.5)]);
});
test("removes the whole contiguous auto run up to the nearest manual on each side", () => {
// M0 | A2 | A4 | M8 ; insert manual at 6 -> both autos between M0 and M8 go (the run stops at manuals)
test("removes autos near the new anchor (both within 10 s here), never past a manual", () => {
// M0 | A2 | A4 | M8 ; insert manual at 6 -> both autos between M0 and M8 go
const base = [M(0, 0), A(2, 2), A(4, 4.5), M(8, 8)];
expect(addManual(base, { midiSec: 6, audioSec: 6.2 })).toEqual([M(0, 0), M(6, 6.2), M(8, 8)]);
});
@@ -126,7 +126,39 @@ describe("addManual", () => {
test("moving a manual anchor across several autos succeeds and removes the crossed autos", () => {
// M0 | A10 | A20 | M30 | A40 | M60 ; move M30 -> midi 45 (crosses A40, stays before M60)
const base = [M(0, 0), A(10, 10), A(20, 20), M(30, 30), A(40, 40), M(60, 60)];
expect(moveAnchor(base, 3, { midiSec: 45, audioSec: 45 })).toEqual([M(0, 0), M(45, 45), M(60, 60)]);
// A40 is within 10 s of the new position, A20 within 10 s of the old one; A10 is further than 10 s from both and kept
expect(moveAnchor(base, 3, { midiSec: 45, audioSec: 45 })).toEqual([M(0, 0), A(10, 10), M(45, 45), M(60, 60)]);
});
test("autos every 2 s, no manuals: a manual at 60 s removes only the autos within 10 s (50..70)", () => {
const base = Array.from({ length: 60 }, (_, k) => A(2 * k + 1, 2 * k + 1)); // 1, 3, ..., 119
const out = addManual(base, { midiSec: 60, audioSec: 60 });
expect(out.filter((a) => a.kind === "auto").map((a) => a.midiSec)).toEqual(base.map((a) => a.midiSec).filter((m) => m < 50 || m > 70));
expect(out.find((a) => a.kind === "manual")).toEqual(M(60, 60));
});
test("autos outside the 10 s window that would break audio monotonicity are removed too", () => {
const base = Array.from({ length: 60 }, (_, k) => A(2 * k + 1, 2 * k + 1));
// a manual at midi 60 placed at audio 75: autos at midi 71, 73 (outside the window) sit at audio < 75 after it
const out = addManual(base, { midiSec: 60, audioSec: 75 });
const autos = out.filter((a) => a.kind === "auto").map((a) => a.midiSec);
expect(autos).not.toContain(71);
expect(autos).not.toContain(73);
expect(autos).toContain(75 + 2); // 77 > 75 stays
expect(autos).toContain(49);
expect(() => makeWarp(out)).not.toThrow();
});
test("a drag that crosses an auto outside the window removes it via the monotonicity rule", () => {
const base = [...Array.from({ length: 30 }, (_, k) => A(2 * k + 1, 2 * k + 1)).filter((a) => a.midiSec !== 31), M(30, 30)].sort((x, y) => x.midiSec - y.midiSec);
const i = base.findIndex((a) => a.kind === "manual");
// keep MIDI 30, drag the recording end to 45 s: autos at midi 41, 43 (window is 20..40) now lie before it in audio
const out = moveAnchor(base, i, { midiSec: 30, audioSec: 45 });
const autos = out.filter((a) => a.kind === "auto").map((a) => a.midiSec);
expect(autos.filter((m) => m >= 20 && m <= 44)).toEqual([]);
expect(autos).toContain(47);
expect(autos).toContain(19);
expect(() => makeWarp(out)).not.toThrow();
});
test("auto runs are removed even when they are the only anchors besides the moved one", () => {