feat(align): warp model
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
85bd6a2e29
commit
1ff29f94cd
@@ -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<Anchor, "kind">): 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, "kind">): 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<number>();
|
||||
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, "kind">): 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, "kind">): 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);
|
||||
}
|
||||
Reference in New Issue
Block a user