// 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, max: ArrayLike): 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 }; }