- 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]>
233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import { makeWarp, stretches, addManual, moveAnchor, deleteAnchor, type Anchor } from "../../src/align/warp.ts";
|
|
|
|
const M = (midiSec: number, audioSec: number): Anchor => ({ midiSec, audioSec, kind: "manual" });
|
|
const A = (midiSec: number, audioSec: number): Anchor => ({ midiSec, audioSec, kind: "auto" });
|
|
const close = (a: number, b: number, eps = 1e-9) => expect(Math.abs(a - b)).toBeLessThan(eps);
|
|
|
|
describe("makeWarp", () => {
|
|
test("zero anchors is the identity", () => {
|
|
const w = makeWarp([]);
|
|
expect(w.anchors).toEqual([]);
|
|
for (const x of [-2, 0, 3.5, 100]) {
|
|
expect(w.toAudio(x)).toBe(x);
|
|
expect(w.toMidi(x)).toBe(x);
|
|
}
|
|
});
|
|
|
|
test("single anchor is a pure offset", () => {
|
|
const w = makeWarp([M(1, 1.5)]);
|
|
expect(w.toAudio(1)).toBe(1.5);
|
|
expect(w.toAudio(3)).toBe(3.5);
|
|
expect(w.toAudio(-1)).toBe(-0.5);
|
|
expect(w.toMidi(1.5)).toBe(1);
|
|
expect(w.toMidi(0)).toBe(-0.5);
|
|
});
|
|
|
|
test("two anchors interpolate linearly and extrapolate with the same slope", () => {
|
|
const w = makeWarp([M(1, 1), M(3, 5)]); // slope 2
|
|
expect(w.toAudio(2)).toBe(3);
|
|
expect(w.toAudio(0)).toBe(-1);
|
|
expect(w.toAudio(5)).toBe(9);
|
|
expect(w.toMidi(3)).toBe(2);
|
|
});
|
|
|
|
test("three anchors: outer segments use the nearest interior slope", () => {
|
|
const w = makeWarp([M(1, 1), M(3, 5), M(5, 6)]); // slopes 2, then 0.5
|
|
expect(w.toAudio(4)).toBe(5.5); // interior
|
|
expect(w.toAudio(0)).toBe(-1); // before first: slope 2
|
|
expect(w.toAudio(7)).toBe(7); // after last: slope 0.5 -> 6 + 2*0.5
|
|
});
|
|
|
|
test("toMidi(toAudio(x)) round-trips", () => {
|
|
const w = makeWarp([M(0.5, 0.2), A(2, 2.9), M(4, 4.1), A(9, 8.0)]);
|
|
for (let x = -3; x <= 12; x += 0.37) close(w.toMidi(w.toAudio(x)), x);
|
|
for (let y = -3; y <= 12; y += 0.41) close(w.toAudio(w.toMidi(y)), y);
|
|
});
|
|
|
|
test("sorts anchors by midiSec and keeps them as copies", () => {
|
|
const input = [M(3, 5), M(1, 1)];
|
|
const w = makeWarp(input);
|
|
expect(w.anchors.map((a) => a.midiSec)).toEqual([1, 3]);
|
|
expect(w.toAudio(2)).toBe(3);
|
|
input[0]!.audioSec = 999;
|
|
expect(w.toAudio(3)).toBe(5);
|
|
});
|
|
|
|
test("throws when audioSec is not strictly increasing with midiSec", () => {
|
|
expect(() => makeWarp([M(1, 2), M(3, 1)])).toThrow();
|
|
expect(() => makeWarp([M(1, 2), M(3, 2)])).toThrow();
|
|
});
|
|
|
|
test("throws on duplicate midiSec", () => {
|
|
expect(() => makeWarp([M(1, 1), M(1, 2)])).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("stretches", () => {
|
|
test("no anchors: one stretch with ratio 1", () => {
|
|
expect(stretches([], 10)).toEqual([{ fromMidi: 0, toMidi: 10, ratio: 1, warn: false }]);
|
|
});
|
|
|
|
test("single anchor: two ratio-1 edge stretches split at the anchor", () => {
|
|
expect(stretches([M(4, 4.2)], 10)).toEqual([
|
|
{ fromMidi: 0, toMidi: 4, ratio: 1, warn: false },
|
|
{ fromMidi: 4, toMidi: 10, ratio: 1, warn: false },
|
|
]);
|
|
});
|
|
|
|
test("interior stretches get their ratio; edges take the nearest interior ratio", () => {
|
|
const s = stretches([M(1, 1), M(3, 5), M(5, 6)], 7);
|
|
expect(s.map((x) => [x.fromMidi, x.toMidi])).toEqual([
|
|
[0, 1],
|
|
[1, 3],
|
|
[3, 5],
|
|
[5, 7],
|
|
]);
|
|
expect(s.map((x) => x.ratio)).toEqual([2, 2, 0.5, 0.5]);
|
|
expect(s.map((x) => x.warn)).toEqual([true, true, true, true]);
|
|
});
|
|
|
|
test("warn when tempo changes by more than 5%", () => {
|
|
const hot = stretches([M(0, 0), M(10, 10.7)], 10); // ratio 1.07 -> 1/1.07-1 = -6.5%
|
|
expect(hot.length).toBe(1);
|
|
close(hot[0]!.ratio, 1.07);
|
|
expect(hot[0]!.warn).toBe(true);
|
|
const mild = stretches([M(0, 0), M(10, 10.4)], 10); // ratio 1.04 -> -3.8%
|
|
expect(mild[0]!.warn).toBe(false);
|
|
});
|
|
|
|
test("drops zero-length edge stretches when an anchor sits at 0 or at the end", () => {
|
|
const s = stretches([M(0, 0.5), M(10, 10.5)], 10);
|
|
expect(s).toEqual([{ fromMidi: 0, toMidi: 10, ratio: 1, warn: false }]);
|
|
});
|
|
});
|
|
|
|
describe("addManual", () => {
|
|
test("adds a manual anchor into an empty list", () => {
|
|
expect(addManual([], { midiSec: 2, audioSec: 2.5 })).toEqual([M(2, 2.5)]);
|
|
});
|
|
|
|
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)]);
|
|
});
|
|
|
|
test("autos every 2 s: adding a manual at 20 s removes autos between manuals at 10 s and 30 s only", () => {
|
|
const autos = [2, 4, 6, 8, 12, 14, 16, 18, 22, 24, 26, 28].map((t) => A(t, t));
|
|
const base = [...autos.slice(0, 4), M(10, 10), M(30, 30), ...autos.slice(4)].sort((x, y) => x.midiSec - y.midiSec);
|
|
// autos 12..28 sit between the manuals at 10 and 30 and go; 2..8 (before 10) are kept
|
|
expect(addManual(base, { midiSec: 20, audioSec: 20 })).toEqual([
|
|
A(2, 2), A(4, 4), A(6, 6), A(8, 8), M(10, 10), M(20, 20), M(30, 30),
|
|
]);
|
|
});
|
|
|
|
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)];
|
|
// 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", () => {
|
|
const base = [A(2, 2), A(4, 4), A(6, 6)];
|
|
expect(addManual(base, { midiSec: 5, audioSec: 5 })).toEqual([M(5, 5)]);
|
|
});
|
|
|
|
test("keeps manual neighbours untouched", () => {
|
|
const base = [M(0, 0), M(8, 8)];
|
|
expect(addManual(base, { midiSec: 4, audioSec: 4 })).toEqual([M(0, 0), M(4, 4), M(8, 8)]);
|
|
});
|
|
|
|
test("removes auto neighbours on both sides", () => {
|
|
const base = [M(0, 0), A(2, 2), A(6, 6.5), M(10, 10)];
|
|
expect(addManual(base, { midiSec: 4, audioSec: 4.1 })).toEqual([M(0, 0), M(4, 4.1), M(10, 10)]);
|
|
});
|
|
|
|
test("throws if the new anchor would cross a neighbour's audio time", () => {
|
|
const base = [M(0, 0), M(8, 8)];
|
|
expect(() => addManual(base, { midiSec: 4, audioSec: 9 })).toThrow();
|
|
expect(() => addManual(base, { midiSec: 4, audioSec: -1 })).toThrow();
|
|
});
|
|
|
|
test("throws on an existing midi position", () => {
|
|
expect(() => addManual([M(4, 4)], { midiSec: 4, audioSec: 4.5 })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("moveAnchor", () => {
|
|
test("moves an anchor, makes it manual and removes adjacent autos", () => {
|
|
const base = [M(0, 0), A(3, 3), M(6, 6), A(9, 9)];
|
|
// move M(6) to midi 5 audio 5.5; immediate auto neighbours: A(3) left (removed), A(9) right (removed)
|
|
expect(moveAnchor(base, 2, { midiSec: 5, audioSec: 5.5 })).toEqual([M(0, 0), M(5, 5.5)]);
|
|
});
|
|
|
|
test("turns an auto anchor into a manual one", () => {
|
|
const base = [M(0, 0), A(4, 4.5), M(8, 8)];
|
|
expect(moveAnchor(base, 1, { midiSec: 4.2, audioSec: 4.6 })).toEqual([M(0, 0), M(4.2, 4.6), M(8, 8)]);
|
|
});
|
|
|
|
test("throws when moved past a manual neighbour", () => {
|
|
const base = [M(0, 0), M(4, 4), M(8, 8)];
|
|
expect(() => moveAnchor(base, 1, { midiSec: 9, audioSec: 4 })).toThrow();
|
|
expect(() => moveAnchor(base, 1, { midiSec: 4, audioSec: 8.5 })).toThrow();
|
|
});
|
|
|
|
test("moving past an auto neighbour is allowed; the crossed auto is removed", () => {
|
|
const base = [M(0, 0), A(4, 4), M(8, 8)];
|
|
expect(moveAnchor(base, 2, { midiSec: 3, audioSec: 3 })).toEqual([M(0, 0), M(3, 3)]);
|
|
});
|
|
|
|
test("crossing a manual neighbour in audio time throws even with an auto between", () => {
|
|
const base = [M(0, 0), A(4, 4), M(8, 8), A(12, 12), M(16, 16)];
|
|
// Moving M(8) to audio 17 puts it past its right manual neighbour M(16) (audio 16); autos between are ignored.
|
|
expect(() => moveAnchor(base, 2, { midiSec: 10, audioSec: 17 })).toThrow();
|
|
});
|
|
|
|
test("throws on a bad index", () => {
|
|
expect(() => moveAnchor([M(1, 1)], 3, { midiSec: 2, audioSec: 2 })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("deleteAnchor", () => {
|
|
test("removes the anchor at index", () => {
|
|
expect(deleteAnchor([M(1, 1), A(2, 2), M(3, 3)], 1)).toEqual([M(1, 1), M(3, 3)]);
|
|
});
|
|
|
|
test("throws on a bad index", () => {
|
|
expect(() => deleteAnchor([M(1, 1)], 1)).toThrow();
|
|
});
|
|
});
|