From f0249995cffe17df90b78af3e0340c633ec638b8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 14:48:32 +0100 Subject: [PATCH] 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 --- src/align/page/app.ts | 25 +++++++------- src/align/page/edit.ts | 4 +-- src/align/page/index.html | 2 +- src/align/server.ts | 51 +++++++++++++++++++++-------- src/align/warp.ts | 27 ++++++++++++---- src/ref/manifest.ts | 14 ++++++-- tests/align/page/edit.test.ts | 3 ++ tests/align/server.test.ts | 61 +++++++++++++++++++++++++++++++++++ tests/align/warp.test.ts | 38 ++++++++++++++++++++-- tests/dsp/resample.test.ts | 12 ++++--- 10 files changed, 192 insertions(+), 45 deletions(-) diff --git a/src/align/page/app.ts b/src/align/page/app.ts index 8453da6..6de729c 100644 --- a/src/align/page/app.ts +++ b/src/align/page/app.ts @@ -3,7 +3,7 @@ // Alignment page (layout A): two lanes (recording, warped MIDI audio) on one zoomable timeline, anchors spanning both, // stretch tempo labels, fit-flagged windows, Web Audio playback with a mix choice. Geometry/edit logic is in the pure // modules ./timeline.ts and ./edit.ts; warp maths come from ../warp.ts so every edit redraws instantly. -import { deleteAnchor, makeWarp, stretches, type Anchor, type Warp } from "../warp.ts"; +import { AUTO_CLEAR_SEC, deleteAnchor, makeWarp, stretches, type Anchor, type Warp } from "../warp.ts"; import { stretchInfo, stretchLabel, type StretchInfo } from "../tempo.ts"; import { fitSummary, WINDOW_SEC, type FitReport } from "../fit.ts"; import type { TempoPoint } from "../smf.ts"; @@ -34,7 +34,7 @@ const mixButtons = [...document.querySelectorAll(".mix button interface SongInfo { id: string; title: string; audioDur: number; midiDur: number; tracks: { name: string; role: string | null }[]; recordingPeaks: { bucketSec: number; min: number[]; max: number[] }; anchors: Anchor[]; fit: FitReport; - midiTempo: TempoPoint[]; source: "saved" | "auto"; savedAt: string | null; + midiTempo: TempoPoint[]; source: "saved" | "auto"; savedAt: string | null; warning?: string; } interface Wave { levels: PeakLevel[]; rate: number; norm: number } type Mix = "both" | "rec" | "midi" | "split"; @@ -234,7 +234,7 @@ function addAtCursor() { if (!tryEdit(() => anchorAtCursor(anchors, cursor))) return; const removed = autosBefore - anchors.filter((a) => a.kind === "auto").length; setStatus(`Anchor added at ${formatTime(cursor, 2)}. Drag its MIDI end (lower lane) until the MIDI lines up with the recording.` - + (removed > 0 ? ` ${removed} automatic anchors between your neighbouring anchors were removed; "Auto-align around my anchors" fills them in again.` : "")); + + (removed > 0 ? ` ${removed} automatic anchors within ${AUTO_CLEAR_SEC} s of it were removed; "Auto-align around my anchors" fills them in again.` : "")); } function deleteSelected() { @@ -326,7 +326,8 @@ async function openSong(id: string) { songInfo.textContent = `${formatTime(s.audioDur, 0)} · MIDI ${bpms.length > 1 ? `${bpms.length} tempos` : `${bpms[0]} bpm`} · ${s.tracks.length} tracks`; songInfo.title = roles; showOverlay(null); - setStatus(s.source === "auto" ? "This is the automatic alignment (not saved yet). Play a few spots, fix anchors where it drifts, then Save." : `Loaded the saved alignment (${anchors.length} anchors).`); + if (s.warning) setStatus(s.warning, true); + else setStatus(s.source === "auto" ? "This is the automatic alignment (not saved yet). Play a few spots, fix anchors where it drifts, then Save." : `Loaded the saved alignment (${anchors.length} anchors).`); scheduleRefresh(true); // render the MIDI for the current anchors http(songUrl("/audio")) .then((b) => actx.decodeAudioData(b)) @@ -518,7 +519,7 @@ canvas.addEventListener("pointerdown", (e) => { if (x < GUTTER) return; canvas.setPointerCapture(e.pointerId); const lx = x - GUTTER, lane = laneAt(y); - if (y < REC_Y - 4) { drag = { kind: "cursor" }; setCursor(xToTime(view, lx)); return; } + if (y < REC_Y - 4) { drag = { kind: "cursor" }; setCursor(xToTime(view, lx), false); return; } const hit = lane ? hitAnchor(anchors, view, lx, HIT_PX) : -1; if (hit >= 0 && lane) { selected = hit; @@ -541,7 +542,7 @@ canvas.addEventListener("pointermove", (e) => { canvas.style.cursor = h >= 0 ? "ew-resize" : y < REC_Y - 4 && x >= GUTTER ? "text" : "default"; return; } - if (drag.kind === "cursor") { setCursor(xToTime(view, lx)); return; } + if (drag.kind === "cursor") { setCursor(xToTime(view, lx), false); return; } if (drag.kind === "pan") { if (Math.abs(x - drag.x0) > DRAG_PX) drag.moved = true; if (drag.moved) { view = scrollBy(view, x - drag.lastX, dur()); drag.lastX = x; draw(); } @@ -564,22 +565,24 @@ function endDrag(e: PointerEvent) { drag = null; canvas.style.cursor = "default"; const { x } = local(e); - if (d.kind === "pan" && !d.moved && x >= GUTTER) { selected = -1; setCursor(xToTime(view, x - GUTTER)); updateUi(); } + if (d.kind === "cursor" && playing) void play(); // scrubbing seeks playback once, on release + else if (d.kind === "pan" && !d.moved && x >= GUTTER) { selected = -1; setCursor(xToTime(view, x - GUTTER)); updateUi(); } else if ((d.kind === "rec" || d.kind === "midi") && d.moved) { const now = anchors; anchors = d.start; // commit from the drag-start state so undo returns there commit(now, d.start); - const removedAutos = d.start.filter((a) => a.kind === "auto").length - now.filter((a) => a.kind === "auto").length; - if (removedAutos > 0) setStatus(`Moved the anchor; ${removedAutos} automatic anchors next to it were removed. Use "Auto-align around my anchors" to fill them in again.`); + const removedAutos = d.start.filter((a) => a.kind === "auto").length - now.filter((a) => a.kind === "auto").length + - (d.start[d.index]!.kind === "auto" ? 1 : 0); // the dragged auto itself became manual + if (removedAutos > 0) setStatus(`Moved the anchor; ${removedAutos} automatic anchors within ${AUTO_CLEAR_SEC} s of it were removed. Use "Auto-align around my anchors" to fill them in again.`); } } canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); canvas.addEventListener("pointerleave", () => { if (!drag && hover >= 0) { hover = -1; draw(); } }); -function setCursor(t: number) { +function setCursor(t: number, seek = true) { cursor = Math.min(dur(), Math.max(0, t)); - if (playing) void play(); // jump playback to the new cursor + if (seek && playing) void play(); // jump playback to the new cursor draw(); } diff --git a/src/align/page/edit.ts b/src/align/page/edit.ts index e9c6b07..7d3a140 100644 --- a/src/align/page/edit.ts +++ b/src/align/page/edit.ts @@ -22,11 +22,11 @@ function moved(anchors: Anchor[], i: number, midiSec: number, audioSec: number): return { anchors: out, index: out.findIndex((a) => a.kind === "manual" && a.midiSec === midiSec && a.audioSec === audioSec) }; } -/** Recording end: anchor `i` of `start` (the list at drag start) to `audioSec`, MIDI time unchanged; clamped. */ +/** Recording end: anchor `i` of `start` (the list at drag start) to `audioSec`, MIDI time unchanged; clamped between manual neighbours and at >= 0. */ export function dragAudio(start: Anchor[], i: number, audioSec: number): Edit { const A = makeWarp(start).anchors; const { left, right } = manualNeighbours(A, i); - const a = clamp(audioSec, left ? left.audioSec + MIN_GAP_SEC : -Infinity, right ? right.audioSec - MIN_GAP_SEC : Infinity); + const a = clamp(audioSec, left ? left.audioSec + MIN_GAP_SEC : 0, right ? right.audioSec - MIN_GAP_SEC : Infinity); return moved(A, i, A[i]!.midiSec, a); } diff --git a/src/align/page/index.html b/src/align/page/index.html index d1b87b9..b99469a 100644 --- a/src/align/page/index.html +++ b/src/align/page/index.html @@ -54,7 +54,7 @@
  • Click the lanes to set the cursor (black line); click an anchor to select it. Wheel zooms around the mouse (Shift+wheel or a sideways swipe scrolls); drag the background to scroll.
  • Drag an anchor in the Recording lane to move it along the recording (the MIDI moment stays attached to it). Drag it in the MIDI audio lane to slide the MIDI under it: the bit of MIDI you grabbed follows the mouse while the anchor stays at the same place in the recording.
  • -
  • Moving or adding an anchor removes the automatic anchors between your neighbouring anchors; Auto-align around my anchors fills them in again.
  • +
  • Moving or adding an anchor removes the automatic anchors within 10 s of it (never past your other anchors), plus any that would now be out of order; Auto-align around my anchors fills them in again.
  • Keys: Space play/stop · A anchor at cursor · Delete delete selected · ←/→ nudge selected anchor 10 ms (Shift 100 ms; nothing selected: move cursor 1 s) · 1–4 mix · Ctrl+Z undo / Ctrl+Shift+Z redo · Ctrl+S save · 0 show whole song.
  • diff --git a/src/align/server.ts b/src/align/server.ts index 1fe2a53..6a4e19e 100644 --- a/src/align/server.ts +++ b/src/align/server.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { timingSafeEqual } from "node:crypto"; import index from "./page/index.html"; import type { Role } from "../core/types.ts"; -import { audioOgg, loadSong, readAlignment, readManifest, writeAlignment, type LoadedSong } from "../ref/manifest.ts"; +import { audioOgg, loadSong, readAlignment, readManifest, writeAlignment, type AlignmentFile, type LoadedSong } from "../ref/manifest.ts"; import { trackKeys } from "./roles.ts"; import { makeWarp, stretches, type Anchor } from "./warp.ts"; import { autoAlign, fitFromOnsets, recordingOnsets } from "./auto.ts"; @@ -16,7 +16,11 @@ import type { FitReport } from "./fit.ts"; import { encodeWav, toMono } from "../audio/wav.ts"; export const SF2_PATH = join(import.meta.dir, "../../node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2"); +// Cookies are not port-scoped: two align servers on one host share this name, so opening one server's URL replaces +// the other's cookie (the other then answers 401 until its own URL is opened again). Harmless; tokens never match. const COOKIE = "stsheets_align"; +const MAX_ANCHORS = 10_000; +const MAX_BODY_BYTES = 1 << 20; const PEAKS_PER_SEC = 2000 / 60; const PEAKS_VERSION = 1; const RENDER_SR = 22050; @@ -48,6 +52,7 @@ class Lru { function parseAnchors(body: unknown): Anchor[] { const list = (body as { anchors?: unknown } | null)?.anchors; if (!Array.isArray(list)) throw new HttpError(400, "body must be { anchors: [...] }"); + if (list.length > MAX_ANCHORS) throw new HttpError(400, `too many anchors (${list.length} > ${MAX_ANCHORS})`); const out = list.map((a: any): Anchor => { if (!a || typeof a.midiSec !== "number" || typeof a.audioSec !== "number" || (a.kind !== "manual" && a.kind !== "auto")) { throw new HttpError(400, "each anchor needs numeric midiSec, audioSec and kind 'manual' | 'auto'"); @@ -133,11 +138,25 @@ export async function startServer(opts: ServeOptions = {}): Promise return r; } - async function api(req: Request, url: URL): Promise { - const path = url.pathname, method = req.method; + /** Saved alignment with validated anchors; an unreadable/invalid file yields `error` instead of failing the request. */ + async function savedAlignment(id: string): Promise<{ file: AlignmentFile | null; error?: string }> { + try { + const f = await readAlignment(id, root); + if (!f) return { file: null }; + return { file: { ...f, anchors: parseAnchors(f) } }; + } catch (e) { + return { file: null, error: `${id}/alignment.json is unreadable (${(e as Error).message})` }; + } + } + + async function api(req: Request, path: string): Promise { + const method = req.method; if (path === "/api/songs" && method === "GET") { const all = await readManifest(root); - return Response.json(await Promise.all(all.map(async (e) => ({ id: e.id, title: e.title, hasAlignment: (await readAlignment(e.id, root)) !== null })))); + return Response.json(await Promise.all(all.map(async (e) => { + const a = await savedAlignment(e.id); + return { id: e.id, title: e.title, hasAlignment: a.file !== null, ...(a.error ? { alignmentError: a.error } : {}) }; + }))); } const m = path.match(/^\/api\/song\/([^/]+)(?:\/(audio|render|fit|auto|alignment|reset))?$/); if (!m) throw new HttpError(404, "not found"); @@ -148,15 +167,16 @@ export async function startServer(opts: ServeOptions = {}): Promise if (route === "GET ") { const s = await song(id); - const saved = await readAlignment(id, root); + const { file: saved, error } = await savedAlignment(id); let anchors: Anchor[], fit: FitReport | undefined; - if (saved) anchors = makeWarp(saved.anchors).anchors; + if (saved) anchors = saved.anchors; else { s.auto ??= runAuto(s, []); anchors = s.auto.anchors; fit = s.auto.fit; } return Response.json({ id, title: s.entry.title, audioDur: s.audioDur, midiDur: s.smf.durationSec, tracks: trackKeys(s.smf.tracks).map((name) => ({ name, role: s.roles[name] ?? null })), recordingPeaks: s.peaks, ...state(s, anchors, fit), midiTempo: s.smf.tempoMap, source: saved ? "saved" : "auto", savedAt: saved?.savedAt ?? null, + ...(error ? { warning: `${error}. Showing the automatic alignment instead; saving will overwrite the file.` } : {}), }); } if (route === "GET audio") { @@ -202,26 +222,31 @@ export async function startServer(opts: ServeOptions = {}): Promise throw new HttpError(405, "method not allowed"); } - // Inner server: Bun bundles and serves the page and its assets; reachable only from this process via loopback. + // Inner server: Bun bundles and serves the page and its assets. It deliberately has no token check: it binds + // loopback only on a random port, serves nothing but the static page bundle (no song data), and the public server + // below authenticates every request before proxying to it. const inner = Bun.serve({ hostname: "127.0.0.1", port: 0, development: false, routes: { "/": index }, fetch: () => new Response("not found", { status: 404 }), }); - async function proxyPage(req: Request, url: URL): Promise { + async function proxyPage(req: Request, path: string): Promise { if (req.method !== "GET" && req.method !== "HEAD") return new Response("method not allowed", { status: 405 }); - const r = await fetch(new URL(url.pathname, inner.url), { method: req.method }); + const r = await fetch(inner.url.origin + path, { method: req.method }); // `path` is "/..." with one leading slash + const headers = new Headers(r.headers); for (const h of ["content-encoding", "content-length", "transfer-encoding", "connection"]) headers.delete(h); return new Response(r.body, { status: r.status, headers }); } const outer = Bun.serve({ - hostname: host, port: opts.port ?? 5180, + hostname: host, port: opts.port ?? 5180, maxRequestBodySize: MAX_BODY_BYTES, idleTimeout: 255, // first open of a song transcodes and auto-aligns; renders take seconds async fetch(req) { const url = new URL(req.url); + // "//host/x" must never become a protocol-relative URL (off-host proxy target or redirect): collapse slashes + const path = "/" + url.pathname.replace(/^\/+/, ""); const key = url.searchParams.get("key"); const viaKey = key !== null && sameToken(key, token); const viaCookie = !viaKey && sameToken(cookieValue(req, COOKIE) ?? "", token); @@ -229,13 +254,13 @@ export async function startServer(opts: ServeOptions = {}): Promise return new Response("Unauthorized: open the URL printed by `bun run align` (it contains ?key=...).\n", { status: 401, headers: { "content-type": "text/plain; charset=utf-8" } }); } const setCookie = `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`; - if (viaKey && req.method === "GET" && !url.pathname.startsWith("/api/")) { + if (viaKey && req.method === "GET" && !path.startsWith("/api/")) { url.searchParams.delete("key"); // drop the token from the address bar - return new Response(null, { status: 302, headers: { location: url.pathname + url.search, "set-cookie": setCookie } }); + return new Response(null, { status: 302, headers: { location: path + url.search, "set-cookie": setCookie } }); } let res: Response; try { - res = url.pathname.startsWith("/api/") ? await api(req, url) : await proxyPage(req, url); + res = path.startsWith("/api/") ? await api(req, path) : await proxyPage(req, path); } catch (e) { const status = e instanceof HttpError ? e.status : 500; if (status === 500) console.error(`${req.method} ${url.pathname}:`, e); diff --git a/src/align/warp.ts b/src/align/warp.ts index 06272bf..c005864 100644 --- a/src/align/warp.ts +++ b/src/align/warp.ts @@ -75,17 +75,30 @@ function checkSlot(left: Anchor | undefined, right: Anchor | undefined, a: Omit< if (right && !(a.midiSec < right.midiSec && a.audioSec < right.audioSec)) throw new Error("anchor would cross its right neighbour"); } -// Inserts a manual anchor at index `at` of an already-sorted list. Removes the whole contiguous run of -// auto anchors on each side, up to the nearest manual anchor (or the song start/end). Crossing checks -// apply only against those manual neighbours, since the autos in between are removed. -function insertManual(list: Anchor[], at: number, a: Omit): Anchor[] { +/** Autos within this many MIDI seconds of an edited anchor (new position, and old position for a move) are removed. */ +export const AUTO_CLEAR_SEC = 10; + +// Inserts a manual anchor at index `at` of an already-sorted list. Removes the auto anchors within AUTO_CLEAR_SEC (MIDI +// time) of the new position, and of `oldMidi` for a move, but never past the nearest manual anchor on either side +// (manual neighbours around `at`). Then removes any remaining auto whose audio time no longer lies strictly between the +// manual anchors around it, so the result stays strictly monotonic. Crossing checks apply only against the manual +// neighbours. +function insertManual(list: Anchor[], at: number, a: Omit, oldMidi?: number): Anchor[] { let l = at - 1; while (l >= 0 && list[l]!.kind === "auto") l--; let r = at; while (r < list.length && list[r]!.kind === "auto") r++; checkSlot(list[l], list[r], a); - const out = [...list.slice(0, l + 1), { midiSec: a.midiSec, audioSec: a.audioSec, kind: "manual" as const }, ...list.slice(r)]; - return sortedCopy(out); + const near = (m: number) => Math.abs(m - a.midiSec) <= AUTO_CLEAR_SEC || (oldMidi !== undefined && Math.abs(m - oldMidi) <= AUTO_CLEAR_SEC); + const kept = list.filter((x, k) => !(x.kind === "auto" && k > l && k < r && (near(x.midiSec) || x.midiSec === a.midiSec))); + const all = [...kept, { midiSec: a.midiSec, audioSec: a.audioSec, kind: "manual" as const }].sort((x, y) => x.midiSec - y.midiSec); + // audio time of the nearest manual before / after each position + const before: number[] = [], after: number[] = []; + let lo = -Infinity; + for (let k = 0; k < all.length; k++) { before[k] = lo; if (all[k]!.kind === "manual") lo = all[k]!.audioSec; } + let hi = Infinity; + for (let k = all.length - 1; k >= 0; k--) { after[k] = hi; if (all[k]!.kind === "manual") hi = all[k]!.audioSec; } + return sortedCopy(all.filter((x, k) => x.kind === "manual" || (x.audioSec > before[k]! && x.audioSec < after[k]!))); } export function addManual(anchors: Anchor[], a: Omit): Anchor[] { @@ -98,7 +111,7 @@ export function moveAnchor(anchors: Anchor[], i: number, to: Omit= A.length) throw new RangeError(`moveAnchor: index ${i} out of range`); const rest = [...A.slice(0, i), ...A.slice(i + 1)]; - return insertManual(rest, i, to); + return insertManual(rest, i, to, A[i]!.midiSec); } export function deleteAnchor(anchors: Anchor[], i: number): Anchor[] { diff --git a/src/ref/manifest.ts b/src/ref/manifest.ts index f68ba80..c0e8aef 100644 --- a/src/ref/manifest.ts +++ b/src/ref/manifest.ts @@ -89,15 +89,23 @@ export async function audioOgg(e: SongEntry, root = "reference"): Promise ffmpeg(["-i", e.audio, "-vn", "-c:a", "libopus", "-b:a", "160k", "-f", "ogg", tmp])); } +/** The saved alignment, or null when none exists. Throws a message naming the file when it is unreadable. */ export async function readAlignment(id: string, root = "reference"): Promise { - const f = Bun.file(join(songDir(id, root), "alignment.json")); - return (await f.exists()) ? ((await f.json()) as AlignmentFile) : null; + const path = join(songDir(id, root), "alignment.json"); + const f = Bun.file(path); + if (!(await f.exists())) return null; + let a: AlignmentFile; + try { a = (await f.json()) as AlignmentFile; } catch (e) { throw new Error(`${path}: not valid JSON (${(e as Error).message})`); } + if (!a || !Array.isArray(a.anchors)) throw new Error(`${path}: missing "anchors" array`); + return a; } +let tmpCounter = 0; + export async function writeAlignment(id: string, a: AlignmentFile, root = "reference"): Promise { const dir = songDir(id, root); await mkdir(dir, { recursive: true }); - const tmp = join(dir, `alignment.json.${process.pid}.tmp`); + const tmp = join(dir, `alignment.json.${process.pid}.${Date.now()}.${tmpCounter++}.tmp`); await Bun.write(tmp, JSON.stringify(a, null, 1) + "\n"); await rename(tmp, join(dir, "alignment.json")); } diff --git a/tests/align/page/edit.test.ts b/tests/align/page/edit.test.ts index 2ba2b78..2127e7f 100644 --- a/tests/align/page/edit.test.ts +++ b/tests/align/page/edit.test.ts @@ -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); diff --git a/tests/align/server.test.ts b/tests/align/server.test.ts index a21aa7a..19bf40a 100644 --- a/tests/align/server.test.ts +++ b/tests/align/server.test.ts @@ -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 }> { + const text = await new Promise((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 /// 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:/ 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); + }); }); diff --git a/tests/align/warp.test.ts b/tests/align/warp.test.ts index fb9322b..2762ff8 100644 --- a/tests/align/warp.test.ts +++ b/tests/align/warp.test.ts @@ -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", () => { diff --git a/tests/dsp/resample.test.ts b/tests/dsp/resample.test.ts index ef15d48..281c237 100644 --- a/tests/dsp/resample.test.ts +++ b/tests/dsp/resample.test.ts @@ -23,13 +23,15 @@ function reference(x: Float32Array, from: number, to: number): Float32Array { const noise = (n: number) => { let s = 1; return Float32Array.from({ length: n }, () => ((s = (s * 16807) % 2147483647) / 2147483647) * 2 - 1); }; -test("2:1 decimation is bit-identical to the direct evaluation and fast", () => { +test("2:1 decimation is bit-identical to the direct evaluation and much faster", () => { const x = noise(44100); expect(Array.from(resample(x, 44100, 22050))).toEqual(Array.from(reference(x, 44100, 22050))); - const big = noise(44100 * 60); - const t0 = performance.now(); - resample(big, 44100, 22050); - expect(performance.now() - t0).toBeLessThan(500); // one minute of audio: ~50 ms cached vs ~1.7 s direct + const big = noise(44100 * 10); + const time = (f: () => unknown) => { const t0 = performance.now(); f(); return performance.now() - t0; }; + time(() => resample(big, 44100, 22050)); // warm-up (JIT) + const fast = time(() => resample(big, 44100, 22050)), slow = time(() => reference(big, 44100, 22050)); + // measured ~40x; a 4x margin keeps this robust on a loaded machine + expect(fast * 4).toBeLessThan(slow); }); test("non-integer ratios are unchanged", () => {