Files
STSheets/src/align/page/timeline.ts
T
Lucas WintherandClaude Opus 5.5 894fd9b9ef feat(align): alignment web page
Token-protected Bun.serve server (bun run align [--host] [--port]
[--url-host] [--root]) with the layout-A page: recording and warped-MIDI
lanes on one zoomable timeline, draggable anchors (recording end / MIDI
end), stretch tempo labels with warnings, fit-flagged windows, Web Audio
playback with mix choice, auto-align, save, reset, undo. API per the task
brief; transcode/peaks cache under reference/<id>/. Adds bun run align
auto <id>, writeSmf (tests), encodeWav, recordingOnsets/fitFromOnsets,
fitSummary and stretch tempo helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:38:36 +01:00

122 lines
5.5 KiB
TypeScript

// Timeline geometry for the alignment page: time <-> pixel mapping, zoom/scroll, ruler, waveform peaks.
// Pure (no DOM), unit-tested in tests/align/page/timeline.test.ts.
/** `start` = time (s) at x = 0 of the lane area; `width` = lane area width in CSS px. */
export interface View { start: number; pxPerSec: number; width: number }
export const MAX_PX_PER_SEC = 4000; // ~11 samples per px at 44.1 kHz
const MARGIN = 0.01; // fraction of the song shown beyond either end
export const timeToX = (v: View, t: number) => (t - v.start) * v.pxPerSec;
export const xToTime = (v: View, x: number) => v.start + x / v.pxPerSec;
const minPxPerSec = (dur: number, width: number) => width / (Math.max(dur, 1e-3) * (1 + 2 * MARGIN));
/** The whole song, with a small margin either side. */
export function fitView(dur: number, width: number): View {
return { start: -dur * MARGIN, pxPerSec: minPxPerSec(dur, width), width };
}
/** Clamps zoom to [whole song, MAX_PX_PER_SEC] and scroll so the view stays within the song (plus margin). */
export function clampView(v: View, dur: number): View {
const lo = minPxPerSec(dur, v.width);
const pxPerSec = Math.min(MAX_PX_PER_SEC, Math.max(lo, v.pxPerSec));
const visible = v.width / pxPerSec, m = dur * MARGIN;
const start = Math.min(Math.max(v.start, -m), Math.max(-m, dur + m - visible));
return start === v.start && pxPerSec === v.pxPerSec ? v : { start, pxPerSec, width: v.width };
}
/** Zoom by `factor` keeping the time under pixel `x` fixed (as far as clamping allows). */
export function zoomAt(v: View, x: number, factor: number, dur: number): View {
const t = xToTime(v, x);
const pxPerSec = Math.min(MAX_PX_PER_SEC, Math.max(minPxPerSec(dur, v.width), v.pxPerSec * factor));
return clampView({ start: t - x / pxPerSec, pxPerSec, width: v.width }, dur);
}
/** Drag-scroll: content follows the pointer, so dragging right by dx shows earlier time. */
export function scrollBy(v: View, dx: number, dur: number): View {
return clampView({ ...v, start: v.start - dx / v.pxPerSec }, dur);
}
/** Pages the view when `t` (e.g. the playhead) leaves it; unchanged otherwise. */
export function follow(v: View, t: number, dur: number): View {
const visible = v.width / v.pxPerSec;
if (t >= v.start && t <= v.start + visible * 0.95) return v;
return clampView({ ...v, start: t - visible * 0.05 }, dur);
}
/** "m:ss.s" (decimals configurable). */
export function formatTime(sec: number, decimals = 1): string {
const p = 10 ** decimals;
const total = Math.round(Math.abs(sec) * p) / p;
const mins = Math.floor(total / 60);
const s = (total - mins * 60).toFixed(decimals).padStart(decimals ? 3 + decimals : 2, "0");
return `${sec < 0 && total > 0 ? "-" : ""}${mins}:${s}`;
}
const STEPS = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200];
/** Seconds between labelled ruler ticks so labels are at least 80 px apart. */
export function rulerStep(pxPerSec: number): number {
return STEPS.find((s) => s * pxPerSec >= 80) ?? STEPS[STEPS.length - 1]!;
}
/** One level of a min/max pyramid: `bucket` = input samples per entry. */
export interface PeakLevel { bucket: number; min: Float32Array; max: Float32Array }
function levelsFrom(level: PeakLevel): PeakLevel[] {
const out = [level];
let cur = level;
while (cur.min.length > 2) {
const n = Math.ceil(cur.min.length / 2);
const min = new Float32Array(n), max = new Float32Array(n);
for (let i = 0; i < n; i++) {
const a = 2 * i, b = Math.min(2 * i + 1, cur.min.length - 1);
min[i] = Math.min(cur.min[a]!, cur.min[b]!);
max[i] = Math.max(cur.max[a]!, cur.max[b]!);
}
cur = { bucket: cur.bucket * 2, min, max };
out.push(cur);
}
return out;
}
/** Min/max pyramid of a waveform: level 0 has `base` samples per bucket, each next level doubles it. */
export function buildPyramid(x: Float32Array, base = 64): PeakLevel[] {
const n = Math.max(1, Math.ceil(x.length / base));
const min = new Float32Array(n), max = new Float32Array(n);
for (let b = 0; b < n; b++) {
let lo = Infinity, hi = -Infinity;
for (let i = b * base, e = Math.min(x.length, i + base); i < e; 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;
}
return levelsFrom({ bucket: base, min, max });
}
/** Pyramid over precomputed peaks (e.g. from the server): each entry counts as one "sample". */
export function pyramidFromPeaks(min: ArrayLike<number>, max: ArrayLike<number>): PeakLevel[] {
return levelsFrom({ bucket: 1, min: Float32Array.from(min), max: Float32Array.from(max) });
}
/** Per pixel column of `v`: min/max of the waveform (sampled at `sampleRate`) under that column; 0 outside it. */
export function columnPeaks(levels: PeakLevel[], sampleRate: number, v: View): { min: Float32Array; max: Float32Array } {
const w = Math.max(0, Math.round(v.width));
const min = new Float32Array(w), max = new Float32Array(w);
const spp = sampleRate / v.pxPerSec;
let L = levels[0]!;
for (const l of levels) if (l.bucket <= spp) L = l;
const n = L.min.length;
for (let c = 0; c < w; c++) {
const s0 = (v.start + c / v.pxPerSec) * sampleRate;
const b0 = Math.floor(s0 / L.bucket);
const b1 = Math.max(b0, Math.ceil((s0 + spp) / L.bucket) - 1);
if (b1 < 0 || b0 >= n) continue;
let lo = Infinity, hi = -Infinity;
for (let b = Math.max(0, b0), e = Math.min(n - 1, b1); b <= e; b++) {
if (L.min[b]! < lo) lo = L.min[b]!;
if (L.max[b]! > hi) hi = L.max[b]!;
}
min[c] = lo; max[c] = hi;
}
return { min, max };
}