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]>
89 lines
4.6 KiB
TypeScript
89 lines
4.6 KiB
TypeScript
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)));
|
||
});
|