From 1a922e2508eb4cebe998d4df03b5e2d6c5a5ae14 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 22:04:46 +0100 Subject: [PATCH] feat(align-page): pair marks + Link, per-track volume, no main-thread stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/align/page/app.ts | 319 +++++++++++++++++++++++++++------- src/align/page/edit.ts | 72 ++++++++ src/align/page/index.html | 41 ++++- src/align/page/style.css | 20 +++ src/align/page/timeline.ts | 36 ++++ tests/align/page/pair.test.ts | 88 ++++++++++ 6 files changed, 507 insertions(+), 69 deletions(-) create mode 100644 tests/align/page/pair.test.ts diff --git a/src/align/page/app.ts b/src/align/page/app.ts index 6b73166..088abdc 100644 --- a/src/align/page/app.ts +++ b/src/align/page/app.ts @@ -1,23 +1,34 @@ /// /// // Alignment page (layout A): two lanes (recording, warped MIDI audio) on one zoomable timeline, anchors spanning both, -// stretch tempo labels, fit-flagged windows, Web Audio playback with a mix choice. Geometry/edit logic is in the pure -// modules ./timeline.ts and ./edit.ts; warp maths come from ../warp.ts so every edit redraws instantly. +// stretch tempo labels, fit-flagged windows, Web Audio playback with a mix choice and per-track volume. The main tool is +// pairing: click the recording lane and the MIDI lane to place two marks (nothing else changes), then Link them into one +// manual anchor. The ruler sets the playback cursor; anchors are dragged by their handles. Geometry/edit logic is in the +// pure modules ./timeline.ts and ./edit.ts; warp maths come from ../warp.ts so every edit redraws instantly. import { AUTO_CLEAR_SEC, deleteAnchor, makeWarp, stretches, type Anchor, type Warp } from "../warp.ts"; import { stretchInfo, stretchLabel, type StretchInfo } from "../tempo.ts"; import { fitSummary, WINDOW_SEC, type FitReport } from "../fit.ts"; import type { TempoPoint } from "../smf.ts"; import { - buildPyramid, clampView, columnPeaks, fitView, follow, formatTime, pyramidFromPeaks, rulerStep, scrollBy, timeToX, xToTime, zoomAt, + buildPyramidSliced, clampView, columnPeaks, fitView, follow, formatTime, parseTime, pyramidFromPeaks, rulerStep, scrollBy, timeToX, xToTime, zoomAt, type PeakLevel, type View, } from "./timeline.ts"; -import { anchorAtCursor, dragAudio, dragMidi, hitAnchor, type Edit } from "./edit.ts"; +import { + anchorAtCursor, dragAudio, dragMidi, hitAnchor, linkMarks, NO_MARKS, parseVolumes, placeMark, + type Edit, type PairMarks, type PairSide, type Volumes, +} from "./edit.ts"; // ---------- layout (CSS px) ---------- const GUTTER = 92, RULER_H = 22, LABEL_H = 18, LANE_H = 118, LANE_GAP = 8, STRETCH_H = 26; const REC_Y = RULER_H + LABEL_H, MIDI_Y = REC_Y + LANE_H + LANE_GAP, STRETCH_Y = MIDI_Y + LANE_H, HEIGHT = STRETCH_Y + STRETCH_H; -const C = { rec: "#5b8def", midi: "#e2873b", anchor: "#2bb673", anchorAuto: "#86cfa9", warn: "#d9534f", ink: "#222", muted: "#888", lane: "#f4f5f7", flag: "rgba(217,83,79,.13)", play: "#7a3cff" }; -const HIT_PX = 7, DRAG_PX = 3, RENDER_DEBOUNCE_MS = 400; +const C = { + rec: "#5b8def", midi: "#e2873b", anchor: "#2bb673", anchorAuto: "#86cfa9", warn: "#d9534f", ink: "#222", muted: "#888", lane: "#f4f5f7", + flag: "rgba(217,83,79,.13)", play: "#7a3cff", ruler: "#eef0f3", recMark: "#1f5fd6", midiMark: "#c2410c", pair: "#7a3cff", +}; +const HIT_PX = 7, DRAG_PX = 3, RENDER_DEBOUNCE_MS = 200; +/** Anchor handles: grab zones (CSS px) at the top of the recording lane and the bottom of the MIDI lane. */ +const HANDLE_W = 10, HANDLE_H = 9, HANDLE_ZONE = 14; +const VOLUME_KEY = "stsheets.align.volume", RENDER_CLIENT_CACHE = 8; // ---------- DOM ---------- const $ = (id: string) => document.getElementById(id) as T; @@ -27,7 +38,11 @@ const songSel = $("song"), songInfo = $("songinfo"), saveStat const btn = { play: $("play"), add: $("add"), del: $("del"), undo: $("undo"), auto: $("auto"), save: $("save"), reset: $("reset"), + link: $("link"), clearMarks: $("clearmarks"), playRec: $("prec"), playMidi: $("pmidi"), }; +const markIn = { rec: $("mrec"), midi: $("mmidi") }; +const volIn = { rec: $("volrec"), midi: $("volmidi") }; +const volOut = { rec: $("volrecv"), midi: $("volmidiv") }; const mixButtons = [...document.querySelectorAll(".mix button")]; // ---------- state ---------- @@ -46,13 +61,17 @@ let info: StretchInfo[] = []; let fit: FitReport | null = null, fitKey = ""; let savedKey: string | null = null, savedAt: string | null = null; let view: View = { start: 0, pxPerSec: 10, width: 800 }; -let cursor = 0, selected = -1, hover = -1, hoverLane: "rec" | "midi" | null = null; +let cursor = 0, selected = -1, hover = -1, hoverLane: PairSide | null = null; let recWave: Wave | null = null, midiWave: Wave | null = null, midiKey = ""; let recBuf: AudioBuffer | null = null, midiBuf: AudioBuffer | null = null; let busy = false, rendering = false; const undoStack: Anchor[][] = [], redoStack: Anchor[][] = []; let mix: Mix = "both"; let loadGen = 0; +let marks: PairMarks = NO_MARKS; +let volumes: Volumes = parseVolumes(localStorage.getItem(VOLUME_KEY)); +/** Encoded preview renders by anchor key, so undo/redo replays without a server round trip. */ +const renderCache = new Map(); const keyOf = (A: Anchor[]) => JSON.stringify(A.map((a) => [a.midiSec, a.audioSec, a.kind])); const dur = () => song?.audioDur ?? 1; @@ -77,19 +96,32 @@ function showOverlay(msg: string | null) { overlay.hidden = msg === null; overla // ---------- audio ---------- const actx = new AudioContext(); +// source -> mix gain -> volume gain -> pan -> out const recGain = actx.createGain(), midiGain = actx.createGain(); +const recVol = actx.createGain(), midiVol = actx.createGain(); const recPan = actx.createStereoPanner(), midiPan = actx.createStereoPanner(); -recGain.connect(recPan).connect(actx.destination); -midiGain.connect(midiPan).connect(actx.destination); +recGain.connect(recVol).connect(recPan).connect(actx.destination); +midiGain.connect(midiVol).connect(midiPan).connect(actx.destination); let recSrc: AudioBufferSourceNode | null = null, midiSrc: AudioBufferSourceNode | null = null; let playing = false, playCtx0 = 0, playPos0 = 0; +/** While auditioning a mark only that track plays (the mix choice is ignored). */ +let solo: PairSide | null = null; + +function applyVolumes() { + for (const side of ["rec", "midi"] as const) { + (side === "rec" ? recVol : midiVol).gain.setTargetAtTime(volumes[side] / 100, actx.currentTime, 0.015); + volIn[side].value = String(volumes[side]); + volOut[side].value = `${volumes[side]}%`; + } +} function applyMix() { const set = (p: AudioParam, v: number) => p.setTargetAtTime(v, actx.currentTime, 0.015); - set(recGain.gain, mix === "midi" ? 0 : 1); - set(midiGain.gain, mix === "rec" ? 0 : mix === "both" ? 0.8 : 1); - set(recPan.pan, mix === "split" ? -1 : 0); - set(midiPan.pan, mix === "split" ? 1 : 0); + const m: Mix = solo ?? mix; + set(recGain.gain, m === "midi" ? 0 : 1); + set(midiGain.gain, m === "rec" ? 0 : m === "both" ? 0.8 : 1); + set(recPan.pan, m === "split" ? -1 : 0); + set(midiPan.pan, m === "split" ? 1 : 0); mixButtons.forEach((b) => { b.classList.toggle("on", b.dataset.mix === mix); b.setAttribute("aria-checked", String(b.dataset.mix === mix)); }); } @@ -104,31 +136,42 @@ function startSource(buf: AudioBuffer, dest: AudioNode, when: number, offset: nu const playPos = () => playPos0 + Math.max(0, actx.currentTime - playCtx0); -async function play() { - if (!recBuf && !midiBuf) { setStatus("Audio is still loading…"); return; } +/** Plays from `from` (default: the cursor); `only` plays just that track (auditioning a pair mark). */ +async function play(from = cursor, only: PairSide | null = null) { + const need = only === "rec" ? recBuf : only === "midi" ? midiBuf : recBuf ?? midiBuf; + if (!need) { setStatus(only === "midi" ? "The MIDI is still rendering…" : "Audio is still loading…"); return; } await actx.resume(); stop(); + solo = only; applyMix(); const t0 = actx.currentTime + 0.06; // both sources start on the same context tick - playCtx0 = t0; playPos0 = cursor; - recSrc = recBuf ? startSource(recBuf, recGain, t0, cursor) : null; - midiSrc = midiBuf ? startSource(midiBuf, midiGain, t0, cursor) : null; + playCtx0 = t0; playPos0 = from; + recSrc = recBuf && only !== "midi" ? startSource(recBuf, recGain, t0, from) : null; + midiSrc = midiBuf && only !== "rec" ? startSource(midiBuf, midiGain, t0, from) : null; playing = true; - if (!recBuf) setStatus("Recording still loading: playing MIDI only."); - else if (!midiBuf) setStatus("MIDI still rendering: playing the recording only."); - btn.play.textContent = "■ Stop"; + if (!only && !recBuf) setStatus("Recording still loading: playing MIDI only."); + else if (!only && !midiBuf) setStatus("MIDI still rendering: playing the recording only."); + updatePlayButtons(); requestAnimationFrame(tick); } function stop() { for (const s of [recSrc, midiSrc]) { try { s?.stop(); } catch { /* already stopped */ } s?.disconnect(); } recSrc = midiSrc = null; - if (playing) { playing = false; btn.play.textContent = "▶ Play from cursor"; draw(); } + if (playing) { playing = false; draw(); } + if (solo) { solo = null; applyMix(); } + updatePlayButtons(); +} + +function updatePlayButtons() { + btn.play.textContent = playing && !solo ? "■ Stop" : "▶ Play from cursor"; + btn.playRec.textContent = playing && solo === "rec" ? "■" : "▶"; + btn.playMidi.textContent = playing && solo === "midi" ? "■" : "▶"; } /** Replace the MIDI buffer; if playing, the new one takes over seamlessly at the current position. */ function swapMidi(buf: AudioBuffer) { midiBuf = buf; - if (!playing) return; + if (!playing || solo === "rec") return; const t = actx.currentTime + 0.03; const next = startSource(buf, midiGain, t, playPos0 + (t - playCtx0)); try { midiSrc?.stop(t); } catch { /* ended */ } @@ -138,16 +181,18 @@ function swapMidi(buf: AudioBuffer) { function tick() { if (!playing) return; const p = playPos(); - if (p >= Math.max(recBuf?.duration ?? 0, midiBuf?.duration ?? 0)) { stop(); return; } + const end = solo === "rec" ? recBuf?.duration ?? 0 : solo === "midi" ? midiBuf?.duration ?? 0 : Math.max(recBuf?.duration ?? 0, midiBuf?.duration ?? 0); + if (p >= end) { stop(); return; } view = follow(view, p, dur()); draw(); requestAnimationFrame(tick); } -function waveFrom(buf: AudioBuffer): Wave { - const n = buf.length, mono = new Float32Array(n); - for (let c = 0; c < buf.numberOfChannels; c++) { const d = buf.getChannelData(c); for (let i = 0; i < n; i++) mono[i]! += d[i]! / buf.numberOfChannels; } - const levels = buildPyramid(mono, 64); +const pause = () => new Promise((r) => setTimeout(r, 0)); +/** Waveform pyramid of a decoded buffer, built in slices so a long recording never freezes the page. */ +async function waveFrom(buf: AudioBuffer): Promise { + const chans = Array.from({ length: buf.numberOfChannels }, (_, c) => buf.getChannelData(c)); + const levels = await buildPyramidSliced(chans, 64, pause); return { levels, rate: buf.sampleRate, norm: peakNorm(levels) }; } function peakNorm(levels: PeakLevel[]): number { @@ -198,19 +243,31 @@ function scheduleRefresh(now = false) { async function refresh() { if (!song) return; const A = anchors, key = keyOf(A), id = song.id, gen = ++fitGen; - http<{ fit: FitReport }>(songUrl("/fit"), send("POST", { anchors: A })) - .then((j) => { if (gen === fitGen && song?.id === id) { fit = j.fit; fitKey = key; updateUi(); draw(); } }) - .catch((e) => setStatus(`Fit update failed: ${(e as Error).message}`, true)); - if (key === midiKey) return; + if (key !== fitKey) { + http<{ fit: FitReport }>(songUrl("/fit"), send("POST", { anchors: A })) + .then((j) => { if (gen === fitGen && song?.id === id) { fit = j.fit; fitKey = key; updateUi(); draw(); } }) + .catch((e) => setStatus(`Fit update failed: ${(e as Error).message}`, true)); + } + if (key === midiKey) { renderAbort?.abort(); return; } renderAbort?.abort(); const ac = (renderAbort = new AbortController()); rendering = true; draw(); try { - const wav = await http(songUrl("/render"), send("POST", { anchors: A }, ac.signal)); - const buf = await actx.decodeAudioData(wav); + const ckey = `${id}\n${key}`; + let enc = renderCache.get(ckey); + if (!enc) { + enc = await http(songUrl("/render"), send("POST", { anchors: A }, ac.signal)); + renderCache.set(ckey, enc); + while (renderCache.size > RENDER_CLIENT_CACHE) renderCache.delete(renderCache.keys().next().value!); + } + if (ac.signal.aborted) return; + const buf = await actx.decodeAudioData(enc.slice(0)); // decoding detaches its input: keep the cached copy intact if (ac.signal.aborted || song?.id !== id) return; - midiWave = waveFrom(buf); midiKey = key; - swapMidi(buf); + swapMidi(buf); // audible first; the waveform follows + midiKey = key; + const w = await waveFrom(buf); + if (ac.signal.aborted || song?.id !== id) return; + midiWave = w; } catch (e) { if (!ac.signal.aborted) setStatus(`MIDI render failed: ${(e as Error).message}`, true); } finally { @@ -226,6 +283,7 @@ function undo(redo = false) { selected = -1; setAnchors(prev); scheduleRefresh(); + setStatus(redo ? "Redone." : "Undone."); } function addAtCursor() { @@ -237,6 +295,83 @@ function addAtCursor() { + (removed > 0 ? ` ${removed} automatic anchors within ${AUTO_CLEAR_SEC} s of it were removed; "Auto-align around my anchors" fills them in again.` : "")); } +// ---------- pairing ---------- +/** Where a mark sits on the timeline (recording seconds); the MIDI mark is drawn where the current warp puts its MIDI moment. */ +const markX = (side: PairSide): number | null => (marks[side] === null ? null : side === "rec" ? marks.rec : warp.toAudio(marks.midi!)); +/** The playhead while playing, else the cursor. */ +const playhead = () => (playing ? playPos() : cursor); + +/** Puts a mark at timeline position `t` (recording seconds). Changes nothing else: no warp change, no re-render. */ +function setMark(side: PairSide, t: number) { + if (!song) return; + const lane = Math.min(dur() + 60, Math.max(0, t)); + marks = placeMark(marks, side, side === "rec" ? lane : warp.toMidi(lane)); + markIn[side].classList.remove("invalid"); + const other: PairSide = side === "rec" ? "midi" : "rec"; + setStatus(marks[other] === null + ? `${side === "rec" ? "Recording" : "MIDI"} mark at ${formatTime(lane, 2)}. Now click the same note in the ${other === "rec" ? "Recording" : "MIDI"} lane (or press ${other === "rec" ? "R" : "M"} at the playhead).` + : `Marks: recording ${formatTime(markX("rec")!, 2)} ⇄ MIDI ${formatTime(markX("midi")!, 2)}. Press Link (Enter) to pair them, or move either mark.`); + updatePair(); draw(); +} + +function clearMarks() { + if (marks.rec === null && marks.midi === null) return; + marks = NO_MARKS; + markIn.rec.classList.remove("invalid"); markIn.midi.classList.remove("invalid"); + if (solo) stop(); + updatePair(); draw(); +} + +function link() { + if (!song || busy) return; + if (marks.rec === null || marks.midi === null) { setStatus("Place both a recording mark and a MIDI mark first (click the two lanes).", true); return; } + const rec = marks.rec, midiLane = markX("midi")!; + const autosBefore = anchors.filter((a) => a.kind === "auto").length; + let r: Edit; + try { r = linkMarks(anchors, marks); } catch (e) { setStatus((e as Error).message, true); return; } // marks are kept + const before = anchors; + marks = NO_MARKS; + selected = r.index; + commit(r.anchors, before, true); + updatePair(); + const removed = autosBefore - anchors.filter((a) => a.kind === "auto").length; + setStatus(`Linked: the MIDI at ${formatTime(midiLane, 2)} now plays at recording ${formatTime(rec, 2)}. Re-rendering the MIDI…` + + (removed > 0 ? ` ${removed} automatic anchors within ${AUTO_CLEAR_SEC} s were removed; "Auto-align around my anchors" fills them in again.` : "") + + " (Ctrl+Z undoes.)"); +} + +/** Typed mark time (m:ss.s or seconds), applied on Enter / leaving the field. */ +function markTyped(side: PairSide) { + const el = markIn[side], text = el.value.trim(); + if (!text) { if (marks[side] !== null) { marks = { ...marks, [side]: null, active: marks.active === side ? null : marks.active }; updatePair(); draw(); } return; } + const t = parseTime(text); + if (t === null) { el.classList.add("invalid"); setStatus(`"${text}" is not a time: type e.g. 0:23.2 or 23.2`, true); return; } + setMark(side, t); + el.value = formatTime(markX(side)!, 2); + view = follow(view, t, dur()); draw(); +} + +function auditionMark(side: PairSide) { + const t = markX(side); + if (t === null) return; + if (playing && solo === side) { stop(); return; } + void play(t, side); +} + +function updatePair() { + for (const side of ["rec", "midi"] as const) { + const t = markX(side), el = markIn[side]; + if (document.activeElement !== el) el.value = t === null ? "" : formatTime(t, 2); + el.classList.toggle("set", t !== null); + if (side === "midi") el.title = t === null ? "MIDI mark (position in the MIDI lane): click the MIDI lane, press M at the playhead, or type a time" + : `MIDI mark at ${formatTime(t, 2)} in the MIDI lane = ${formatTime(marks.midi!, 2)} in the MIDI file`; + } + btn.playRec.disabled = marks.rec === null; + btn.playMidi.disabled = marks.midi === null; + btn.link.disabled = !song || busy || marks.rec === null || marks.midi === null; + btn.clearMarks.disabled = marks.rec === null && marks.midi === null; +} + function deleteSelected() { if (selected < 0) return; const k = selected; @@ -314,6 +449,7 @@ async function openSong(id: string) { history.replaceState(null, "", `#${encodeURIComponent(id)}`); song = null; recBuf = midiBuf = null; recWave = midiWave = null; midiKey = ""; fit = null; undoStack.length = redoStack.length = 0; selected = hover = -1; + marks = NO_MARKS; setAnchors([]); showOverlay("Loading song… The first time a song is opened the server converts the recording and runs the automatic alignment (up to ~30 s)."); setStatus(""); @@ -340,7 +476,7 @@ async function openSong(id: string) { scheduleRefresh(true); // render the MIDI for the current anchors http(songUrl("/audio")) .then((b) => actx.decodeAudioData(b)) - .then((buf) => { if (gen !== loadGen) return; recBuf = buf; recWave = waveFrom(buf); draw(); }) + .then(async (buf) => { if (gen !== loadGen) return; recBuf = buf; const w = await waveFrom(buf); if (gen === loadGen) { recWave = w; draw(); } }) .catch((e) => setStatus(`Could not load the recording audio: ${(e as Error).message}`, true)); } catch (e) { if (gen !== loadGen) return; @@ -356,6 +492,7 @@ function updateUi() { btn.undo.disabled = busy || !undoStack.length; btn.auto.disabled = btn.save.disabled = btn.reset.disabled = !has || busy; btn.play.disabled = !has; + updatePair(); if (!has) { saveState.textContent = ""; fitEl.textContent = "Fit: –"; countsEl.textContent = ""; return; } const dirty = isDirty(); saveState.className = `savestate ${dirty ? "dirty" : "clean"}`; @@ -411,6 +548,7 @@ function paintWave(w: Wave | null, y: number, color: string, alpha: number) { function paint() { const W = cssW; g.clearRect(0, 0, W, HEIGHT); + g.fillStyle = C.ruler; g.fillRect(GUTTER, 0, W - GUTTER, RULER_H); // the ruler sets the playback cursor g.font = "11px system-ui, sans-serif"; g.textBaseline = "middle"; // ruler const step = rulerStep(view.pxPerSec), dec = step < 0.1 ? 2 : step < 1 ? 1 : 0; @@ -460,8 +598,12 @@ function paint() { g.beginPath(); g.moveTo(x, REC_Y - 2); g.lineTo(x, MIDI_Y + LANE_H); g.stroke(); if (manual || sel || hov) { // grab handles at the recording (top) and MIDI (bottom) ends g.fillStyle = sel ? "#16804c" : C.anchor; - g.fillRect(x - 4, REC_Y - 2, 8, 6); - g.fillRect(x - 4, MIDI_Y + LANE_H - 6, 8, 6); + g.fillRect(x - HANDLE_W / 2, REC_Y - 2, HANDLE_W, HANDLE_H); + g.fillRect(x - HANDLE_W / 2, MIDI_Y + LANE_H - HANDLE_H, HANDLE_W, HANDLE_H); + } else { // small stubs so automatic anchors show where to grab them + g.fillStyle = C.anchorAuto; + g.fillRect(x - 2.5, REC_Y - 2, 5, 5); + g.fillRect(x - 2.5, MIDI_Y + LANE_H - 5, 5, 5); } const label = formatTime(a.audioSec, view.pxPerSec > 200 ? 2 : 1), tw = g.measureText(label).width; if ((manual || sel || hov || spacingOk(k)) && x - tw / 2 > lastLabelEnd + 6) { @@ -475,7 +617,7 @@ function paint() { // hover tooltip if (hover >= 0 && hover < anchors.length) { const a = anchors[hover]!; - const txt = `${a.kind === "manual" ? "Your anchor" : "Automatic anchor"} · recording ${formatTime(a.audioSec, 2)} = MIDI ${formatTime(a.midiSec, 2)} · drag in the ${hoverLane === "midi" ? "MIDI lane to slide the MIDI" : "recording lane to move it"}`; + const txt = `${a.kind === "manual" ? "Your anchor" : "Automatic anchor"} · recording ${formatTime(a.audioSec, 2)} = MIDI ${formatTime(a.midiSec, 2)} · drag this handle to ${hoverLane === "midi" ? "slide the MIDI under it" : "move it along the recording"}`; const tw = g.measureText(txt).width + 12; const x = Math.min(W - tw - 4, Math.max(GUTTER, X(a.audioSec) + 8)); const y = hoverLane === "midi" ? MIDI_Y + 6 : REC_Y + 6; @@ -483,6 +625,26 @@ function paint() { g.fillStyle = "#fff"; g.textAlign = "left"; g.fillText(txt, x + 6, y + 9); } + // pair marks: dashed, each in its own lane, joined by a dashed connector when both are placed + const xr = markX("rec"), xm = markX("midi"); + const mark = (t: number, y: number, color: string, label: string) => { + const x = Math.round(X(t)) + 0.5; + if (x < GUTTER || x > W) return; + g.strokeStyle = color; g.lineWidth = 2; g.setLineDash([6, 4]); + g.beginPath(); g.moveTo(x, y); g.lineTo(x, y + LANE_H); g.stroke(); + g.setLineDash([]); g.lineWidth = 1; + const tw = g.measureText(label).width + 10, lx = Math.min(W - tw - 2, x + 3); + g.fillStyle = color; g.fillRect(lx, y + LANE_H - 19, tw, 16); + g.fillStyle = "#fff"; g.textAlign = "left"; g.fillText(label, lx + 5, y + LANE_H - 11); + }; + if (xr !== null) mark(xr, REC_Y, C.recMark, `R ${formatTime(xr, 2)}`); + if (xm !== null) mark(xm, MIDI_Y, C.midiMark, `M ${formatTime(xm, 2)}`); + if (xr !== null && xm !== null) { + g.strokeStyle = C.pair; g.lineWidth = 1.5; g.setLineDash([3, 3]); + g.beginPath(); g.moveTo(X(xr), REC_Y + LANE_H); g.lineTo(X(xm), MIDI_Y); g.stroke(); + g.setLineDash([]); g.lineWidth = 1; + } + // cursor and playhead const line = (t: number, color: string, w: number) => { const x = Math.round(X(t)) + 0.5; @@ -501,8 +663,10 @@ function paint() { g.fillText("Recording", 10, REC_Y + 16); g.fillText("MIDI audio", 10, MIDI_Y + 16); g.font = "11px system-ui, sans-serif"; g.fillStyle = C.muted; - g.fillText("drag = move", 10, REC_Y + 32); - g.fillText("drag = slide", 10, MIDI_Y + 32); + g.fillText("click = mark", 10, REC_Y + 32); + g.fillText("▮ = drag", 10, REC_Y + 46); + g.fillText("click = mark", 10, MIDI_Y + 32); + g.fillText("▮ = drag", 10, MIDI_Y + 46); g.fillText("tempo", 10, STRETCH_Y + 14); g.fillText(formatTime(cursor, 2), 10, RULER_H / 2); } @@ -513,47 +677,52 @@ function spacingOk(k: number): boolean { } // ---------- pointer ---------- +// Ruler (top strip): click/drag = playback cursor. Lanes: click = pair mark, drag = scroll. Anchor handles (top of the +// recording lane, bottom of the MIDI lane): drag = move the anchor, click = select it. type Drag = - | { kind: "pan"; x0: number; lastX: number; moved: boolean } + | { kind: "pan"; x0: number; lastX: number; moved: boolean; lane: PairSide | null } | { kind: "cursor" } | { kind: "rec" | "midi"; index: number; start: Anchor[]; grabTime: number; grabMidi: number; offset: number; x0: number; moved: boolean }; let drag: Drag | null = null; const local = (e: PointerEvent | WheelEvent) => { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; -const laneAt = (y: number): "rec" | "midi" | null => (y >= REC_Y - 4 && y < MIDI_Y - LANE_GAP / 2 ? "rec" : y >= MIDI_Y - LANE_GAP / 2 && y <= MIDI_Y + LANE_H + 2 ? "midi" : null); +const laneAt = (y: number): PairSide | null => (y >= REC_Y && y < MIDI_Y - LANE_GAP / 2 ? "rec" : y >= MIDI_Y - LANE_GAP / 2 && y <= MIDI_Y + LANE_H ? "midi" : null); +/** Which end's handle zone `y` is in (null: neither). */ +const handleZone = (y: number): PairSide | null => + y >= REC_Y - 6 && y <= REC_Y + HANDLE_ZONE ? "rec" : y >= MIDI_Y + LANE_H - HANDLE_ZONE && y <= MIDI_Y + LANE_H + 4 ? "midi" : null; +const inRuler = (y: number) => y < REC_Y - 6; canvas.addEventListener("pointerdown", (e) => { if (!song || busy || e.button !== 0) return; const { x, y } = local(e); if (x < GUTTER) return; canvas.setPointerCapture(e.pointerId); - const lx = x - GUTTER, lane = laneAt(y); - if (y < REC_Y - 4) { drag = { kind: "cursor" }; setCursor(xToTime(view, lx), false); return; } - const hit = lane ? hitAnchor(anchors, view, lx, HIT_PX) : -1; - if (hit >= 0 && lane) { + const lx = x - GUTTER, zone = handleZone(y); + const hit = zone ? hitAnchor(anchors, view, lx, HIT_PX) : -1; + if (hit >= 0 && zone) { selected = hit; const t = xToTime(view, lx); - drag = { kind: lane, index: hit, start: anchors, grabTime: t, grabMidi: warp.toMidi(t), offset: anchors[hit]!.audioSec - t, x0: x, moved: false }; + drag = { kind: zone, index: hit, start: anchors, grabTime: t, grabMidi: warp.toMidi(t), offset: anchors[hit]!.audioSec - t, x0: x, moved: false }; updateUi(); draw(); return; } - drag = { kind: "pan", x0: x, lastX: x, moved: false }; - canvas.style.cursor = "grabbing"; + if (inRuler(y)) { drag = { kind: "cursor" }; setCursor(xToTime(view, lx), false); return; } + drag = { kind: "pan", x0: x, lastX: x, moved: false, lane: laneAt(y) }; }); canvas.addEventListener("pointermove", (e) => { if (!song) return; const { x, y } = local(e), lx = x - GUTTER; if (!drag) { - const lane = x >= GUTTER ? laneAt(y) : null; - const h = lane ? hitAnchor(anchors, view, lx, HIT_PX) : -1; - if (h !== hover || lane !== hoverLane) { hover = h; hoverLane = lane; draw(); } - canvas.style.cursor = h >= 0 ? "ew-resize" : y < REC_Y - 4 && x >= GUTTER ? "text" : "default"; + const zone = x >= GUTTER ? handleZone(y) : null; + const h = zone ? hitAnchor(anchors, view, lx, HIT_PX) : -1; + if (h !== hover || zone !== hoverLane) { hover = h; hoverLane = zone; draw(); } + canvas.style.cursor = x < GUTTER ? "default" : h >= 0 ? "ew-resize" : inRuler(y) ? "col-resize" : laneAt(y) ? "crosshair" : "default"; return; } if (drag.kind === "cursor") { setCursor(xToTime(view, lx), false); return; } if (drag.kind === "pan") { - if (Math.abs(x - drag.x0) > DRAG_PX) drag.moved = true; + if (Math.abs(x - drag.x0) > DRAG_PX) { drag.moved = true; canvas.style.cursor = "grabbing"; } if (drag.moved) { view = scrollBy(view, x - drag.lastX, dur()); drag.lastX = x; draw(); } return; } @@ -575,7 +744,7 @@ function endDrag(e: PointerEvent) { canvas.style.cursor = "default"; const { x } = local(e); if (d.kind === "cursor" && playing) void play(); // scrubbing seeks playback once, on release - else if (d.kind === "pan" && !d.moved && x >= GUTTER) { selected = -1; setCursor(xToTime(view, x - GUTTER)); updateUi(); } + else if (d.kind === "pan" && !d.moved && x >= GUTTER && d.lane && e.type === "pointerup") setMark(d.lane, xToTime(view, x - GUTTER)); else if ((d.kind === "rec" || d.kind === "midi") && d.moved) { const now = anchors; anchors = d.start; // commit from the drag-start state so undo returns there @@ -608,8 +777,10 @@ canvas.addEventListener("wheel", (e) => { // ---------- keyboard & buttons ---------- window.addEventListener("keydown", (e) => { - const tag = (e.target as HTMLElement).tagName; - if (tag === "SELECT" || tag === "INPUT" || tag === "TEXTAREA" || !song) return; + const el = e.target as HTMLElement, tag = el.tagName; + const slider = tag === "INPUT" && (el as HTMLInputElement).type === "range"; + if (tag === "SELECT" || tag === "TEXTAREA" || (tag === "INPUT" && !slider) || !song) return; + if (slider && /^(Arrow|Page|Home|End)/.test(e.key)) return; // the slider's own keys const mod = e.ctrlKey || e.metaKey; if (mod && e.key.toLowerCase() === "z") { e.preventDefault(); undo(e.shiftKey); return; } if (mod && e.key.toLowerCase() === "y") { e.preventDefault(); undo(true); return; } @@ -617,17 +788,38 @@ window.addEventListener("keydown", (e) => { if (mod || e.altKey || busy) return; switch (e.key) { case " ": e.preventDefault(); playing ? stop() : void play(); break; + case "r": case "R": setMark("rec", playhead()); break; + case "m": case "M": setMark("midi", playhead()); break; + case "Enter": e.preventDefault(); link(); break; case "Delete": case "Backspace": e.preventDefault(); deleteSelected(); break; case "a": case "A": addAtCursor(); break; case "ArrowLeft": e.preventDefault(); nudge(e.shiftKey ? -0.1 : -0.01); break; case "ArrowRight": e.preventDefault(); nudge(e.shiftKey ? 0.1 : 0.01); break; - case "Escape": selected = -1; updateUi(); draw(); break; + case "Escape": if (marks.rec !== null || marks.midi !== null) { clearMarks(); setStatus("Marks cleared."); } else { selected = -1; updateUi(); draw(); } break; case "0": view = fitView(dur(), view.width); draw(); break; case "1": case "2": case "3": case "4": mix = (["both", "rec", "midi", "split"] as const)[Number(e.key) - 1]!; applyMix(); break; } }); -btn.play.addEventListener("click", () => (playing ? stop() : void play())); +btn.play.addEventListener("click", () => (playing && !solo ? stop() : void play())); +btn.link.addEventListener("click", link); +btn.clearMarks.addEventListener("click", clearMarks); +btn.playRec.addEventListener("click", () => auditionMark("rec")); +btn.playMidi.addEventListener("click", () => auditionMark("midi")); +for (const side of ["rec", "midi"] as const) { + const el = markIn[side]; + el.addEventListener("change", () => markTyped(side)); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); markTyped(side); el.blur(); } + else if (e.key === "Escape") { el.blur(); updatePair(); } + }); + el.addEventListener("input", () => el.classList.remove("invalid")); + volIn[side].addEventListener("input", () => { + volumes = { ...volumes, [side]: Number(volIn[side].value) }; + applyVolumes(); + try { localStorage.setItem(VOLUME_KEY, JSON.stringify(volumes)); } catch { /* storage unavailable */ } + }); +} btn.add.addEventListener("click", addAtCursor); btn.del.addEventListener("click", deleteSelected); btn.undo.addEventListener("click", () => undo()); @@ -635,13 +827,14 @@ btn.auto.addEventListener("click", () => void autoAlign()); btn.save.addEventListener("click", () => void save()); btn.reset.addEventListener("click", reset); for (const b of mixButtons) b.addEventListener("click", () => { mix = b.dataset.mix as Mix; applyMix(); }); -// keep Space/Enter from re-triggering the last clicked button -for (const b of document.querySelectorAll("button")) b.addEventListener("keydown", (e) => { if (e.key === " ") e.preventDefault(); }); +// keep Space/Enter from re-triggering the last clicked button (they are play/stop and Link) +for (const b of document.querySelectorAll("button")) b.addEventListener("keydown", (e) => { if (e.key === " " || e.key === "Enter") e.preventDefault(); }); songSel.addEventListener("change", () => void openSong(songSel.value)); window.addEventListener("beforeunload", (e) => { if (isDirty() && (undoStack.length || savedKey !== null)) e.preventDefault(); }); new ResizeObserver(resize).observe(wrap); applyMix(); +applyVolumes(); resize(); updateUi(); loadSongs().catch((e) => showOverlay(`Could not load the song list: ${(e as Error).message}`)); diff --git a/src/align/page/edit.ts b/src/align/page/edit.ts index 7d3a140..94acaac 100644 --- a/src/align/page/edit.ts +++ b/src/align/page/edit.ts @@ -63,3 +63,75 @@ export function anchorAtCursor(anchors: Anchor[], cursorSec: number): Edit { const out = addManual(anchors, { midiSec, audioSec: cursorSec }); return { anchors: out, index: out.findIndex((a) => a.audioSec === cursorSec && a.kind === "manual") }; } + +// ---------- pair anchors: a recording mark and a MIDI mark, linked into one manual anchor ---------- + +export type PairSide = "rec" | "midi"; +/** + * Marks placed in Pair mode. `rec` = recording time (s). `midi` = MIDI time (s): the MIDI lane shows the warped MIDI, + * so a click there at lane time t means MIDI moment warp.toMidi(t) (see `midiMarkAt`); storing the MIDI moment keeps + * the mark on the same note if the warp changes before linking. `active` = the mark placed last (Space plays from it). + */ +export interface PairMarks { rec: number | null; midi: number | null; active: PairSide | null } + +export const NO_MARKS: PairMarks = { rec: null, midi: null, active: null }; + +/** MIDI moment under lane time `laneSec` on the (warped) MIDI lane. */ +export const midiMarkAt = (anchors: Anchor[], laneSec: number) => makeWarp(anchors).toMidi(laneSec); + +export function placeMark(m: PairMarks, side: PairSide, sec: number): PairMarks { + return { ...m, [side]: sec, active: side }; +} + +/** Lane time (recording seconds) at which to draw / play a mark under the current anchors. */ +export function markLaneSec(anchors: Anchor[], m: PairMarks, side: PairSide): number | null { + const v = m[side]; + return v === null ? null : side === "rec" ? v : makeWarp(anchors).toAudio(v); +} + +const fmt = (s: number) => { + const t = Math.round(Math.abs(s) * 100) / 100, mm = Math.floor(t / 60); + return `${s < 0 && t > 0 ? "-" : ""}${mm}:${(t - mm * 60).toFixed(2).padStart(5, "0")}`; +}; + +/** + * Why a manual anchor (midiSec, audioSec) cannot be added, or null if it can: it must sit strictly between your + * (manual) anchors in both MIDI and recording time. Automatic anchors never block it (addManual drops the ones in the way). + * Times in the message are timeline positions as drawn (the MIDI mark where the warped MIDI lane shows it; an anchor at + * its recording time, where both lanes draw it), so they match what the user sees and typed. + */ +export function linkConflict(anchors: Anchor[], midiSec: number, audioSec: number): string | null { + const w = makeWarp(anchors), man = w.anchors.filter((a) => a.kind === "manual"); + const same = man.find((a) => a.midiSec === midiSec || a.audioSec === audioSec); + if (same) return `Can't link: your anchor at ${fmt(same.audioSec)} already uses that ${same.midiSec === midiSec ? "MIDI" : "recording"} moment. Move a mark or delete that anchor.`; + const left = man.filter((a) => a.midiSec < midiSec).pop(), right = man.find((a) => a.midiSec > midiSec); + const bad = left && !(audioSec > left.audioSec) ? { a: left, side: "after" } : right && !(audioSec < right.audioSec) ? { a: right, side: "before" } : null; + if (!bad) return null; + const other = bad.side === "after" ? "before" : "after"; + return `Can't link: the MIDI mark (${fmt(w.toAudio(midiSec))}) is ${bad.side} your anchor at ${fmt(bad.a.audioSec)}, but the recording mark (${fmt(audioSec)}) is ${other} it. ` + + "Anchors must keep the same order in both lanes: move a mark, or delete that anchor first."; +} + +/** The manual anchor joining both marks; throws (with a user-facing message) if a mark is missing or the order would break. */ +export function linkMarks(anchors: Anchor[], m: PairMarks): Edit { + if (m.rec === null || m.midi === null) throw new Error("Place both a recording mark and a MIDI mark first."); + const audioSec = m.rec, midiSec = m.midi; + const why = linkConflict(anchors, midiSec, audioSec); + if (why) throw new Error(why); + const out = addManual(anchors, { midiSec, audioSec }); + return { anchors: out, index: out.findIndex((a) => a.kind === "manual" && a.audioSec === audioSec && a.midiSec === midiSec) }; +} + +// ---------- volume ---------- + +export interface Volumes { rec: number; midi: number } // percent, 0–150 +export const DEFAULT_VOLUMES: Volumes = { rec: 100, midi: 100 }; +const clampPct = (x: unknown, d: number) => (typeof x === "number" && Number.isFinite(x) ? Math.min(150, Math.max(0, Math.round(x))) : d); + +/** Volumes from a stored JSON string; anything missing or invalid falls back to 100 %. */ +export function parseVolumes(raw: string | null): Volumes { + try { + const j = JSON.parse(raw ?? "null") as Partial | null; + return { rec: clampPct(j?.rec, DEFAULT_VOLUMES.rec), midi: clampPct(j?.midi, DEFAULT_VOLUMES.midi) }; + } catch { return { ...DEFAULT_VOLUMES }; } +} diff --git a/src/align/page/index.html b/src/align/page/index.html index b99469a..d3533c3 100644 --- a/src/align/page/index.html +++ b/src/align/page/index.html @@ -4,6 +4,7 @@ STSheets · MIDI alignment + @@ -25,6 +26,24 @@ +
+ Pair + + + + + + + + + + + + + + Click a note in the Recording lane, then the same note in the MIDI lane, then Link (Enter). +
+
@@ -34,6 +53,10 @@
+ + +
+
@@ -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))); +});