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]>
122 lines
5.5 KiB
TypeScript
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 };
|
|
}
|