diff --git a/src/align/warp.ts b/src/align/warp.ts new file mode 100644 index 0000000..5656eac --- /dev/null +++ b/src/align/warp.ts @@ -0,0 +1,107 @@ +// Anchor model for MIDI-time -> recording-time alignment. Pure: no I/O, no Bun/process/fs. + +export type AnchorKind = "manual" | "auto"; +export interface Anchor { midiSec: number; audioSec: number; kind: AnchorKind } +export interface Warp { toAudio(midiSec: number): number; toMidi(audioSec: number): number; anchors: Anchor[] } +// ratio = audio duration / midi duration of the stretch; warn when the tempo change exceeds 5%. +export interface Stretch { fromMidi: number; toMidi: number; ratio: number; warn: boolean } + +const WARN_TOL = 0.05; + +function sortedCopy(anchors: Anchor[]): Anchor[] { + const s = anchors.map((a) => ({ midiSec: a.midiSec, audioSec: a.audioSec, kind: a.kind })).sort((x, y) => x.midiSec - y.midiSec); + for (const a of s) { + if (!Number.isFinite(a.midiSec) || !Number.isFinite(a.audioSec)) throw new Error("anchors: times must be finite"); + } + for (let i = 1; i < s.length; i++) { + const p = s[i - 1]!, c = s[i]!; + if (!(c.midiSec > p.midiSec)) throw new Error(`anchors: midiSec must be strictly increasing (${p.midiSec}, ${c.midiSec})`); + if (!(c.audioSec > p.audioSec)) throw new Error(`anchors: audioSec must be strictly increasing (${p.audioSec}, ${c.audioSec})`); + } + return s; +} + +const ratioOf = (a: Anchor, b: Anchor) => (b.audioSec - a.audioSec) / (b.midiSec - a.midiSec); + +// Slopes used beyond the outermost anchors: the nearest interior stretch, or 1 with fewer than two anchors. +function edgeRatios(A: Anchor[]): { first: number; last: number } { + if (A.length < 2) return { first: 1, last: 1 }; + return { first: ratioOf(A[0]!, A[1]!), last: ratioOf(A[A.length - 2]!, A[A.length - 1]!) }; +} + +// Piecewise-linear map over the sorted keys X -> Y; extrapolates with `first` / `last` slopes. +function piecewise(X: number[], Y: number[], x: number, first: number, last: number): number { + const n = X.length; + if (n === 0) return x; + if (x <= X[0]!) return Y[0]! + (x - X[0]!) * first; + if (x >= X[n - 1]!) return Y[n - 1]! + (x - X[n - 1]!) * last; + let lo = 0, hi = n - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (X[mid]! <= x) lo = mid; else hi = mid; + } + return Y[lo]! + ((x - X[lo]!) * (Y[hi]! - Y[lo]!)) / (X[hi]! - X[lo]!); +} + +export function makeWarp(anchors: Anchor[]): Warp { + const A = sortedCopy(anchors); + const midi = A.map((a) => a.midiSec), audio = A.map((a) => a.audioSec); + const { first, last } = edgeRatios(A); + return { + toAudio: (x) => piecewise(midi, audio, x, first, last), + toMidi: (y) => piecewise(audio, midi, y, 1 / first, 1 / last), + anchors: A.map((a) => ({ ...a })), + }; +} + +export function stretches(anchors: Anchor[], midiDur: number): Stretch[] { + const A = sortedCopy(anchors); + const mk = (fromMidi: number, toMidi: number, ratio: number): Stretch => ({ + fromMidi, toMidi, ratio, warn: Math.abs(1 / ratio - 1) > WARN_TOL, + }); + if (A.length === 0) return [mk(0, midiDur, 1)]; + const { first, last } = edgeRatios(A); + const segs: Stretch[] = [mk(0, A[0]!.midiSec, first)]; + for (let k = 0; k + 1 < A.length; k++) { + segs.push(mk(A[k]!.midiSec, A[k + 1]!.midiSec, ratioOf(A[k]!, A[k + 1]!))); + } + segs.push(mk(A[A.length - 1]!.midiSec, midiDur, last)); + return segs.filter((s) => s.toMidi > s.fromMidi); +} + +// Throws unless `a` fits strictly between the given neighbours in both midi and audio time. +function checkSlot(left: Anchor | undefined, right: Anchor | undefined, a: Omit): void { + if (left && !(a.midiSec > left.midiSec && a.audioSec > left.audioSec)) throw new Error("anchor would cross its left neighbour"); + 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, then drops the immediate +// neighbours of that anchor when they are auto. Never reaches past a manual anchor. +function insertManual(list: Anchor[], at: number, a: Omit): Anchor[] { + checkSlot(list[at - 1], list[at], a); + const out = [...list.slice(0, at), { midiSec: a.midiSec, audioSec: a.audioSec, kind: "manual" as const }, ...list.slice(at)]; + const p = at; + const drop = new Set(); + if (p - 1 >= 0 && out[p - 1]!.kind === "auto") drop.add(p - 1); + if (p + 1 < out.length && out[p + 1]!.kind === "auto") drop.add(p + 1); + return sortedCopy(out.filter((_, i) => !drop.has(i))); +} + +export function addManual(anchors: Anchor[], a: Omit): Anchor[] { + const A = sortedCopy(anchors); + const idx = A.findIndex((x) => x.midiSec > a.midiSec); + return insertManual(A, idx < 0 ? A.length : idx, a); +} + +export function moveAnchor(anchors: Anchor[], i: number, to: Omit): Anchor[] { + 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); +} + +export function deleteAnchor(anchors: Anchor[], i: number): Anchor[] { + const A = sortedCopy(anchors); + if (!Number.isInteger(i) || i < 0 || i >= A.length) throw new RangeError(`deleteAnchor: index ${i} out of range`); + return A.filter((_, k) => k !== i); +} diff --git a/tests/align/warp.test.ts b/tests/align/warp.test.ts new file mode 100644 index 0000000..725ab95 --- /dev/null +++ b/tests/align/warp.test.ts @@ -0,0 +1,179 @@ +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 only the auto immediate neighbours, keeps manual ones and farther autos", () => { + // M0 | A2 | A4 | M8 ; insert manual at 6 -> A4 (immediate left, auto) goes; A2 stays (not adjacent); M8 stays + 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), + A(2, 2), + M(6, 6.2), + M(8, 8), + ]); + }); + + 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("throws when moved past an auto neighbour", () => { + const base = [M(0, 0), A(4, 4), M(8, 8)]; + expect(() => moveAnchor(base, 2, { midiSec: 3, audioSec: 3 })).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(); + }); +});