From ca5942956a2ad3595bcdd40f0487b1c63d206100 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 06:30:48 +0100 Subject: [PATCH] fix(align): remove whole auto run between manual anchors on edit Co-Authored-By: Claude Haiku 5.5 --- src/align/warp.ts | 19 ++++++++++--------- tests/align/warp.test.ts | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/align/warp.ts b/src/align/warp.ts index 5656eac..06272bf 100644 --- a/src/align/warp.ts +++ b/src/align/warp.ts @@ -75,16 +75,17 @@ 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, then drops the immediate -// neighbours of that anchor when they are auto. Never reaches past a manual anchor. +// 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[] { - 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))); + 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); } export function addManual(anchors: Anchor[], a: Omit): Anchor[] { diff --git a/tests/align/warp.test.ts b/tests/align/warp.test.ts index 725ab95..fb9322b 100644 --- a/tests/align/warp.test.ts +++ b/tests/align/warp.test.ts @@ -108,17 +108,32 @@ describe("addManual", () => { 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 + 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) 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), + 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)]; + expect(moveAnchor(base, 3, { midiSec: 45, audioSec: 45 })).toEqual([M(0, 0), M(45, 45), M(60, 60)]); + }); + + 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)]); @@ -158,9 +173,15 @@ describe("moveAnchor", () => { expect(() => moveAnchor(base, 1, { midiSec: 4, audioSec: 8.5 })).toThrow(); }); - test("throws when moved past an auto neighbour", () => { + 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 })).toThrow(); + 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", () => {