Files
STSheets/tests/align/page/pair.test.ts
T
Lucas WintherandClaude Opus 5.5 1a922e2508 feat(align-page): pair marks + Link, per-track volume, no main-thread stalls
Clicking the Recording / MIDI lane places a dashed mark (nothing else changes); the Pair box
shows both as editable times (m:ss.s or seconds), R / M drop a mark at the playhead, the ▶
buttons audition each track from its mark, Link (Enter) turns them into one manual anchor
(undoable; order conflicts with your anchors are explained and the marks kept), Esc clears.
The ruler sets the playback cursor; anchors are dragged by their handles. Recording and MIDI
volume sliders (0-150 %, Web Audio gain, remembered). Waveform pyramids are built in slices,
previews cached by anchors client-side, fit not re-requested when unchanged, debounce 200 ms.
Help panel updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:04:46 +01:00

89 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, describe } from "bun:test";
import { NO_MARKS, placeMark, midiMarkAt, markLaneSec, linkMarks, linkConflict, parseVolumes } from "../../../src/align/page/edit.ts";
import { makeWarp, type Anchor } from "../../../src/align/warp.ts";
import { parseTime, formatTime, buildPyramid, buildPyramidSliced } from "../../../src/align/page/timeline.ts";
const m = (midiSec: number, audioSec: number): Anchor => ({ midiSec, audioSec, kind: "manual" });
const a = (midiSec: number, audioSec: number): Anchor => ({ midiSec, audioSec, kind: "auto" });
const base = [m(10, 11), m(20, 22), m(30, 32)];
describe("pair marks", () => {
test("placing marks only changes the marks (anchors untouched)", () => {
const before = JSON.stringify(base);
let s = placeMark(NO_MARKS, "rec", 25);
s = placeMark(s, "midi", midiMarkAt(base, 26));
expect(s).toEqual({ rec: 25, midi: makeWarp(base).toMidi(26), active: "midi" });
expect(JSON.stringify(base)).toBe(before);
expect(placeMark(s, "rec", 27)).toEqual({ ...s, rec: 27, active: "rec" });
});
test("markLaneSec: rec mark is recording time, MIDI mark is drawn where the warp puts it", () => {
const s = placeMark(placeMark(NO_MARKS, "rec", 25), "midi", midiMarkAt(base, 26));
expect(markLaneSec(base, s, "rec")).toBe(25);
expect(markLaneSec(base, s, "midi")).toBeCloseTo(26, 9);
expect(markLaneSec(base, NO_MARKS, "midi")).toBeNull();
});
});
describe("linkMarks", () => {
test("creates a manual anchor {audioSec: rec mark, midiSec: warp.toMidi(MIDI-lane mark)}", () => {
const midi = midiMarkAt(base, 26);
const r = linkMarks(base, { rec: 25, midi, active: "midi" });
expect(r.anchors[r.index]).toEqual(m(makeWarp(base).toMidi(26), 25));
expect(r.anchors).toHaveLength(4);
expect(makeWarp(r.anchors).toAudio(midi)).toBeCloseTo(25, 9); // that MIDI moment now plays at the recording mark
});
test("needs both marks", () => {
expect(() => linkMarks(base, placeMark(NO_MARKS, "rec", 25))).toThrow(/both/);
});
test("refuses to cross a manual neighbour, with a clear message", () => {
// MIDI mark between anchors 20 and 30, recording mark before the anchor at 22 s
const s = { rec: 21, midi: 25, active: "rec" as const };
expect(linkConflict(base, 25, 21)).toMatch(/same order/);
expect(() => linkMarks(base, s)).toThrow(/Can't link/);
expect(linkConflict(base, 15, 33)).toMatch(/MIDI mark \(0:16\.50\) is before your anchor at 0:22\.00/);
expect(linkConflict(base, 20, 21.5)).toMatch(/already uses that MIDI moment/);
expect(linkConflict(base, 25, 27)).toBeNull();
});
test("automatic anchors never block a link (they are dropped)", () => {
const list = [m(0, 0), a(5, 5.2), a(10, 10.3), m(40, 40)];
expect(linkConflict(list, 10, 4)).toBeNull();
const r = linkMarks(list, { rec: 4, midi: 10, active: "rec" });
expect(r.anchors).toEqual([m(0, 0), m(10, 4), m(40, 40)]);
});
});
test("parseVolumes clamps to 0–150 and defaults to 100", () => {
expect(parseVolumes(null)).toEqual({ rec: 100, midi: 100 });
expect(parseVolumes("garbage")).toEqual({ rec: 100, midi: 100 });
expect(parseVolumes(JSON.stringify({ rec: 0, midi: 999 }))).toEqual({ rec: 0, midi: 150 });
expect(parseVolumes(JSON.stringify({ rec: 42.4, midi: "x" }))).toEqual({ rec: 42, midi: 100 });
});
describe("parseTime", () => {
test("m:ss.s, h:mm:ss, plain seconds, comma decimals", () => {
expect(parseTime("0:23.2")).toBeCloseTo(23.2, 9);
expect(parseTime(" 1:05 ")).toBe(65);
expect(parseTime("1:02:03.5")).toBeCloseTo(3723.5, 9);
expect(parseTime("24")).toBe(24);
expect(parseTime("24,5")).toBe(24.5);
expect(parseTime(".5")).toBe(0.5);
expect(parseTime(formatTime(83.25, 2))).toBeCloseTo(83.25, 9);
});
test("rejects junk, negatives and seconds >= 60 in m:ss", () => {
for (const s of ["", "abc", "-3", "1:60", "1::2", "1:2:3:4", "0:23.2.1", "12s"]) expect(parseTime(s)).toBeNull();
});
});
test("buildPyramidSliced matches buildPyramid and takes the min/max across channels", async () => {
const x = Float32Array.from({ length: 10_000 }, (_, i) => Math.sin(i / 13) * (i % 7) / 7);
let pauses = 0;
const a = await buildPyramidSliced([x], 64, async () => { pauses++; }, 1000);
const b = buildPyramid(x, 64);
expect(pauses).toBeGreaterThan(5);
expect(a.length).toBe(b.length);
a.forEach((l, k) => { expect(l.bucket).toBe(b[k]!.bucket); expect([...l.min]).toEqual([...b[k]!.min]); expect([...l.max]).toEqual([...b[k]!.max]); });
const y = x.map((v) => -v * 2);
const c = await buildPyramidSliced([x, y], 64, async () => {});
expect(c[0]!.max[3]).toBe(Math.max(...x.subarray(192, 256), ...y.subarray(192, 256)));
});