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]>
This commit is contained in:
Lucas Winther
2026-10-30 22:04:46 +01:00
co-authored by Claude Opus 5.5
parent 3ce58e1e05
commit 1a922e2508
6 changed files with 507 additions and 69 deletions
+256 -63
View File
@@ -1,23 +1,34 @@
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
// 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 $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
@@ -27,7 +38,11 @@ const songSel = $<HTMLSelectElement>("song"), songInfo = $("songinfo"), saveStat
const btn = {
play: $<HTMLButtonElement>("play"), add: $<HTMLButtonElement>("add"), del: $<HTMLButtonElement>("del"), undo: $<HTMLButtonElement>("undo"),
auto: $<HTMLButtonElement>("auto"), save: $<HTMLButtonElement>("save"), reset: $<HTMLButtonElement>("reset"),
link: $<HTMLButtonElement>("link"), clearMarks: $<HTMLButtonElement>("clearmarks"), playRec: $<HTMLButtonElement>("prec"), playMidi: $<HTMLButtonElement>("pmidi"),
};
const markIn = { rec: $<HTMLInputElement>("mrec"), midi: $<HTMLInputElement>("mmidi") };
const volIn = { rec: $<HTMLInputElement>("volrec"), midi: $<HTMLInputElement>("volmidi") };
const volOut = { rec: $<HTMLOutputElement>("volrecv"), midi: $<HTMLOutputElement>("volmidiv") };
const mixButtons = [...document.querySelectorAll<HTMLButtonElement>(".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<string, ArrayBuffer>();
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<void>((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<Wave> {
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<ArrayBuffer>(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<ArrayBuffer>(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<ArrayBuffer>(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}`));
+72
View File
@@ -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<Volumes> | null;
return { rec: clampPct(j?.rec, DEFAULT_VOLUMES.rec), midi: clampPct(j?.midi, DEFAULT_VOLUMES.midi) };
} catch { return { ...DEFAULT_VOLUMES }; }
}
+35 -6
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>STSheets · MIDI alignment</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
@@ -25,6 +26,24 @@
<div id="overlay" class="overlay" hidden></div>
</div>
<section class="pair" aria-label="Pair a recording moment with a MIDI moment">
<span class="pairtitle">Pair</span>
<span class="mark rec">
<label for="mrec">Recording</label>
<input id="mrec" class="time" placeholder="not set" autocomplete="off" spellcheck="false" title="Recording mark: click the Recording lane, press R at the playhead, or type a time (m:ss.s or seconds)" />
<button id="prec" class="aud" title="Listen: the recording alone from this mark" disabled>▶</button>
</span>
<span class="pairarrow" aria-hidden="true">⇄</span>
<span class="mark midi">
<label for="mmidi">MIDI</label>
<input id="mmidi" class="time" placeholder="not set" autocomplete="off" spellcheck="false" title="MIDI mark (position in the MIDI lane): click the MIDI lane, press M at the playhead, or type a time" />
<button id="pmidi" class="aud" title="Listen: the MIDI alone from this mark" disabled>▶</button>
</span>
<button id="link" class="link" title="Enter" disabled>Link</button>
<button id="clearmarks" class="quiet2" title="Esc" disabled>Clear</button>
<span id="pairhint" class="pairhint">Click a note in the <b>Recording</b> lane, then the same note in the <b>MIDI</b> lane, then <b>Link</b> (Enter).</span>
</section>
<div class="row controls">
<button id="play" title="Space">▶ Play from cursor</button>
<div class="mix" role="radiogroup" aria-label="Mix">
@@ -34,6 +53,10 @@
<button data-mix="midi" title="3">MIDI only</button>
<button data-mix="split" title="4">rec L / MIDI R</button>
</div>
<label class="vol rec" title="Recording volume">Recording <input id="volrec" type="range" min="0" max="150" step="1" value="100" /> <output id="volrecv">100%</output></label>
<label class="vol midi" title="MIDI volume">MIDI <input id="volmidi" type="range" min="0" max="150" step="1" value="100" /> <output id="volmidiv">100%</output></label>
</div>
<div class="row controls">
<button id="add" title="A">+ Anchor at cursor</button>
<button id="del" title="Delete" disabled>Delete anchor</button>
<button id="undo" title="Ctrl+Z" disabled>Undo</button>
@@ -51,12 +74,18 @@
the label under the lanes shows MIDI bpm → resulting bpm. <b>⚠</b> = 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.</p>
<ul>
<li><b>Click</b> the lanes to set the cursor (black line); click an anchor to select it. <b>Wheel</b> zooms around the mouse (Shift+wheel or a sideways swipe scrolls); <b>drag the background</b> to scroll.</li>
<li>Drag an anchor in the <b>Recording</b> lane to move it along the recording (the MIDI moment stays attached to it).
Drag it in the <b>MIDI audio</b> 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.</li>
<li>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; <b>Auto-align around my anchors</b> fills them in again.</li>
<li>Keys: <kbd>Space</kbd> play/stop · <kbd>A</kbd> anchor at cursor · <kbd>Delete</kbd> delete selected · <kbd>←</kbd>/<kbd>→</kbd> nudge selected anchor 10 ms (<kbd>Shift</kbd> 100 ms; nothing selected: move cursor 1 s) ·
<kbd>1</kbd>–<kbd>4</kbd> mix · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo / <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> redo · <kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>0</kbd> show whole song.</li>
<li><b>Pairing (the main tool).</b> Click a note in the <b>Recording</b> lane: a dashed blue <b>recording mark</b> appears. Click the same note in the <b>MIDI</b> lane: a dashed orange <b>MIDI mark</b>.
Placing marks changes nothing else. Fine-tune them by clicking again, typing a time in the Pair box (<code>0:23.2</code> or <code>23.2</code>), or pressing <kbd>R</kbd> / <kbd>M</kbd> to drop a mark at the playhead while listening.
The <b>▶</b> next to each mark plays just that track from the mark. <b>Link</b> (<kbd>Enter</kbd>) makes them one anchor: that MIDI moment now plays at the recording mark. <kbd>Esc</kbd> clears the marks.</li>
<li><b>Playback cursor:</b> click or drag in the <b>time ruler</b> along the top (black triangle). <kbd>Space</kbd> plays from it; <kbd>←</kbd>/<kbd>→</kbd> moves it 1 s when no anchor is selected.</li>
<li><b>Moving anchors:</b> grab an anchor by its <b>handle</b> (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 (<kbd>Delete</kbd> removes it).</li>
<li><b>Wheel</b> zooms around the mouse (Shift+wheel or a sideways swipe scrolls); <b>drag the lane background</b> to scroll.</li>
<li>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; <b>Auto-align around my anchors</b> 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.</li>
<li><b>Volume:</b> the Recording and MIDI sliders (0–150 %) work on top of the mix choice, also while playing, and are remembered in this browser.</li>
<li>Keys: <kbd>Space</kbd> play/stop · <kbd>R</kbd> / <kbd>M</kbd> recording / MIDI mark at the playhead · <kbd>Enter</kbd> link · <kbd>Esc</kbd> clear marks / selection · <kbd>A</kbd> anchor at cursor · <kbd>Delete</kbd> delete selected ·
<kbd>←</kbd>/<kbd>→</kbd> nudge selected anchor 10 ms (<kbd>Shift</kbd> 100 ms) · <kbd>1</kbd>–<kbd>4</kbd> mix · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo / <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> redo · <kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>0</kbd> show whole song.</li>
</ul>
</details>
+20
View File
@@ -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; }
+36
View File
@@ -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<void>, sliceSamples = 1 << 20,
): Promise<PeakLevel[]> {
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 });
}
+88
View File
@@ -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)));
});