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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
894fd9b9ef
commit
f0249995cf
+14
-11
@@ -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<HTMLButtonElement>(".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<ArrayBuffer>(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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<li><b>Click</b> the lanes to set the cursor (black line); click an anchor to select it. <b>Wheel</b> zooms around the mouse (Shift+wheel or a sideways swipe scrolls); <b>drag the background</b> to scroll.</li>
|
||||
<li>Drag an anchor in the <b>Recording</b> lane to move it along the recording (the MIDI moment stays attached to it).
|
||||
Drag it in the <b>MIDI audio</b> 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.</li>
|
||||
<li>Moving or adding an anchor removes the automatic anchors between your neighbouring anchors; <b>Auto-align around my anchors</b> fills them in again.</li>
|
||||
<li>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; <b>Auto-align around my anchors</b> fills them in again.</li>
|
||||
<li>Keys: <kbd>Space</kbd> play/stop · <kbd>A</kbd> anchor at cursor · <kbd>Delete</kbd> delete selected · <kbd>←</kbd>/<kbd>→</kbd> nudge selected anchor 10 ms (<kbd>Shift</kbd> 100 ms; nothing selected: move cursor 1 s) ·
|
||||
<kbd>1</kbd>–<kbd>4</kbd> mix · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo / <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> redo · <kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>0</kbd> show whole song.</li>
|
||||
</ul>
|
||||
|
||||
+38
-13
@@ -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<V> {
|
||||
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<AlignServer>
|
||||
return r;
|
||||
}
|
||||
|
||||
async function api(req: Request, url: URL): Promise<Response> {
|
||||
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<Response> {
|
||||
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<AlignServer>
|
||||
|
||||
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<AlignServer>
|
||||
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<Response> {
|
||||
async function proxyPage(req: Request, path: string): Promise<Response> {
|
||||
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<AlignServer>
|
||||
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);
|
||||
|
||||
+20
-7
@@ -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, "kind">): 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<Anchor, "kind">, 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, "kind">): Anchor[] {
|
||||
@@ -98,7 +111,7 @@ export function moveAnchor(anchors: Anchor[], i: number, to: Omit<Anchor, "kind"
|
||||
const A = sortedCopy(anchors);
|
||||
if (!Number.isInteger(i) || i < 0 || i >= 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[] {
|
||||
|
||||
+11
-3
@@ -89,15 +89,23 @@ export async function audioOgg(e: SongEntry, root = "reference"): Promise<string
|
||||
return cached(join(dir, "audio.ogg"), e.audio, (tmp) => 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<AlignmentFile | null> {
|
||||
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<void> {
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user