@@ -51,12 +74,18 @@
the label under the lanes shows MIDI bpm → resulting bpm.
⚠ = a stretch changed by more than 5%; that usually means an anchor sits on the wrong bar.
Thick lines are your (manual) anchors, thin ones were placed automatically. Red-shaded 30 s windows fit poorly.
- - Click the lanes to set the cursor (black line); click an anchor to select it. Wheel zooms around the mouse (Shift+wheel or a sideways swipe scrolls); drag the background to scroll.
- - Drag an anchor in the Recording lane to move it along the recording (the MIDI moment stays attached to it).
- Drag it in the MIDI audio lane to slide the MIDI under it: the bit of MIDI you grabbed follows the mouse while the anchor stays at the same place in the recording.
- - Moving or adding an anchor removes the automatic anchors within 10 s of it (never past your other anchors), plus any that would now be out of order; Auto-align around my anchors fills them in again.
- - Keys: Space play/stop · A anchor at cursor · Delete delete selected · ←/→ nudge selected anchor 10 ms (Shift 100 ms; nothing selected: move cursor 1 s) ·
- 1–4 mix · Ctrl+Z undo / Ctrl+Shift+Z redo · Ctrl+S save · 0 show whole song.
+ - Pairing (the main tool). Click a note in the Recording lane: a dashed blue recording mark appears. Click the same note in the MIDI lane: a dashed orange MIDI mark.
+ Placing marks changes nothing else. Fine-tune them by clicking again, typing a time in the Pair box (
0:23.2 or 23.2), or pressing R / M to drop a mark at the playhead while listening.
+ The ▶ next to each mark plays just that track from the mark. Link (Enter) makes them one anchor: that MIDI moment now plays at the recording mark. Esc clears the marks.
+ - Playback cursor: click or drag in the time ruler along the top (black triangle). Space plays from it; ←/→ moves it 1 s when no anchor is selected.
+ - Moving anchors: grab an anchor by its handle (the small green block at the top of the Recording lane or the bottom of the MIDI lane). Top handle: move the anchor along the recording.
+ Bottom handle: slide the MIDI under it (the bit of MIDI you grabbed follows the mouse). Clicking a handle selects the anchor (Delete removes it).
+ - Wheel zooms around the mouse (Shift+wheel or a sideways swipe scrolls); drag the lane background to scroll.
+ - Adding or moving an anchor removes the automatic anchors within 10 s of it (never past your other anchors), plus any that would now be out of order; Auto-align around my anchors fills them in again.
+ Your anchors must stay in the same order in both lanes; Link tells you if a pair would cross one of them.
+ - Volume: the Recording and MIDI sliders (0–150 %) work on top of the mix choice, also while playing, and are remembered in this browser.
+ - Keys: Space play/stop · R / M recording / MIDI mark at the playhead · Enter link · Esc clear marks / selection · A anchor at cursor · Delete delete selected ·
+ ←/→ nudge selected anchor 10 ms (Shift 100 ms) · 1–4 mix · Ctrl+Z undo / Ctrl+Shift+Z redo · Ctrl+S save · 0 show whole song.
diff --git a/src/align/page/style.css b/src/align/page/style.css
index 06ff113..4c5f2a4 100644
--- a/src/align/page/style.css
+++ b/src/align/page/style.css
@@ -40,3 +40,23 @@ button:focus-visible { outline: 2px solid var(--rec); outline-offset: 1px; }
.help summary { cursor: pointer; color: var(--muted); }
.help li { margin: 4px 0; }
kbd { font: 11px ui-monospace, monospace; border: 1px solid #ccc; border-bottom-width: 2px; border-radius: 3px; padding: 0 4px; background: #fff; }
+.pair { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-left: 4px solid #7a3cff; border-radius: 6px; }
+.pairtitle { font-weight: 600; font-size: 13px; margin-right: 2px; }
+.pair .mark { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; }
+.pair .mark.rec label { color: #1f5fd6; font-weight: 600; }
+.pair .mark.midi label { color: #c2410c; font-weight: 600; }
+.pair input.time { font: 13px ui-monospace, monospace; width: 10ch; padding: 3px 5px; border: 1px dashed #aaa; border-radius: 4px; }
+.pair .mark.rec input.time.set { border-color: #1f5fd6; border-style: solid; }
+.pair .mark.midi input.time.set { border-color: #c2410c; border-style: solid; }
+.pair input.time.invalid { border-color: var(--warn); background: #fff1f0; }
+.pair button.aud { padding: 3px 7px; }
+.pairarrow { color: var(--muted); }
+button.link { background: #7a3cff; border-color: #6a2ee6; color: #fff; font-weight: 600; padding: 5px 16px; }
+button.link:hover:not(:disabled) { background: #6a2ee6; }
+button.quiet2 { color: #555; }
+.pairhint { font-size: 12px; color: var(--muted); flex: 1 1 260px; }
+.vol { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #444; margin-left: 6px; }
+.vol input[type="range"] { width: 110px; }
+.vol output { font: 12px ui-monospace, monospace; width: 4.5ch; color: var(--muted); }
+.vol.rec { color: #1f5fd6; } .vol.midi { color: #c2410c; }
+code { font: 12px ui-monospace, monospace; background: #f1f1f1; padding: 0 3px; border-radius: 3px; }
diff --git a/src/align/page/timeline.ts b/src/align/page/timeline.ts
index bae98ff..d6068a0 100644
--- a/src/align/page/timeline.ts
+++ b/src/align/page/timeline.ts
@@ -119,3 +119,39 @@ export function columnPeaks(levels: PeakLevel[], sampleRate: number, v: View): {
}
return { min, max };
}
+
+/**
+ * Parses a typed time: "m:ss.s" ("1:23.4", "0:05"), "h:mm:ss.s", or plain seconds ("83.4", "83,4"). A comma is
+ * accepted as the decimal separator. Returns null for anything else or a negative time.
+ */
+export function parseTime(text: string): number | null {
+ const s = text.trim().replace(",", ".");
+ if (!/^\d+(:\d{1,2}){0,2}(\.\d*)?$|^\.\d+$/.test(s)) return null;
+ const parts = s.split(":").map(Number);
+ if (parts.slice(1).some((p) => p >= 60)) return null;
+ const v = parts.reduce((acc, p) => acc * 60 + p, 0);
+ return Number.isFinite(v) ? v : null;
+}
+
+/**
+ * Like `buildPyramid` but over several channels at once (min/max across all of them) and cooperative: after every
+ * `sliceSamples` samples it awaits `pause()` (e.g. a zero timeout) so a long recording never blocks the page.
+ */
+export async function buildPyramidSliced(
+ channels: Float32Array[], base: number, pause: () => Promise
, sliceSamples = 1 << 20,
+): Promise {
+ const len = channels[0]?.length ?? 0;
+ const n = Math.max(1, Math.ceil(len / base));
+ const min = new Float32Array(n), max = new Float32Array(n);
+ const perSlice = Math.max(1, Math.floor(sliceSamples / base / Math.max(1, channels.length)));
+ for (let b0 = 0; b0 < n; b0 += perSlice) {
+ for (let b = b0, bEnd = Math.min(n, b0 + perSlice); b < bEnd; b++) {
+ let lo = Infinity, hi = -Infinity;
+ const i0 = b * base, i1 = Math.min(len, i0 + base);
+ for (const x of channels) for (let i = i0; i < i1; i++) { const s = x[i]!; if (s < lo) lo = s; if (s > hi) hi = s; }
+ min[b] = lo === Infinity ? 0 : lo; max[b] = hi === -Infinity ? 0 : hi;
+ }
+ if (b0 + perSlice < n) await pause();
+ }
+ return levelsFrom({ bucket: base, min, max });
+}
diff --git a/tests/align/page/pair.test.ts b/tests/align/page/pair.test.ts
new file mode 100644
index 0000000..f45c630
--- /dev/null
+++ b/tests/align/page/pair.test.ts
@@ -0,0 +1,88 @@
+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)));
+});