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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e5773e0c33
commit
894fd9b9ef
20 files changed
+1783
-11
No files matched your search
+11
-2
@@ -196,10 +196,19 @@ function fitFor(recOnsets: number[], smf: Smf, roles: Record<string, Role | null
|
||||
return fitQuality(recOnsets, midi, durSec);
|
||||
}
|
||||
|
||||
/** Onset-flux peaks of the raw recording (seconds). Compute once and reuse with `fitFromOnsets` to re-score edits cheaply. */
|
||||
export function recordingOnsets(audio: Audio): number[] {
|
||||
return pickOnsets(features(audio, FINE_RATE).onset, FINE_RATE);
|
||||
}
|
||||
|
||||
/** `alignmentFit` with precomputed recording onsets; `durSec` = recording duration. */
|
||||
export function fitFromOnsets(onsets: number[], smf: Smf, roles: Record<string, Role | null>, anchors: Anchor[], durSec: number): FitReport {
|
||||
return fitFor(onsets, smf, roles, makeWarp(anchors), durSec);
|
||||
}
|
||||
|
||||
/** Fit quality of any alignment (e.g. a hand-made one): onset-flux peaks of the raw recording vs the warped MIDI. */
|
||||
export function alignmentFit(audio: Audio, smf: Smf, roles: Record<string, Role | null>, anchors: Anchor[]): FitReport {
|
||||
const onsets = pickOnsets(features(audio, FINE_RATE).onset, FINE_RATE);
|
||||
return fitFor(onsets, smf, roles, makeWarp(anchors), durationOf(audio));
|
||||
return fitFromOnsets(recordingOnsets(audio), smf, roles, anchors, durationOf(audio));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-2
@@ -1,8 +1,14 @@
|
||||
import { parseArgs } from "node:util";
|
||||
import { addSong, trackKeys } from "../ref/manifest.ts";
|
||||
import { addSong, loadSong, readAlignment, trackKeys, writeAlignment } from "../ref/manifest.ts";
|
||||
import { parseSmf } from "./smf.ts";
|
||||
import { autoAlign } from "./auto.ts";
|
||||
import { fitSummary } from "./fit.ts";
|
||||
import { SF2_PATH, startServer } from "./server.ts";
|
||||
|
||||
const USAGE = `usage: bun run align add <audio> <midi> --id <id> [--title <t>] [--capo "<track name>=9"]...`;
|
||||
const USAGE = `usage:
|
||||
bun run align [serve] [--host 127.0.0.1] [--port 5180] [--url-host <host shown in the URL>] [--root reference]
|
||||
bun run align add <audio> <midi> --id <id> [--title <t>] [--capo "<track name>=9"]...
|
||||
bun run align auto <id> [--root reference]`;
|
||||
|
||||
async function add(argv: string[]) {
|
||||
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { id: { type: "string" }, title: { type: "string" }, capo: { type: "string", multiple: true } } });
|
||||
@@ -23,9 +29,39 @@ async function add(argv: string[]) {
|
||||
for (const [k, r] of Object.entries(e.roles)) console.log(` ${JSON.stringify(k)} -> ${r ?? "unmapped"}`);
|
||||
}
|
||||
|
||||
async function serve(argv: string[]) {
|
||||
const { values } = parseArgs({ args: argv, options: { host: { type: "string" }, port: { type: "string" }, "url-host": { type: "string" }, root: { type: "string" } } });
|
||||
const port = values.port === undefined ? 5180 : Number(values.port);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`bad --port ${values.port}`);
|
||||
const s = await startServer({ host: values.host ?? "127.0.0.1", port, urlHost: values["url-host"], root: values.root ?? "reference" });
|
||||
console.log(`Alignment page: ${s.url}`);
|
||||
console.log(`(listening on ${values.host ?? "127.0.0.1"}:${s.port}; the key in the URL is required and changes on every start)`);
|
||||
}
|
||||
|
||||
async function auto(argv: string[]) {
|
||||
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { root: { type: "string" } } });
|
||||
const root = values.root ?? "reference";
|
||||
const id = positionals[0];
|
||||
if (!id) throw new Error(USAGE);
|
||||
const s = await loadSong(id, root);
|
||||
const prev = await readAlignment(id, root);
|
||||
const manual = (prev?.anchors ?? []).filter((a) => a.kind === "manual");
|
||||
const keys = trackKeys(s.smf.tracks);
|
||||
const roles = Object.fromEntries(keys.map((k) => [k, s.entry.roles[k] ?? null]));
|
||||
const sf2 = new Uint8Array(await Bun.file(SF2_PATH).arrayBuffer());
|
||||
const t0 = performance.now();
|
||||
const r = autoAlign(s.audio, s.smf, roles, manual, sf2);
|
||||
await writeAlignment(id, { anchors: r.anchors, savedAt: new Date().toISOString(), fit: r.fit }, root);
|
||||
console.log(`${id}: ${r.anchors.length} anchors (${manual.length} manual kept) in ${((performance.now() - t0) / 1000).toFixed(1)} s -> ${root}/${id}/alignment.json`);
|
||||
console.log(`fit: ${fitSummary(r.fit)}`);
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
try {
|
||||
if (cmd === "add") await add(rest);
|
||||
else if (cmd === "auto") await auto(rest);
|
||||
else if (cmd === "serve") await serve(rest);
|
||||
else if (cmd === undefined || cmd.startsWith("--")) await serve(process.argv.slice(2));
|
||||
else throw new Error(USAGE);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
|
||||
@@ -55,3 +55,13 @@ export function fitQuality(audioOnsets: number[], midiOnsets: number[], durSec:
|
||||
});
|
||||
return { ...summarise(res), windows };
|
||||
}
|
||||
|
||||
/** One-line summary, e.g. "82.3% of MIDI onsets matched · median residual 14.2 ms · 2/9 windows flagged (0:30, 2:00)". */
|
||||
export function fitSummary(f: FitReport): string {
|
||||
if (f.matchRate === null) return "no scored MIDI onsets";
|
||||
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
const flagged = f.windows.filter((w) => w.flagged);
|
||||
const med = f.medianResidualMs === null ? "n/a" : `${f.medianResidualMs.toFixed(1)} ms`;
|
||||
const list = flagged.length ? ` (${flagged.map((w) => mmss(w.startSec)).join(", ")})` : "";
|
||||
return `${(f.matchRate * 100).toFixed(1)}% of MIDI onsets matched · median residual ${med} · ${flagged.length}/${f.windows.length} windows flagged${list}`;
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
/// <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.
|
||||
import { 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,
|
||||
type PeakLevel, type View,
|
||||
} from "./timeline.ts";
|
||||
import { anchorAtCursor, dragAudio, dragMidi, hitAnchor, type Edit } 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;
|
||||
|
||||
// ---------- DOM ----------
|
||||
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const canvas = $<HTMLCanvasElement>("tl"), g = canvas.getContext("2d")!;
|
||||
const wrap = $("wrap"), overlay = $("overlay"), statusEl = $("status"), fitEl = $("fit"), countsEl = $("counts");
|
||||
const songSel = $<HTMLSelectElement>("song"), songInfo = $("songinfo"), saveState = $("savestate");
|
||||
const btn = {
|
||||
play: $<HTMLButtonElement>("play"), add: $<HTMLButtonElement>("add"), del: $<HTMLButtonElement>("del"), undo: $<HTMLButtonElement>("undo"),
|
||||
auto: $<HTMLButtonElement>("auto"), save: $<HTMLButtonElement>("save"), reset: $<HTMLButtonElement>("reset"),
|
||||
};
|
||||
const mixButtons = [...document.querySelectorAll<HTMLButtonElement>(".mix button")];
|
||||
|
||||
// ---------- state ----------
|
||||
interface SongInfo {
|
||||
id: string; title: string; audioDur: number; midiDur: number; tracks: { name: string; role: string | null }[];
|
||||
recordingPeaks: { bucketSec: number; min: number[]; max: number[] }; anchors: Anchor[]; fit: FitReport;
|
||||
midiTempo: TempoPoint[]; source: "saved" | "auto"; savedAt: string | null;
|
||||
}
|
||||
interface Wave { levels: PeakLevel[]; rate: number; norm: number }
|
||||
type Mix = "both" | "rec" | "midi" | "split";
|
||||
|
||||
let song: SongInfo | null = null;
|
||||
let anchors: Anchor[] = [];
|
||||
let warp: Warp = makeWarp([]);
|
||||
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 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;
|
||||
|
||||
const keyOf = (A: Anchor[]) => JSON.stringify(A.map((a) => [a.midiSec, a.audioSec, a.kind]));
|
||||
const dur = () => song?.audioDur ?? 1;
|
||||
const isDirty = () => song !== null && keyOf(anchors) !== savedKey;
|
||||
|
||||
// ---------- HTTP ----------
|
||||
async function http<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const r = await fetch(path, init);
|
||||
if (r.status === 401) throw new Error("Not authorised (server restarted?): open the URL printed by the server again.");
|
||||
if (!r.ok) {
|
||||
let msg = `${r.status} ${r.statusText}`;
|
||||
try { msg = ((await r.json()) as { error?: string }).error ?? msg; } catch { /* not JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return (r.headers.get("content-type") ?? "").includes("json") ? ((await r.json()) as T) : ((await r.arrayBuffer()) as T);
|
||||
}
|
||||
const send = (method: string, body: unknown, signal?: AbortSignal): RequestInit => ({ method, body: JSON.stringify(body), headers: { "content-type": "application/json" }, signal });
|
||||
const songUrl = (rest = "") => `/api/song/${encodeURIComponent(song!.id)}${rest}`;
|
||||
|
||||
function setStatus(msg: string, error = false) { statusEl.textContent = msg; statusEl.classList.toggle("error", error); }
|
||||
function showOverlay(msg: string | null) { overlay.hidden = msg === null; overlay.textContent = msg ?? ""; }
|
||||
|
||||
// ---------- audio ----------
|
||||
const actx = new AudioContext();
|
||||
const recGain = actx.createGain(), midiGain = actx.createGain();
|
||||
const recPan = actx.createStereoPanner(), midiPan = actx.createStereoPanner();
|
||||
recGain.connect(recPan).connect(actx.destination);
|
||||
midiGain.connect(midiPan).connect(actx.destination);
|
||||
let recSrc: AudioBufferSourceNode | null = null, midiSrc: AudioBufferSourceNode | null = null;
|
||||
let playing = false, playCtx0 = 0, playPos0 = 0;
|
||||
|
||||
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);
|
||||
mixButtons.forEach((b) => { b.classList.toggle("on", b.dataset.mix === mix); b.setAttribute("aria-checked", String(b.dataset.mix === mix)); });
|
||||
}
|
||||
|
||||
function startSource(buf: AudioBuffer, dest: AudioNode, when: number, offset: number): AudioBufferSourceNode | null {
|
||||
if (offset >= buf.duration) return null;
|
||||
const s = actx.createBufferSource();
|
||||
s.buffer = buf;
|
||||
s.connect(dest);
|
||||
s.start(when, Math.max(0, offset));
|
||||
return s;
|
||||
}
|
||||
|
||||
const playPos = () => playPos0 + Math.max(0, actx.currentTime - playCtx0);
|
||||
|
||||
async function play() {
|
||||
if (!recBuf && !midiBuf) { setStatus("Audio is still loading…"); return; }
|
||||
await actx.resume();
|
||||
stop();
|
||||
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;
|
||||
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";
|
||||
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(); }
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const t = actx.currentTime + 0.03;
|
||||
const next = startSource(buf, midiGain, t, playPos0 + (t - playCtx0));
|
||||
try { midiSrc?.stop(t); } catch { /* ended */ }
|
||||
midiSrc = next;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!playing) return;
|
||||
const p = playPos();
|
||||
if (p >= Math.max(recBuf?.duration ?? 0, midiBuf?.duration ?? 0)) { 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);
|
||||
return { levels, rate: buf.sampleRate, norm: peakNorm(levels) };
|
||||
}
|
||||
function peakNorm(levels: PeakLevel[]): number {
|
||||
const top = levels[levels.length - 1]!;
|
||||
let m = 0;
|
||||
for (let i = 0; i < top.min.length; i++) m = Math.max(m, -top.min[i]!, top.max[i]!);
|
||||
return m > 1e-4 ? 1 / m : 1;
|
||||
}
|
||||
|
||||
// ---------- edits ----------
|
||||
function setAnchors(next: Anchor[]) {
|
||||
anchors = next;
|
||||
warp = makeWarp(anchors);
|
||||
info = song ? stretchInfo(stretches(anchors, song.midiDur), song.midiTempo) : [];
|
||||
if (selected >= anchors.length) selected = -1;
|
||||
updateUi();
|
||||
draw();
|
||||
}
|
||||
|
||||
/** A finished edit: undoable, marks unsaved, and (debounced) re-renders the MIDI and re-scores the fit. */
|
||||
function commit(next: Anchor[], before: Anchor[], refreshNow = false) {
|
||||
if (keyOf(next) === keyOf(before)) { setAnchors(next); return; }
|
||||
undoStack.push(before);
|
||||
if (undoStack.length > 200) undoStack.shift();
|
||||
redoStack.length = 0;
|
||||
setAnchors(next);
|
||||
scheduleRefresh(refreshNow);
|
||||
}
|
||||
|
||||
function tryEdit(f: () => Edit, before = anchors): boolean {
|
||||
try {
|
||||
const r = f();
|
||||
selected = r.index;
|
||||
commit(r.anchors, before);
|
||||
return true;
|
||||
} catch (e) { setStatus((e as Error).message, true); return false; }
|
||||
}
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let renderAbort: AbortController | null = null;
|
||||
let fitGen = 0;
|
||||
function scheduleRefresh(now = false) {
|
||||
clearTimeout(refreshTimer);
|
||||
fitEl.classList.add("stale");
|
||||
refreshTimer = setTimeout(refresh, now ? 0 : RENDER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
if (ac.signal.aborted || song?.id !== id) return;
|
||||
midiWave = waveFrom(buf); midiKey = key;
|
||||
swapMidi(buf);
|
||||
} catch (e) {
|
||||
if (!ac.signal.aborted) setStatus(`MIDI render failed: ${(e as Error).message}`, true);
|
||||
} finally {
|
||||
if (renderAbort === ac) { rendering = false; renderAbort = null; draw(); }
|
||||
}
|
||||
}
|
||||
|
||||
function undo(redo = false) {
|
||||
const from = redo ? redoStack : undoStack, to = redo ? undoStack : redoStack;
|
||||
const prev = from.pop();
|
||||
if (!prev) return;
|
||||
to.push(anchors);
|
||||
selected = -1;
|
||||
setAnchors(prev);
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
function addAtCursor() {
|
||||
if (!song) return;
|
||||
const autosBefore = anchors.filter((a) => a.kind === "auto").length;
|
||||
if (!tryEdit(() => anchorAtCursor(anchors, cursor))) return;
|
||||
const removed = autosBefore - anchors.filter((a) => a.kind === "auto").length;
|
||||
setStatus(`Anchor added at ${formatTime(cursor, 2)}. Drag its MIDI end (lower lane) until the MIDI lines up with the recording.`
|
||||
+ (removed > 0 ? ` ${removed} automatic anchors between your neighbouring anchors were removed; "Auto-align around my anchors" fills them in again.` : ""));
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
if (selected < 0) return;
|
||||
const k = selected;
|
||||
selected = -1;
|
||||
commit(deleteAnchor(anchors, k), anchors);
|
||||
}
|
||||
|
||||
function nudge(delta: number) {
|
||||
if (selected < 0) { cursor = Math.min(dur(), Math.max(0, cursor + delta * 100)); view = follow(view, cursor, dur()); draw(); return; }
|
||||
tryEdit(() => dragAudio(anchors, selected, anchors[selected]!.audioSec + delta));
|
||||
}
|
||||
|
||||
async function runBusy(label: string, f: () => Promise<void>) {
|
||||
if (busy || !song) return;
|
||||
busy = true; updateUi(); showOverlay(label); setStatus(label);
|
||||
try { await f(); } catch (e) { setStatus((e as Error).message, true); } finally { busy = false; showOverlay(null); updateUi(); }
|
||||
}
|
||||
|
||||
const autoAlign = () => runBusy("Auto-aligning around your anchors… (a few seconds to half a minute)", async () => {
|
||||
const manual = anchors.filter((a) => a.kind === "manual");
|
||||
const j = await http<{ anchors: Anchor[]; fit: FitReport }>(songUrl("/auto"), send("POST", { anchors: manual }));
|
||||
selected = -1;
|
||||
commit(j.anchors, anchors, true);
|
||||
fit = j.fit; fitKey = keyOf(j.anchors);
|
||||
updateUi();
|
||||
setStatus(`Auto-aligned: ${j.anchors.length - manual.length} automatic anchors around your ${manual.length}. Not saved yet.`);
|
||||
});
|
||||
|
||||
const save = () => runBusy("Saving…", async () => {
|
||||
const A = anchors;
|
||||
const j = await http<{ savedAt: string; fit: FitReport }>(songUrl("/alignment"), send("PUT", { anchors: A }));
|
||||
savedKey = keyOf(A); savedAt = j.savedAt; fit = j.fit; fitKey = savedKey;
|
||||
const opt = [...songSel.options].find((o) => o.value === song!.id);
|
||||
if (opt) opt.textContent = `✓ ${song!.title}`;
|
||||
setStatus(`Saved ${A.length} anchors.`);
|
||||
});
|
||||
|
||||
function reset() {
|
||||
if (!song || !anchors.length) return;
|
||||
if (!confirm("Remove all anchors and show the raw MIDI timing?\nNothing is written until you press Save (Undo also works).")) return;
|
||||
selected = -1;
|
||||
commit([], anchors, true);
|
||||
setStatus("Reset to raw MIDI timing (not saved).");
|
||||
}
|
||||
|
||||
// ---------- song loading ----------
|
||||
async function loadSongs() {
|
||||
const list = await http<{ id: string; title: string; hasAlignment: boolean }[]>("/api/songs");
|
||||
songSel.innerHTML = "";
|
||||
for (const s of list) songSel.add(new Option(`${s.hasAlignment ? "✓ " : ""}${s.title}`, s.id));
|
||||
if (!list.length) {
|
||||
showOverlay("No songs yet. Add one on the server with:\nbun run align add <recording> <midi> --id <id> --title <title>\nthen reload this page.");
|
||||
return;
|
||||
}
|
||||
const want = decodeURIComponent(location.hash.slice(1));
|
||||
await openSong(list.some((s) => s.id === want) ? want : list[0]!.id);
|
||||
}
|
||||
|
||||
async function openSong(id: string) {
|
||||
if (song && isDirty() && !confirm(`"${song.title}" has unsaved changes. Discard them?`)) { songSel.value = song.id; return; }
|
||||
const gen = ++loadGen;
|
||||
stop();
|
||||
renderAbort?.abort();
|
||||
clearTimeout(refreshTimer);
|
||||
songSel.value = id;
|
||||
history.replaceState(null, "", `#${encodeURIComponent(id)}`);
|
||||
song = null; recBuf = midiBuf = null; recWave = midiWave = null; midiKey = ""; fit = null; undoStack.length = redoStack.length = 0;
|
||||
selected = hover = -1;
|
||||
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("");
|
||||
try {
|
||||
const s = await http<SongInfo>(`/api/song/${encodeURIComponent(id)}`);
|
||||
if (gen !== loadGen) return;
|
||||
song = s;
|
||||
savedKey = s.source === "saved" ? keyOf(makeWarp(s.anchors).anchors) : null;
|
||||
savedAt = s.savedAt;
|
||||
fit = s.fit;
|
||||
cursor = 0;
|
||||
view = fitView(s.audioDur, view.width);
|
||||
recWave = { levels: pyramidFromPeaks(s.recordingPeaks.min, s.recordingPeaks.max), rate: 1 / s.recordingPeaks.bucketSec, norm: 1 };
|
||||
recWave.norm = peakNorm(recWave.levels);
|
||||
setAnchors(makeWarp(s.anchors).anchors);
|
||||
fitKey = keyOf(anchors);
|
||||
const roles = s.tracks.map((t) => `${t.name}: ${t.role ?? "unmapped"}`).join("\n");
|
||||
const bpms = [...new Set(s.midiTempo.map((t) => t.bpm.toFixed(1)))];
|
||||
songInfo.textContent = `${formatTime(s.audioDur, 0)} · MIDI ${bpms.length > 1 ? `${bpms.length} tempos` : `${bpms[0]} bpm`} · ${s.tracks.length} tracks`;
|
||||
songInfo.title = roles;
|
||||
showOverlay(null);
|
||||
setStatus(s.source === "auto" ? "This is the automatic alignment (not saved yet). Play a few spots, fix anchors where it drifts, then Save." : `Loaded the saved alignment (${anchors.length} anchors).`);
|
||||
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(); })
|
||||
.catch((e) => setStatus(`Could not load the recording audio: ${(e as Error).message}`, true));
|
||||
} catch (e) {
|
||||
if (gen !== loadGen) return;
|
||||
showOverlay(`Could not open the song: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- UI state ----------
|
||||
function updateUi() {
|
||||
const has = song !== null;
|
||||
btn.add.disabled = !has || busy;
|
||||
btn.del.disabled = !has || busy || selected < 0;
|
||||
btn.undo.disabled = busy || !undoStack.length;
|
||||
btn.auto.disabled = btn.save.disabled = btn.reset.disabled = !has || busy;
|
||||
btn.play.disabled = !has;
|
||||
if (!has) { saveState.textContent = ""; fitEl.textContent = "Fit: –"; countsEl.textContent = ""; return; }
|
||||
const dirty = isDirty();
|
||||
saveState.className = `savestate ${dirty ? "dirty" : "clean"}`;
|
||||
saveState.textContent = dirty ? (savedKey === null && !undoStack.length ? "● Not saved yet (automatic alignment)" : "● Unsaved changes")
|
||||
: `✓ Saved${savedAt ? ` ${new Date(savedAt).toLocaleString()}` : ""}`;
|
||||
btn.save.textContent = dirty ? "Save alignment" : "Saved";
|
||||
const stale = fitKey !== keyOf(anchors);
|
||||
fitEl.classList.toggle("stale", stale);
|
||||
if (fit) {
|
||||
fitEl.textContent = `Fit${stale ? " (updating…)" : ""}: ${fitSummary(fit)}`;
|
||||
fitEl.classList.toggle("bad", (fit.matchRate ?? 1) < 0.5 || fit.windows.some((w) => w.flagged));
|
||||
}
|
||||
const man = anchors.filter((a) => a.kind === "manual").length;
|
||||
countsEl.textContent = `· anchors: ${man} yours, ${anchors.length - man} automatic`;
|
||||
}
|
||||
|
||||
// ---------- drawing ----------
|
||||
let cssW = 800;
|
||||
function resize() {
|
||||
cssW = wrap.clientWidth;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(HEIGHT * dpr);
|
||||
canvas.style.height = `${HEIGHT}px`;
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
view = { ...view, width: Math.max(50, cssW - GUTTER) };
|
||||
if (song) view = clampView(view, dur());
|
||||
draw();
|
||||
}
|
||||
|
||||
let drawQueued = false;
|
||||
function draw() {
|
||||
if (drawQueued) return;
|
||||
drawQueued = true;
|
||||
requestAnimationFrame(() => { drawQueued = false; paint(); });
|
||||
}
|
||||
|
||||
const X = (t: number) => GUTTER + timeToX(view, t);
|
||||
|
||||
function paintWave(w: Wave | null, y: number, color: string, alpha: number) {
|
||||
g.fillStyle = C.lane; g.fillRect(GUTTER, y, view.width, LANE_H);
|
||||
if (!w) return;
|
||||
const { min, max } = columnPeaks(w.levels, w.rate, view);
|
||||
const mid = y + LANE_H / 2, half = (LANE_H / 2 - 4) * w.norm;
|
||||
g.globalAlpha = alpha; g.fillStyle = color;
|
||||
for (let c = 0; c < min.length; c++) {
|
||||
if (min[c] === 0 && max[c] === 0) continue;
|
||||
const top = mid - max[c]! * half, bot = mid - min[c]! * half;
|
||||
g.fillRect(GUTTER + c, top, 1, Math.max(1, bot - top));
|
||||
}
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function paint() {
|
||||
const W = cssW;
|
||||
g.clearRect(0, 0, W, HEIGHT);
|
||||
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;
|
||||
const t0 = Math.floor(xToTime(view, 0) / step) * step, t1 = xToTime(view, view.width);
|
||||
g.strokeStyle = "#ccc"; g.fillStyle = C.muted; g.textAlign = "left";
|
||||
for (let t = t0; t <= t1; t += step) {
|
||||
const x = Math.round(X(t)) + 0.5;
|
||||
if (x < GUTTER) continue;
|
||||
g.beginPath(); g.moveTo(x, RULER_H - 7); g.lineTo(x, RULER_H); g.stroke();
|
||||
g.fillText(formatTime(t, dec), x + 3, RULER_H / 2 - 1);
|
||||
}
|
||||
g.beginPath(); g.moveTo(GUTTER, RULER_H + 0.5); g.lineTo(W, RULER_H + 0.5); g.stroke();
|
||||
if (!song) return;
|
||||
|
||||
// lanes and flagged windows
|
||||
paintWave(recWave, REC_Y, C.rec, 0.85);
|
||||
paintWave(midiWave, MIDI_Y, C.midi, rendering || midiKey !== keyOf(anchors) ? 0.35 : 0.85);
|
||||
const outside = (a: number, b: number) => { const x0 = Math.max(GUTTER, X(a)), x1 = Math.min(W, X(b)); if (x1 > x0) { g.fillRect(x0, REC_Y, x1 - x0, LANE_H); g.fillRect(x0, MIDI_Y, x1 - x0, LANE_H); } };
|
||||
g.fillStyle = "rgba(0,0,0,.05)"; outside(-1e6, 0); outside(dur(), 1e6);
|
||||
if (fit) {
|
||||
g.fillStyle = C.flag;
|
||||
for (const w of fit.windows) if (w.flagged) outside(w.startSec, w.startSec + WINDOW_SEC);
|
||||
}
|
||||
if (rendering || midiKey !== keyOf(anchors)) { g.fillStyle = C.muted; g.textAlign = "right"; g.fillText(midiWave ? "re-rendering MIDI…" : "rendering MIDI…", W - 8, MIDI_Y + 10); }
|
||||
|
||||
// stretch labels (MIDI bpm -> effective bpm), between consecutive anchors
|
||||
g.textAlign = "center";
|
||||
for (const s of info) {
|
||||
const xa = X(warp.toAudio(s.fromMidi)), xb = X(warp.toAudio(s.toMidi));
|
||||
if (xb < GUTTER || xa > W) continue;
|
||||
const label = stretchLabel(s), tw = g.measureText(label).width;
|
||||
const cx = (Math.max(xa, GUTTER) + Math.min(xb, W)) / 2, room = Math.min(xb, W) - Math.max(xa, GUTTER);
|
||||
if (s.warn) { g.fillStyle = "rgba(217,83,79,.5)"; g.fillRect(Math.max(xa, GUTTER), STRETCH_Y + 2, Math.min(xb, W) - Math.max(xa, GUTTER), 2); }
|
||||
g.fillStyle = s.warn ? C.warn : "#555";
|
||||
if (tw + 10 <= room) g.fillText(label, cx, STRETCH_Y + 14);
|
||||
else if (s.warn && room >= 12) g.fillText("⚠", cx, STRETCH_Y + 14);
|
||||
}
|
||||
|
||||
// anchors
|
||||
let lastLabelEnd = -Infinity;
|
||||
anchors.forEach((a, k) => {
|
||||
const x = Math.round(X(a.audioSec)) + 0.5;
|
||||
if (x < GUTTER - 2 || x > W + 2) return;
|
||||
const manual = a.kind === "manual", sel = k === selected, hov = k === hover;
|
||||
g.strokeStyle = sel ? "#16804c" : manual || hov ? C.anchor : C.anchorAuto;
|
||||
g.lineWidth = sel ? 3 : manual ? 2 : hov ? 2 : 1;
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
g.fillStyle = manual || sel ? "#1e8a57" : "#6fbf97"; g.textAlign = "center";
|
||||
g.fillText(label, x, RULER_H + LABEL_H / 2);
|
||||
lastLabelEnd = x + tw / 2;
|
||||
}
|
||||
});
|
||||
g.lineWidth = 1;
|
||||
|
||||
// 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 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;
|
||||
g.fillStyle = "rgba(34,34,34,.85)"; g.fillRect(x, y, tw, 18);
|
||||
g.fillStyle = "#fff"; g.textAlign = "left"; g.fillText(txt, x + 6, y + 9);
|
||||
}
|
||||
|
||||
// cursor and playhead
|
||||
const line = (t: number, color: string, w: number) => {
|
||||
const x = Math.round(X(t)) + 0.5;
|
||||
if (x < GUTTER || x > W) return;
|
||||
g.strokeStyle = color; g.lineWidth = w;
|
||||
g.beginPath(); g.moveTo(x, 0); g.lineTo(x, MIDI_Y + LANE_H); g.stroke();
|
||||
g.lineWidth = 1;
|
||||
};
|
||||
line(cursor, C.ink, 1);
|
||||
{ const x = X(cursor); if (x >= GUTTER) { g.fillStyle = C.ink; g.beginPath(); g.moveTo(x - 5, 0); g.lineTo(x + 5, 0); g.lineTo(x, 7); g.fill(); } }
|
||||
if (playing) line(playPos(), C.play, 2);
|
||||
|
||||
// gutter with lane tags (drawn last so it masks anything scrolled under it)
|
||||
g.fillStyle = "#fff"; g.fillRect(0, 0, GUTTER, HEIGHT);
|
||||
g.textAlign = "left"; g.fillStyle = "#555"; g.font = "12px system-ui, sans-serif";
|
||||
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("tempo", 10, STRETCH_Y + 14);
|
||||
g.fillText(formatTime(cursor, 2), 10, RULER_H / 2);
|
||||
}
|
||||
|
||||
function spacingOk(k: number): boolean {
|
||||
const px = (i: number) => (anchors[i] ? Math.abs(timeToX(view, anchors[i]!.audioSec) - timeToX(view, anchors[k]!.audioSec)) : Infinity);
|
||||
return Math.min(px(k - 1), px(k + 1)) >= 70;
|
||||
}
|
||||
|
||||
// ---------- pointer ----------
|
||||
type Drag =
|
||||
| { kind: "pan"; x0: number; lastX: number; moved: boolean }
|
||||
| { 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);
|
||||
|
||||
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)); return; }
|
||||
const hit = lane ? hitAnchor(anchors, view, lx, HIT_PX) : -1;
|
||||
if (hit >= 0 && lane) {
|
||||
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 };
|
||||
updateUi(); draw();
|
||||
return;
|
||||
}
|
||||
drag = { kind: "pan", x0: x, lastX: x, moved: false };
|
||||
canvas.style.cursor = "grabbing";
|
||||
});
|
||||
|
||||
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";
|
||||
return;
|
||||
}
|
||||
if (drag.kind === "cursor") { setCursor(xToTime(view, lx)); return; }
|
||||
if (drag.kind === "pan") {
|
||||
if (Math.abs(x - drag.x0) > DRAG_PX) drag.moved = true;
|
||||
if (drag.moved) { view = scrollBy(view, x - drag.lastX, dur()); drag.lastX = x; draw(); }
|
||||
return;
|
||||
}
|
||||
if (!drag.moved && Math.abs(x - drag.x0) <= DRAG_PX) return;
|
||||
drag.moved = true;
|
||||
hover = -1;
|
||||
const t = xToTime(view, lx);
|
||||
try {
|
||||
const r = drag.kind === "rec" ? dragAudio(drag.start, drag.index, t + drag.offset) : dragMidi(drag.start, drag.index, drag.grabMidi, t);
|
||||
selected = r.index;
|
||||
setAnchors(r.anchors); // live preview; committed on release
|
||||
} catch (err) { setStatus((err as Error).message, true); }
|
||||
});
|
||||
|
||||
function endDrag(e: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const d = drag;
|
||||
drag = null;
|
||||
canvas.style.cursor = "default";
|
||||
const { x } = local(e);
|
||||
if (d.kind === "pan" && !d.moved && x >= GUTTER) { selected = -1; setCursor(xToTime(view, x - GUTTER)); updateUi(); }
|
||||
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
|
||||
commit(now, d.start);
|
||||
const removedAutos = d.start.filter((a) => a.kind === "auto").length - now.filter((a) => a.kind === "auto").length;
|
||||
if (removedAutos > 0) setStatus(`Moved the anchor; ${removedAutos} automatic anchors next to it were removed. Use "Auto-align around my anchors" to fill them in again.`);
|
||||
}
|
||||
}
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
canvas.addEventListener("pointerleave", () => { if (!drag && hover >= 0) { hover = -1; draw(); } });
|
||||
|
||||
function setCursor(t: number) {
|
||||
cursor = Math.min(dur(), Math.max(0, t));
|
||||
if (playing) void play(); // jump playback to the new cursor
|
||||
draw();
|
||||
}
|
||||
|
||||
canvas.addEventListener("wheel", (e) => {
|
||||
if (!song) return;
|
||||
e.preventDefault();
|
||||
const scale = e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 400 : 1;
|
||||
const dx = e.deltaX * scale, dy = e.deltaY * scale;
|
||||
const { x } = local(e);
|
||||
if (e.shiftKey || Math.abs(dx) > Math.abs(dy)) view = scrollBy(view, -(e.shiftKey ? dy || dx : dx), dur());
|
||||
else view = zoomAt(view, Math.max(0, x - GUTTER), Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0015)), dur());
|
||||
draw();
|
||||
}, { passive: false });
|
||||
|
||||
// ---------- keyboard & buttons ----------
|
||||
window.addEventListener("keydown", (e) => {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "SELECT" || tag === "INPUT" || tag === "TEXTAREA" || !song) return;
|
||||
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; }
|
||||
if (mod && e.key.toLowerCase() === "s") { e.preventDefault(); void save(); return; }
|
||||
if (mod || e.altKey || busy) return;
|
||||
switch (e.key) {
|
||||
case " ": e.preventDefault(); playing ? stop() : void play(); 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 "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.add.addEventListener("click", addAtCursor);
|
||||
btn.del.addEventListener("click", deleteSelected);
|
||||
btn.undo.addEventListener("click", () => undo());
|
||||
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(); });
|
||||
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();
|
||||
resize();
|
||||
updateUi();
|
||||
loadSongs().catch((e) => showOverlay(`Could not load the song list: ${(e as Error).message}`));
|
||||
@@ -0,0 +1,65 @@
|
||||
// Anchor editing for the alignment page: drags, hit-testing, add-at-cursor. Pure (no DOM); wraps src/align/warp.ts.
|
||||
import { addManual, makeWarp, moveAnchor, type Anchor } from "../warp.ts";
|
||||
import { timeToX, type View } from "./timeline.ts";
|
||||
|
||||
/** Closest a dragged anchor may get to a manual neighbour (seconds, both axes). */
|
||||
export const MIN_GAP_SEC = 0.05;
|
||||
|
||||
export interface Edit { anchors: Anchor[]; index: number }
|
||||
|
||||
/** Nearest manual anchors left/right of index `i` (autos are dropped by the edit, so only manuals constrain it). */
|
||||
function manualNeighbours(anchors: Anchor[], i: number): { left?: Anchor; right?: Anchor } {
|
||||
let left: Anchor | undefined, right: Anchor | undefined;
|
||||
for (let k = i - 1; k >= 0 && !left; k--) if (anchors[k]!.kind === "manual") left = anchors[k];
|
||||
for (let k = i + 1; k < anchors.length && !right; k++) if (anchors[k]!.kind === "manual") right = anchors[k];
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x));
|
||||
|
||||
function moved(anchors: Anchor[], i: number, midiSec: number, audioSec: number): Edit {
|
||||
const out = moveAnchor(anchors, i, { midiSec, audioSec });
|
||||
return { anchors: out, index: out.findIndex((a) => a.kind === "manual" && a.midiSec === midiSec && a.audioSec === audioSec) };
|
||||
}
|
||||
|
||||
/** Recording end: anchor `i` of `start` (the list at drag start) to `audioSec`, MIDI time unchanged; clamped. */
|
||||
export function dragAudio(start: Anchor[], i: number, audioSec: number): Edit {
|
||||
const A = makeWarp(start).anchors;
|
||||
const { left, right } = manualNeighbours(A, i);
|
||||
const a = clamp(audioSec, left ? left.audioSec + MIN_GAP_SEC : -Infinity, right ? right.audioSec - MIN_GAP_SEC : Infinity);
|
||||
return moved(A, i, A[i]!.midiSec, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDI end: the anchor keeps its recording time; its MIDI time is chosen so that the MIDI moment `grabMidi` (what was
|
||||
* under the pointer on the warped MIDI lane at drag start) is drawn at `toAudio` (where the pointer is now). The
|
||||
* grabbed point therefore follows the mouse. Solved by bisection (the drawn position falls as the anchor's MIDI time
|
||||
* rises); clamped between the manual neighbours' MIDI times.
|
||||
*/
|
||||
export function dragMidi(start: Anchor[], i: number, grabMidi: number, toAudio: number): Edit {
|
||||
const A = makeWarp(start).anchors;
|
||||
const { left, right } = manualNeighbours(A, i);
|
||||
const a = A[i]!.audioSec, SPAN = 3600;
|
||||
const lo0 = left ? left.midiSec + MIN_GAP_SEC : A[i]!.midiSec - SPAN;
|
||||
const hi0 = right ? right.midiSec - MIN_GAP_SEC : A[i]!.midiSec + SPAN;
|
||||
const drawnAt = (m: number) => makeWarp(moveAnchor(A, i, { midiSec: m, audioSec: a })).toAudio(grabMidi);
|
||||
let lo = lo0, hi = hi0;
|
||||
if (drawnAt(lo) <= toAudio) hi = lo;
|
||||
else if (drawnAt(hi) >= toAudio) lo = hi;
|
||||
else for (let k = 0; k < 60 && hi - lo > 1e-9; k++) { const mid = (lo + hi) / 2; if (drawnAt(mid) > toAudio) lo = mid; else hi = mid; }
|
||||
return moved(A, i, (lo + hi) / 2, a);
|
||||
}
|
||||
|
||||
/** Index of the anchor line nearest pixel `x` within `tolPx`, or -1. */
|
||||
export function hitAnchor(anchors: Anchor[], v: View, x: number, tolPx: number): number {
|
||||
let best = -1, bestD = tolPx;
|
||||
anchors.forEach((a, k) => { const d = Math.abs(timeToX(v, a.audioSec) - x); if (d <= bestD) { best = k; bestD = d; } });
|
||||
return best;
|
||||
}
|
||||
|
||||
/** New manual anchor at recording time `cursorSec`, its MIDI time from the current warp (so nothing jumps there). */
|
||||
export function anchorAtCursor(anchors: Anchor[], cursorSec: number): Edit {
|
||||
const midiSec = makeWarp(anchors).toMidi(cursorSec);
|
||||
const out = addManual(anchors, { midiSec, audioSec: cursorSec });
|
||||
return { anchors: out, index: out.findIndex((a) => a.audioSec === cursorSec && a.kind === "manual") };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>STSheets · MIDI alignment</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<h1>MIDI alignment</h1>
|
||||
<label class="picker">Song <select id="song" aria-label="Song"></select></label>
|
||||
<span id="songinfo" class="muted"></span>
|
||||
<span class="grow"></span>
|
||||
<span id="savestate" class="savestate"></span>
|
||||
</header>
|
||||
|
||||
<section class="fitbar" aria-live="polite">
|
||||
<span id="fit" class="fit">Fit: –</span>
|
||||
<span id="counts" class="muted"></span>
|
||||
</section>
|
||||
|
||||
<div id="wrap" class="wrap">
|
||||
<canvas id="tl" aria-label="Recording and MIDI timeline"></canvas>
|
||||
<div id="overlay" class="overlay" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="row controls">
|
||||
<button id="play" title="Space">▶ Play from cursor</button>
|
||||
<div class="mix" role="radiogroup" aria-label="Mix">
|
||||
<span class="mixlabel">Mix:</span>
|
||||
<button data-mix="both" class="on" title="1">both</button>
|
||||
<button data-mix="rec" title="2">rec only</button>
|
||||
<button data-mix="midi" title="3">MIDI only</button>
|
||||
<button data-mix="split" title="4">rec L / MIDI R</button>
|
||||
</div>
|
||||
<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>
|
||||
<span class="grow"></span>
|
||||
<button id="auto">Auto-align around my anchors</button>
|
||||
<button id="save" class="primary" title="Ctrl+S">Save alignment</button>
|
||||
<button id="reset" class="quiet">Reset to raw MIDI</button>
|
||||
</div>
|
||||
|
||||
<p id="status" class="status" role="status"></p>
|
||||
|
||||
<details class="help">
|
||||
<summary>How it works · keyboard</summary>
|
||||
<p>Green lines are <b>anchors</b>: "this MIDI moment = this recording moment". Between two anchors the MIDI's tempo is scaled so it fits;
|
||||
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 between your neighbouring anchors; <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>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<script type="module" src="./app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
--rec: #5b8def; --midi: #e2873b; --anchor: #2bb673; --warn: #d9534f; --ink: #222; --muted: #777;
|
||||
--bg: #fafafa; --panel: #fff; --line: #ddd;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--ink); background: var(--bg);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 12px 16px 24px; }
|
||||
h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
||||
.bar, .row, .fitbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.bar { padding-bottom: 8px; border-bottom: 1px solid var(--line); }
|
||||
.grow { flex: 1; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.picker { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
||||
select { font: inherit; font-size: 14px; padding: 3px 6px; color: var(--ink); max-width: 40ch; }
|
||||
.savestate { font-size: 13px; padding: 2px 8px; border-radius: 10px; }
|
||||
.savestate.dirty { background: #fff3e0; color: #b45309; }
|
||||
.savestate.clean { color: #2f7d4f; }
|
||||
.fitbar { margin: 8px 0 6px; font-size: 13px; min-height: 20px; }
|
||||
.fit.bad { color: var(--warn); }
|
||||
.fit.stale { opacity: .55; }
|
||||
.wrap { position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; user-select: none; }
|
||||
canvas { display: block; width: 100%; touch-action: none; }
|
||||
.overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,.75); font-size: 14px; color: #333; text-align: center; padding: 20px; }
|
||||
.overlay[hidden] { display: none; }
|
||||
.controls { margin-top: 10px; }
|
||||
button { font: inherit; font-size: 13px; padding: 5px 10px; border: 1px solid #c8c8c8; background: #fff; border-radius: 5px; cursor: pointer; color: var(--ink); }
|
||||
button:hover:not(:disabled) { background: #f1f1f1; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
button.primary { background: var(--anchor); border-color: #22975f; color: #fff; }
|
||||
button.primary:hover:not(:disabled) { background: #26a467; }
|
||||
button.quiet { color: #a33; }
|
||||
button:focus-visible { outline: 2px solid var(--rec); outline-offset: 1px; }
|
||||
.mix { display: inline-flex; align-items: center; border: 1px solid #c8c8c8; border-radius: 5px; overflow: hidden; background: #fff; }
|
||||
.mixlabel { font-size: 13px; padding: 0 6px 0 8px; color: var(--muted); }
|
||||
.mix button { border: 0; border-left: 1px solid #e3e3e3; border-radius: 0; }
|
||||
.mix button.on { background: #2b2b2b; color: #fff; }
|
||||
.status { min-height: 18px; font-size: 13px; margin: 8px 0 0; color: #444; }
|
||||
.status.error { color: var(--warn); }
|
||||
.help { margin-top: 10px; font-size: 13px; color: #555; max-width: 110ch; }
|
||||
.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; }
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Alignment web server: serves the page (HTML import) and the alignment API behind a random access token.
|
||||
// The page bundle lives on a loopback-only inner server; the public server checks the token and proxies to it,
|
||||
// because Bun serves HTML-import assets directly from `routes`, bypassing any auth in `fetch`.
|
||||
import { join } from "node:path";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import index from "./page/index.html";
|
||||
import type { Role } from "../core/types.ts";
|
||||
import { audioOgg, loadSong, readAlignment, readManifest, writeAlignment, type LoadedSong } from "../ref/manifest.ts";
|
||||
import { trackKeys } from "./roles.ts";
|
||||
import { makeWarp, stretches, type Anchor } from "./warp.ts";
|
||||
import { autoAlign, fitFromOnsets, recordingOnsets } from "./auto.ts";
|
||||
import { renderMidi } from "./render.ts";
|
||||
import { peaks } from "./peaks.ts";
|
||||
import { stretchInfo } from "./tempo.ts";
|
||||
import type { FitReport } from "./fit.ts";
|
||||
import { encodeWav, toMono } from "../audio/wav.ts";
|
||||
|
||||
export const SF2_PATH = join(import.meta.dir, "../../node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2");
|
||||
const COOKIE = "stsheets_align";
|
||||
const PEAKS_PER_SEC = 2000 / 60;
|
||||
const PEAKS_VERSION = 1;
|
||||
const RENDER_SR = 22050;
|
||||
const RENDER_CACHE = 6;
|
||||
const SONG_CACHE = 2;
|
||||
|
||||
export interface ServeOptions { root?: string; host?: string; port?: number; urlHost?: string; quiet?: boolean; sf2Path?: string }
|
||||
export interface AlignServer { url: string; origin: string; token: string; port: number; stop(): Promise<void> }
|
||||
|
||||
interface SongState extends LoadedSong {
|
||||
roles: Record<string, Role | null>;
|
||||
onsets: number[];
|
||||
peaks: { bucketSec: number; min: number[]; max: number[] };
|
||||
auto?: { anchors: Anchor[]; fit: FitReport }; // first automatic pass, reused while no alignment.json exists
|
||||
}
|
||||
|
||||
class HttpError extends Error { constructor(readonly status: number, msg: string) { super(msg); } }
|
||||
|
||||
/** Small insertion-ordered LRU. */
|
||||
class Lru<V> {
|
||||
private m = new Map<string, V>();
|
||||
constructor(private cap: number) {}
|
||||
get(k: string): V | undefined { const v = this.m.get(k); if (v !== undefined) { this.m.delete(k); this.m.set(k, v); } return v; }
|
||||
set(k: string, v: V) { this.m.delete(k); this.m.set(k, v); while (this.m.size > this.cap) this.m.delete(this.m.keys().next().value!); }
|
||||
delete(k: string) { this.m.delete(k); }
|
||||
}
|
||||
|
||||
/** Validates `{ anchors }` from a request body; throws HttpError(400). */
|
||||
function parseAnchors(body: unknown): Anchor[] {
|
||||
const list = (body as { anchors?: unknown } | null)?.anchors;
|
||||
if (!Array.isArray(list)) throw new HttpError(400, "body must be { anchors: [...] }");
|
||||
const out = list.map((a: any): Anchor => {
|
||||
if (!a || typeof a.midiSec !== "number" || typeof a.audioSec !== "number" || (a.kind !== "manual" && a.kind !== "auto")) {
|
||||
throw new HttpError(400, "each anchor needs numeric midiSec, audioSec and kind 'manual' | 'auto'");
|
||||
}
|
||||
return { midiSec: a.midiSec, audioSec: a.audioSec, kind: a.kind };
|
||||
});
|
||||
try { return makeWarp(out).anchors; } catch (e) { throw new HttpError(400, (e as Error).message); }
|
||||
}
|
||||
|
||||
async function readJson(req: Request): Promise<unknown> {
|
||||
try { return await req.json(); } catch { throw new HttpError(400, "invalid JSON body"); }
|
||||
}
|
||||
|
||||
function sameToken(a: string, b: string): boolean {
|
||||
const x = Buffer.from(a), y = Buffer.from(b);
|
||||
return x.length === y.length && timingSafeEqual(x, y);
|
||||
}
|
||||
|
||||
function cookieValue(req: Request, name: string): string | null {
|
||||
for (const part of (req.headers.get("cookie") ?? "").split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function startServer(opts: ServeOptions = {}): Promise<AlignServer> {
|
||||
const root = opts.root ?? "reference";
|
||||
const host = opts.host ?? "127.0.0.1";
|
||||
const log = (msg: string) => { if (!opts.quiet) console.log(msg); };
|
||||
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(18))).toString("base64url");
|
||||
const sf2 = new Uint8Array(await Bun.file(opts.sf2Path ?? SF2_PATH).arrayBuffer());
|
||||
|
||||
const songs = new Lru<Promise<SongState>>(SONG_CACHE);
|
||||
const renders = new Lru<Uint8Array<ArrayBuffer>>(RENDER_CACHE);
|
||||
|
||||
async function entryExists(id: string): Promise<boolean> {
|
||||
return (await readManifest(root)).some((s) => s.id === id);
|
||||
}
|
||||
|
||||
async function buildState(id: string): Promise<SongState> {
|
||||
const t0 = performance.now();
|
||||
const s = await loadSong(id, root);
|
||||
const keys = trackKeys(s.smf.tracks);
|
||||
const roles = Object.fromEntries(keys.map((k) => [k, s.entry.roles[k] ?? null]));
|
||||
const onsets = recordingOnsets(s.audio);
|
||||
const peaksFile = Bun.file(join(root, id, "peaks.json"));
|
||||
const recWav = Bun.file(join(root, id, "recording.wav"));
|
||||
let pk: SongState["peaks"] | null = null;
|
||||
if (await peaksFile.exists()) {
|
||||
const j = await peaksFile.json();
|
||||
if (j.version === PEAKS_VERSION && peaksFile.lastModified >= recWav.lastModified) pk = j.peaks;
|
||||
}
|
||||
if (!pk) {
|
||||
const n = Math.max(1, Math.round(s.audioDur * PEAKS_PER_SEC));
|
||||
const p = peaks(toMono(s.audio), n);
|
||||
const r = (v: number) => Math.round(v * 1000) / 1000;
|
||||
pk = { bucketSec: s.audioDur / n, min: Array.from(p.min, r), max: Array.from(p.max, r) };
|
||||
await Bun.write(peaksFile, JSON.stringify({ version: PEAKS_VERSION, peaks: pk }));
|
||||
}
|
||||
log(`loaded ${id} (${s.audioDur.toFixed(1)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
|
||||
return { ...s, roles, onsets, peaks: pk };
|
||||
}
|
||||
|
||||
function song(id: string): Promise<SongState> {
|
||||
let p = songs.get(id);
|
||||
if (!p) {
|
||||
p = buildState(id);
|
||||
p.catch(() => songs.delete(id));
|
||||
songs.set(id, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const fitOf = (s: SongState, anchors: Anchor[]) => fitFromOnsets(s.onsets, s.smf, s.roles, anchors, s.audioDur);
|
||||
const stretchesOf = (s: SongState, anchors: Anchor[]) => stretchInfo(stretches(anchors, s.smf.durationSec), s.smf.tempoMap);
|
||||
const state = (s: SongState, anchors: Anchor[], fit = fitOf(s, anchors)) => ({ anchors, stretches: stretchesOf(s, anchors), fit });
|
||||
|
||||
function runAuto(s: SongState, manual: Anchor[]) {
|
||||
const t0 = performance.now();
|
||||
const r = autoAlign(s.audio, s.smf, s.roles, manual, sf2);
|
||||
log(`auto-aligned ${s.entry.id}: ${r.anchors.length} anchors (${manual.length} manual) in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function api(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname, method = req.method;
|
||||
if (path === "/api/songs" && method === "GET") {
|
||||
const all = await readManifest(root);
|
||||
return Response.json(await Promise.all(all.map(async (e) => ({ id: e.id, title: e.title, hasAlignment: (await readAlignment(e.id, root)) !== null }))));
|
||||
}
|
||||
const m = path.match(/^\/api\/song\/([^/]+)(?:\/(audio|render|fit|auto|alignment|reset))?$/);
|
||||
if (!m) throw new HttpError(404, "not found");
|
||||
let id: string;
|
||||
try { id = decodeURIComponent(m[1]!); } catch { throw new HttpError(404, "not found"); }
|
||||
if (!(await entryExists(id))) throw new HttpError(404, `unknown song: ${id}`);
|
||||
const action = m[2] ?? "", route = `${method} ${action}`;
|
||||
|
||||
if (route === "GET ") {
|
||||
const s = await song(id);
|
||||
const saved = await readAlignment(id, root);
|
||||
let anchors: Anchor[], fit: FitReport | undefined;
|
||||
if (saved) anchors = makeWarp(saved.anchors).anchors;
|
||||
else { s.auto ??= runAuto(s, []); anchors = s.auto.anchors; fit = s.auto.fit; }
|
||||
return Response.json({
|
||||
id, title: s.entry.title, audioDur: s.audioDur, midiDur: s.smf.durationSec,
|
||||
tracks: trackKeys(s.smf.tracks).map((name) => ({ name, role: s.roles[name] ?? null })),
|
||||
recordingPeaks: s.peaks, ...state(s, anchors, fit), midiTempo: s.smf.tempoMap,
|
||||
source: saved ? "saved" : "auto", savedAt: saved?.savedAt ?? null,
|
||||
});
|
||||
}
|
||||
if (route === "GET audio") {
|
||||
const e = (await readManifest(root)).find((x) => x.id === id)!;
|
||||
return new Response(Bun.file(await audioOgg(e, root)), { headers: { "content-type": "audio/ogg", "cache-control": "private, max-age=3600" } });
|
||||
}
|
||||
if (route === "POST render") {
|
||||
const anchors = parseAnchors(await readJson(req));
|
||||
const s = await song(id);
|
||||
const hash = Bun.hash(JSON.stringify(anchors)).toString(16);
|
||||
const key = `${id}:${hash}`;
|
||||
let wav = renders.get(key);
|
||||
const hit = wav !== undefined;
|
||||
if (!wav) {
|
||||
const t0 = performance.now();
|
||||
const warp = makeWarp(anchors);
|
||||
const dur = Math.max(s.audioDur, Math.min(warp.toAudio(s.smf.durationSec) + 1, s.audioDur + 60));
|
||||
wav = encodeWav(renderMidi(s.smf.tracks, warp, sf2, { sampleRate: RENDER_SR, mono: true, durationSec: dur }));
|
||||
renders.set(key, wav);
|
||||
log(`rendered ${id} (${anchors.length} anchors) in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
|
||||
}
|
||||
return new Response(wav, { headers: { "content-type": "audio/wav", "x-anchors-hash": hash, "x-cache": hit ? "hit" : "miss" } });
|
||||
}
|
||||
if (route === "POST fit") {
|
||||
const anchors = parseAnchors(await readJson(req));
|
||||
return Response.json(state(await song(id), anchors));
|
||||
}
|
||||
if (route === "POST auto") {
|
||||
const manual = parseAnchors(await readJson(req)).filter((a) => a.kind === "manual");
|
||||
const s = await song(id);
|
||||
const r = runAuto(s, manual);
|
||||
return Response.json(state(s, r.anchors, r.fit));
|
||||
}
|
||||
if (route === "POST reset") return Response.json(state(await song(id), []));
|
||||
if (route === "PUT alignment") {
|
||||
const anchors = parseAnchors(await readJson(req));
|
||||
const s = await song(id);
|
||||
const file = { anchors, savedAt: new Date().toISOString(), fit: fitOf(s, anchors) };
|
||||
await writeAlignment(id, file, root);
|
||||
log(`saved ${id}: ${anchors.length} anchors`);
|
||||
return Response.json({ savedAt: file.savedAt, fit: file.fit });
|
||||
}
|
||||
throw new HttpError(405, "method not allowed");
|
||||
}
|
||||
|
||||
// Inner server: Bun bundles and serves the page and its assets; reachable only from this process via loopback.
|
||||
const inner = Bun.serve({
|
||||
hostname: "127.0.0.1", port: 0, development: false,
|
||||
routes: { "/": index },
|
||||
fetch: () => new Response("not found", { status: 404 }),
|
||||
});
|
||||
|
||||
async function proxyPage(req: Request, url: URL): Promise<Response> {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return new Response("method not allowed", { status: 405 });
|
||||
const r = await fetch(new URL(url.pathname, inner.url), { method: req.method });
|
||||
const headers = new Headers(r.headers);
|
||||
for (const h of ["content-encoding", "content-length", "transfer-encoding", "connection"]) headers.delete(h);
|
||||
return new Response(r.body, { status: r.status, headers });
|
||||
}
|
||||
|
||||
const outer = Bun.serve({
|
||||
hostname: host, port: opts.port ?? 5180,
|
||||
idleTimeout: 255, // first open of a song transcodes and auto-aligns; renders take seconds
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const key = url.searchParams.get("key");
|
||||
const viaKey = key !== null && sameToken(key, token);
|
||||
const viaCookie = !viaKey && sameToken(cookieValue(req, COOKIE) ?? "", token);
|
||||
if (!viaKey && !viaCookie) {
|
||||
return new Response("Unauthorized: open the URL printed by `bun run align` (it contains ?key=...).\n", { status: 401, headers: { "content-type": "text/plain; charset=utf-8" } });
|
||||
}
|
||||
const setCookie = `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`;
|
||||
if (viaKey && req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
||||
url.searchParams.delete("key"); // drop the token from the address bar
|
||||
return new Response(null, { status: 302, headers: { location: url.pathname + url.search, "set-cookie": setCookie } });
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = url.pathname.startsWith("/api/") ? await api(req, url) : await proxyPage(req, url);
|
||||
} catch (e) {
|
||||
const status = e instanceof HttpError ? e.status : 500;
|
||||
if (status === 500) console.error(`${req.method} ${url.pathname}:`, e);
|
||||
res = Response.json({ error: (e as Error).message }, { status });
|
||||
}
|
||||
if (viaKey) res.headers.append("set-cookie", setCookie);
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
const origin = `http://${opts.urlHost ?? (host === "0.0.0.0" || host === "::" ? "localhost" : host)}:${outer.port}`;
|
||||
return {
|
||||
url: `${origin}/?key=${token}`, origin, token, port: outer.port!,
|
||||
async stop() { await outer.stop(true); await inner.stop(true); },
|
||||
};
|
||||
}
|
||||
@@ -140,3 +140,42 @@ export function parseSmf(bytes: Uint8Array): Smf {
|
||||
timeSigs: sigs.map((s) => ({ sec: toSec(s.tick), num: s.num, den: s.den })),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SmfTrackIn { name: string; program: number; isDrums: boolean; notes: { onset: number; offset: number; pitch: number; velocity?: number }[] }
|
||||
|
||||
/**
|
||||
* Write a format-1 SMF at a single fixed tempo (PPQ 960): a conductor track plus one track per input track.
|
||||
* Melodic tracks take channels 0..15 skipping 9; drums use channel 9. Inverse of parseSmf up to tick rounding.
|
||||
* Intended for tests and fixtures.
|
||||
*/
|
||||
export function writeSmf(tracks: SmfTrackIn[], tempoBpm = 120): Uint8Array {
|
||||
const PPQ = 960;
|
||||
const us = Math.round(60e6 / tempoBpm);
|
||||
const tickOf = (sec: number) => Math.max(0, Math.round((sec * 1e6 * PPQ) / us));
|
||||
const vlq = (v: number): number[] => { const o = [v & 0x7f]; while ((v >>>= 7) > 0) o.unshift((v & 0x7f) | 0x80); return o; };
|
||||
const u32 = (v: number) => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
|
||||
const text = (s: string) => [...new TextEncoder().encode(s)];
|
||||
const chunk = (id: string, body: number[]) => [...text(id), ...u32(body.length), ...body];
|
||||
const conductor = [0, 0xff, 0x51, 3, (us >> 16) & 255, (us >> 8) & 255, us & 255, 0, 0xff, 0x58, 4, 4, 2, 24, 8, 0, 0xff, 0x2f, 0];
|
||||
const melodic = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15];
|
||||
let next = 0;
|
||||
const bodies = tracks.map((t) => {
|
||||
const ch = t.isDrums ? 9 : melodic[next++ % melodic.length]!;
|
||||
// offs sort before ons at equal ticks so a repeated pitch re-triggers cleanly
|
||||
const ev: { tick: number; off: boolean; bytes: number[] }[] = [];
|
||||
for (const n of t.notes) {
|
||||
const on = tickOf(n.onset), off = Math.max(on + 1, tickOf(n.offset));
|
||||
ev.push({ tick: on, off: false, bytes: [0x90 | ch, n.pitch & 127, Math.max(1, Math.min(127, Math.round(n.velocity ?? 96)))] });
|
||||
ev.push({ tick: off, off: true, bytes: [0x80 | ch, n.pitch & 127, 0] });
|
||||
}
|
||||
ev.sort((a, b) => a.tick - b.tick || Number(b.off) - Number(a.off));
|
||||
const name = text(t.name);
|
||||
const out = [0, 0xff, 0x03, ...vlq(name.length), ...name, 0, 0xc0 | ch, Math.max(0, t.program) & 127];
|
||||
let at = 0;
|
||||
for (const e of ev) { out.push(...vlq(e.tick - at), ...e.bytes); at = e.tick; }
|
||||
out.push(0, 0xff, 0x2f, 0);
|
||||
return out;
|
||||
});
|
||||
const header = chunk("MThd", [0, 1, (tracks.length + 1) >> 8, (tracks.length + 1) & 255, PPQ >> 8, PPQ & 255]);
|
||||
return new Uint8Array([...header, ...chunk("MTrk", conductor), ...bodies.flatMap((b) => chunk("MTrk", b))]);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Tempo of alignment stretches: the MIDI's own tempo vs the tempo after warping. Pure (shared by server and page).
|
||||
import type { TempoPoint } from "./smf.ts";
|
||||
import type { Stretch } from "./warp.ts";
|
||||
|
||||
export interface StretchInfo extends Stretch { midiBpm: number; bpm: number; pct: number }
|
||||
|
||||
/** Average MIDI tempo over [from, to] (MIDI seconds) = beats / minutes; 120 bpm before the first tempo point. */
|
||||
export function avgBpm(tempoMap: TempoPoint[], from: number, to: number): number {
|
||||
const pts = [...tempoMap].sort((a, b) => a.sec - b.sec);
|
||||
const bpmAt = (t: number) => { let b = 120; for (const p of pts) if (p.sec <= t) b = p.bpm; else break; return b; };
|
||||
if (!(to > from)) return bpmAt(from);
|
||||
let beats = 0, t = from;
|
||||
for (const p of pts) {
|
||||
if (p.sec <= t) continue;
|
||||
if (p.sec >= to) break;
|
||||
beats += ((p.sec - t) * bpmAt(t)) / 60;
|
||||
t = p.sec;
|
||||
}
|
||||
beats += ((to - t) * bpmAt(t)) / 60;
|
||||
return (beats * 60) / (to - from);
|
||||
}
|
||||
|
||||
/** Adds the MIDI tempo, the effective (warped) tempo = MIDI bpm / ratio, and the change in percent. */
|
||||
export function stretchInfo(stretches: Stretch[], tempoMap: TempoPoint[]): StretchInfo[] {
|
||||
return stretches.map((s) => {
|
||||
const midiBpm = avgBpm(tempoMap, s.fromMidi, s.toMidi);
|
||||
return { ...s, midiBpm, bpm: midiBpm / s.ratio, pct: (1 / s.ratio - 1) * 100 };
|
||||
});
|
||||
}
|
||||
|
||||
/** "78.0 → 79.1 bpm (+1.4%)", with " ⚠" when the stretch is warned; the percent is omitted below 0.05%. */
|
||||
export function stretchLabel(s: StretchInfo): string {
|
||||
const pct = Math.abs(s.pct) < 0.05 ? "" : ` (${s.pct > 0 ? "+" : "−"}${Math.abs(s.pct).toFixed(1)}%)`;
|
||||
return `${s.midiBpm.toFixed(1)} → ${s.bpm.toFixed(1)} bpm${pct}${s.warn ? " ⚠" : ""}`;
|
||||
}
|
||||
+6
-1
@@ -45,6 +45,11 @@ export async function readWav(path: string): Promise<Audio> {
|
||||
|
||||
/** Write 16-bit PCM WAV (clipped to [-1, 1]). */
|
||||
export async function writeWav(path: string, a: Audio): Promise<void> {
|
||||
await Bun.write(path, encodeWav(a));
|
||||
}
|
||||
|
||||
/** Encode 16-bit PCM WAV bytes (clipped to [-1, 1]). */
|
||||
export function encodeWav(a: Audio): Uint8Array<ArrayBuffer> {
|
||||
const nch = a.channels.length;
|
||||
const frames = a.channels[0]?.length ?? 0;
|
||||
const dataLen = frames * nch * 2;
|
||||
@@ -64,7 +69,7 @@ export async function writeWav(path: string, a: Audio): Promise<void> {
|
||||
o += 2;
|
||||
}
|
||||
}
|
||||
await Bun.write(path, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export { toMono } from "../dsp/mono.ts";
|
||||
|
||||
+76
-3
@@ -1,7 +1,10 @@
|
||||
import { join, resolve } from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import type { Role } from "../core/types.ts";
|
||||
import { parseSmf } from "../align/smf.ts";
|
||||
import { mkdir, rename, rm } from "node:fs/promises";
|
||||
import type { Audio, Role } from "../core/types.ts";
|
||||
import { parseSmf, type Smf } from "../align/smf.ts";
|
||||
import type { Anchor } from "../align/warp.ts";
|
||||
import type { FitReport } from "../align/fit.ts";
|
||||
import { readWav } from "../audio/wav.ts";
|
||||
import { guessRole, trackKeys } from "../align/roles.ts";
|
||||
|
||||
export interface SongEntry {
|
||||
@@ -39,3 +42,73 @@ export async function addSong(e: Omit<SongEntry, "roles"> & { roles?: SongEntry[
|
||||
await Bun.write(join(root, "songs.json"), JSON.stringify([...all, entry], null, 2) + "\n");
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---- per-song caches and alignment file (reference/<id>/) ----
|
||||
|
||||
export interface AlignmentFile { anchors: Anchor[]; savedAt: string; fit: FitReport | null }
|
||||
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
|
||||
/** Runs `make(tmp)` to build `out` when it is missing or older than `src`; atomic via rename; concurrent calls share one run. */
|
||||
function cached(out: string, src: string, make: (tmp: string) => Promise<unknown>): Promise<string> {
|
||||
const running = inflight.get(out);
|
||||
if (running) return running;
|
||||
const p = (async () => {
|
||||
const o = Bun.file(out);
|
||||
if ((await o.exists()) && o.lastModified >= Bun.file(src).lastModified) return out;
|
||||
const tmp = `${out}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await make(tmp);
|
||||
await rename(tmp, out);
|
||||
} catch (e) {
|
||||
await rm(tmp, { force: true });
|
||||
throw e;
|
||||
}
|
||||
return out;
|
||||
})().finally(() => inflight.delete(out));
|
||||
inflight.set(out, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function ffmpeg(args: string[]): Promise<void> {
|
||||
const r = await Bun.$`ffmpeg -nostdin -y -loglevel error ${args}`.nothrow().quiet();
|
||||
if (r.exitCode !== 0) throw new Error(`ffmpeg failed (${r.exitCode}): ${r.stderr.toString().trim()}`);
|
||||
}
|
||||
|
||||
/** `reference/<id>/recording.wav`: the recording as 44.1 kHz stereo PCM16 (analysis input), transcoded lazily. */
|
||||
export async function recordingWav(e: SongEntry, root = "reference"): Promise<string> {
|
||||
const dir = songDir(e.id, root);
|
||||
await mkdir(dir, { recursive: true });
|
||||
return cached(join(dir, "recording.wav"), e.audio, (tmp) => ffmpeg(["-i", e.audio, "-vn", "-ar", "44100", "-ac", "2", "-c:a", "pcm_s16le", "-f", "wav", tmp]));
|
||||
}
|
||||
|
||||
/** `reference/<id>/audio.ogg`: the recording as Opus 160 kbit/s for the browser, transcoded lazily. */
|
||||
export async function audioOgg(e: SongEntry, root = "reference"): Promise<string> {
|
||||
const dir = songDir(e.id, root);
|
||||
await mkdir(dir, { recursive: true });
|
||||
return cached(join(dir, "audio.ogg"), e.audio, (tmp) => ffmpeg(["-i", e.audio, "-vn", "-c:a", "libopus", "-b:a", "160k", "-f", "ogg", tmp]));
|
||||
}
|
||||
|
||||
export async function readAlignment(id: string, root = "reference"): Promise<AlignmentFile | null> {
|
||||
const f = Bun.file(join(songDir(id, root), "alignment.json"));
|
||||
return (await f.exists()) ? ((await f.json()) as AlignmentFile) : null;
|
||||
}
|
||||
|
||||
export async function writeAlignment(id: string, a: AlignmentFile, root = "reference"): Promise<void> {
|
||||
const dir = songDir(id, root);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tmp = join(dir, `alignment.json.${process.pid}.tmp`);
|
||||
await Bun.write(tmp, JSON.stringify(a, null, 1) + "\n");
|
||||
await rename(tmp, join(dir, "alignment.json"));
|
||||
}
|
||||
|
||||
export interface LoadedSong { entry: SongEntry; smf: Smf; audio: Audio; audioDur: number }
|
||||
|
||||
/** Manifest entry, parsed MIDI and the 44.1 kHz recording (transcoded on first use). */
|
||||
export async function loadSong(id: string, root = "reference"): Promise<LoadedSong> {
|
||||
const entry = (await readManifest(root)).find((s) => s.id === id);
|
||||
if (!entry) throw new Error(`unknown song id: ${id}`);
|
||||
const smf = parseSmf(new Uint8Array(await Bun.file(entry.midi).arrayBuffer()));
|
||||
const audio = await readWav(await recordingWav(entry, root));
|
||||
return { entry, smf, audio, audioDur: (audio.channels[0]?.length ?? 0) / audio.sampleRate };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, beforeAll } from "bun:test";
|
||||
import { alignmentFit, autoAlign, bandedWithRetry, barTimes } from "../../src/align/auto.ts";
|
||||
import { alignmentFit, autoAlign, bandedWithRetry, barTimes, fitFromOnsets, recordingOnsets } from "../../src/align/auto.ts";
|
||||
import { makeWarp, type Anchor, type Warp } from "../../src/align/warp.ts";
|
||||
import type { RefTrack, Smf } from "../../src/align/smf.ts";
|
||||
import type { Audio, Role, TruthTrack } from "../../src/core/types.ts";
|
||||
@@ -169,3 +169,12 @@ test("when every fine band fails, the coarse-path fallback still yields auto anc
|
||||
expect(median(err)).toBeLessThan(0.06); // 10 Hz resolution: coarser than the fine pass, still hand-correctable
|
||||
expect(p90(err)).toBeLessThan(0.12);
|
||||
}, BIG);
|
||||
|
||||
test("fitFromOnsets with cached recordingOnsets equals alignmentFit", () => {
|
||||
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t);
|
||||
const roles = rolesOf(song.truth.tracks);
|
||||
const anchors: Anchor[] = [{ midiSec: 1, audioSec: 1.05, kind: "manual" }, { midiSec: 20, audioSec: 20.1, kind: "manual" }];
|
||||
const onsets = recordingOnsets(song.mix);
|
||||
expect(onsets.length).toBeGreaterThan(10);
|
||||
expect(fitFromOnsets(onsets, smf, roles, anchors, durOf(song.mix))).toEqual(alignmentFit(song.mix, smf, roles, anchors));
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { fitQuality } from "../../src/align/fit.ts";
|
||||
import { fitQuality, fitSummary } from "../../src/align/fit.ts";
|
||||
|
||||
test("perfect fit: match rate 1, residual 0, one window per 30 s", () => {
|
||||
const on = [1, 2, 3, 31, 32, 61];
|
||||
@@ -62,3 +62,11 @@ test("unsorted and duplicate MIDI onsets: duplicates count once", () => {
|
||||
const s = fitQuality([1], [1, 1, 1, 3], 5);
|
||||
expect(s.matchRate).toBe(0.5); // unique onsets {1, 3}
|
||||
});
|
||||
|
||||
test("fitSummary: one line with match rate, median residual and flagged windows", () => {
|
||||
const w = (startSec: number, flagged: boolean) => ({ startSec, matchRate: 0.9, medianResidualMs: 10, flagged });
|
||||
expect(fitSummary({ matchRate: 0.8234, medianResidualMs: 14.24, windows: [w(0, false), w(30, true), w(60, false), w(90, true)] }))
|
||||
.toBe("82.3% of MIDI onsets matched · median residual 14.2 ms · 2/4 windows flagged (0:30, 1:30)");
|
||||
expect(fitSummary({ matchRate: 1, medianResidualMs: 3, windows: [w(0, false)] })).toBe("100.0% of MIDI onsets matched · median residual 3.0 ms · 0/1 windows flagged");
|
||||
expect(fitSummary({ matchRate: null, medianResidualMs: null, windows: [] })).toBe("no scored MIDI onsets");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { dragAudio, dragMidi, hitAnchor, anchorAtCursor, MIN_GAP_SEC } from "../../../src/align/page/edit.ts";
|
||||
import { makeWarp, type Anchor } from "../../../src/align/warp.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("dragAudio (recording end)", () => {
|
||||
test("moves audioSec, keeps midiSec, returns the anchor's new index", () => {
|
||||
const r = dragAudio(base, 1, 23);
|
||||
expect(r.anchors[r.index]).toEqual(m(20, 23));
|
||||
expect(r.anchors).toHaveLength(3);
|
||||
});
|
||||
test("cannot cross manual neighbours", () => {
|
||||
expect(dragAudio(base, 1, 5).anchors[1]!.audioSec).toBeCloseTo(11 + MIN_GAP_SEC, 9);
|
||||
expect(dragAudio(base, 1, 99).anchors[1]!.audioSec).toBeCloseTo(32 - MIN_GAP_SEC, 9);
|
||||
});
|
||||
test("dragging an auto anchor makes it manual and drops the autos in its stretches", () => {
|
||||
const list = [m(0, 0), a(5, 5.2), a(10, 10.3), a(15, 15.1), m(20, 20)];
|
||||
const r = dragAudio(list, 2, 10.5);
|
||||
expect(r.anchors).toEqual([m(0, 0), m(10, 10.5), m(20, 20)]);
|
||||
expect(r.index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dragMidi (MIDI end): the grabbed MIDI moment follows the mouse, audioSec stays", () => {
|
||||
for (const [grab, to] of [[22, 23.5], [22, 20.5], [21, 25], [24, 21]] as const) {
|
||||
test(`grab at ${grab}s, drag to ${to}s`, () => {
|
||||
const m0 = makeWarp(base).toMidi(grab);
|
||||
const r = dragMidi(base, 1, m0, to);
|
||||
expect(r.anchors[r.index]!.audioSec).toBe(22);
|
||||
expect(makeWarp(r.anchors).toAudio(m0)).toBeCloseTo(to, 6);
|
||||
});
|
||||
}
|
||||
test("no drag = no change", () => {
|
||||
const r = dragMidi(base, 1, 20, 22);
|
||||
expect(r.anchors[1]!.midiSec).toBeCloseTo(20, 6);
|
||||
});
|
||||
test("clamped between manual neighbours' MIDI times", () => {
|
||||
const r = dragMidi(base, 1, 20, 31.9);
|
||||
expect(r.anchors[1]!.midiSec).toBeGreaterThanOrEqual(10 + MIN_GAP_SEC - 1e-9);
|
||||
const s = dragMidi(base, 1, 20, 11.1);
|
||||
expect(s.anchors[1]!.midiSec).toBeLessThanOrEqual(30 - MIN_GAP_SEC + 1e-9);
|
||||
});
|
||||
test("works for the first and only anchor (edge extrapolation)", () => {
|
||||
const r = dragMidi([m(10, 11)], 0, 10, 12);
|
||||
expect(makeWarp(r.anchors).toAudio(10)).toBeCloseTo(12, 6);
|
||||
expect(r.anchors[0]!.audioSec).toBe(11);
|
||||
const f = dragMidi(base, 0, 10, 12);
|
||||
expect(makeWarp(f.anchors).toAudio(10)).toBeCloseTo(12, 6);
|
||||
});
|
||||
});
|
||||
|
||||
test("hitAnchor picks the nearest anchor line within tolerance", () => {
|
||||
const v = { start: 0, pxPerSec: 10, width: 500 };
|
||||
expect(hitAnchor(base, v, 221, 6)).toBe(1);
|
||||
expect(hitAnchor(base, v, 113, 6)).toBe(0);
|
||||
expect(hitAnchor(base, v, 160, 6)).toBe(-1);
|
||||
});
|
||||
|
||||
test("anchorAtCursor adds a manual anchor where the current warp maps the cursor", () => {
|
||||
const r = anchorAtCursor(base, 27);
|
||||
const k = r.anchors.findIndex((x) => x.audioSec === 27);
|
||||
expect(r.index).toBe(k);
|
||||
expect(r.anchors[k]).toEqual(m(makeWarp(base).toMidi(27), 27));
|
||||
expect(() => anchorAtCursor(base, 22)).toThrow(); // already an anchor there
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { timeToX, xToTime, fitView, zoomAt, scrollBy, clampView, follow, formatTime, rulerStep, buildPyramid, columnPeaks, MAX_PX_PER_SEC, type View } from "../../../src/align/page/timeline.ts";
|
||||
|
||||
const DUR = 200;
|
||||
|
||||
describe("view mapping", () => {
|
||||
const v: View = { start: 10, pxPerSec: 50, width: 1000 };
|
||||
test("time <-> x are inverse", () => {
|
||||
expect(timeToX(v, 12)).toBe(100);
|
||||
expect(xToTime(v, 100)).toBe(12);
|
||||
for (const t of [0, 7.3, 31]) expect(xToTime(v, timeToX(v, t))).toBeCloseTo(t, 12);
|
||||
});
|
||||
test("fitView shows the whole song", () => {
|
||||
const f = fitView(DUR, 1000);
|
||||
expect(xToTime(f, 0)).toBeLessThanOrEqual(0);
|
||||
expect(xToTime(f, 1000)).toBeGreaterThanOrEqual(DUR);
|
||||
expect(xToTime(f, 1000)).toBeLessThan(DUR * 1.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoom and scroll", () => {
|
||||
const v: View = { start: 50, pxPerSec: 20, width: 1000 };
|
||||
test("zoomAt keeps the time under the mouse fixed", () => {
|
||||
const t = xToTime(v, 300);
|
||||
const z = zoomAt(v, 300, 2, DUR);
|
||||
expect(z.pxPerSec).toBe(40);
|
||||
expect(xToTime(z, 300)).toBeCloseTo(t, 9);
|
||||
const o = zoomAt(v, 700, 0.8, DUR);
|
||||
expect(xToTime(o, 700)).toBeCloseTo(xToTime(v, 700), 9);
|
||||
});
|
||||
test("zoom is clamped: cannot zoom out past the whole song or in past the maximum", () => {
|
||||
const out = zoomAt(v, 500, 1e-6, DUR);
|
||||
expect(out.pxPerSec).toBeCloseTo(fitView(DUR, 1000).pxPerSec, 9);
|
||||
const inn = zoomAt(v, 500, 1e9, DUR);
|
||||
expect(inn.pxPerSec).toBe(MAX_PX_PER_SEC);
|
||||
});
|
||||
test("scrollBy moves by pixels (drag right = earlier time) and is clamped to the song", () => {
|
||||
expect(scrollBy(v, -100, DUR).start).toBeCloseTo(55, 9);
|
||||
expect(scrollBy(v, 100, DUR).start).toBeCloseTo(45, 9);
|
||||
expect(xToTime(scrollBy(v, 1e7, DUR), 0)).toBeGreaterThanOrEqual(-DUR * 0.05);
|
||||
expect(xToTime(scrollBy(v, -1e7, DUR), 1000)).toBeLessThanOrEqual(DUR * 1.05 + 1e-9);
|
||||
});
|
||||
test("clampView leaves a valid view unchanged", () => {
|
||||
expect(clampView(v, DUR)).toEqual(v);
|
||||
});
|
||||
test("follow pages the view when the playhead leaves it, and not otherwise", () => {
|
||||
expect(follow(v, 60, DUR)).toEqual(v);
|
||||
const f = follow(v, 120, DUR); // visible range is 50..100
|
||||
expect(f.start).toBeLessThan(120);
|
||||
expect(xToTime(f, 1000)).toBeGreaterThan(120);
|
||||
expect(f.pxPerSec).toBe(v.pxPerSec);
|
||||
});
|
||||
});
|
||||
|
||||
test("formatTime and ruler step", () => {
|
||||
expect(formatTime(58.24)).toBe("0:58.2");
|
||||
expect(formatTime(130)).toBe("2:10.0");
|
||||
expect(formatTime(3.04, 2)).toBe("0:03.04");
|
||||
expect(formatTime(-1.5)).toBe("-0:01.5");
|
||||
// labelled ticks at least ~80 px apart
|
||||
for (const pps of [0.5, 3, 20, 100, 1000]) expect(rulerStep(pps) * pps).toBeGreaterThanOrEqual(80);
|
||||
expect(rulerStep(20)).toBe(5);
|
||||
});
|
||||
|
||||
describe("peaks pyramid", () => {
|
||||
const sr = 1000;
|
||||
const x = new Float32Array(10 * sr);
|
||||
for (let i = 0; i < x.length; i++) x[i] = i >= 2000 && i < 3000 ? (i % 2 ? 0.8 : -0.5) : 0.01;
|
||||
const levels = buildPyramid(x, 16);
|
||||
test("each level halves the previous", () => {
|
||||
expect(levels[0]!.bucket).toBe(16);
|
||||
for (let k = 1; k < levels.length; k++) expect(levels[k]!.bucket).toBe(levels[k - 1]!.bucket * 2);
|
||||
expect(levels.at(-1)!.min.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
test("columnPeaks finds the loud second (2..3 s) at any zoom", () => {
|
||||
for (const pxPerSec of [2, 30, 400, 5000]) {
|
||||
const v: View = { start: 0, pxPerSec, width: Math.ceil(10 * pxPerSec) };
|
||||
const { min, max } = columnPeaks(levels, sr, v);
|
||||
expect(min.length).toBe(v.width);
|
||||
const col = Math.floor(2.5 * pxPerSec);
|
||||
expect(max[col]).toBeCloseTo(0.8, 5);
|
||||
expect(min[col]).toBeCloseTo(-0.5, 5);
|
||||
expect(max[Math.floor(6 * pxPerSec)]).toBeCloseTo(0.01, 5);
|
||||
}
|
||||
});
|
||||
test("columns outside the audio are silent", () => {
|
||||
const { min, max } = columnPeaks(levels, sr, { start: -5, pxPerSec: 10, width: 20 });
|
||||
expect(max[5]).toBe(0);
|
||||
expect(min[5]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect, beforeAll, afterAll, describe } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { startServer, type AlignServer } from "../../src/align/server.ts";
|
||||
import { addSong } from "../../src/ref/manifest.ts";
|
||||
import { writeSmf } from "../../src/align/smf.ts";
|
||||
import { buildSong, loadSongDir } from "../../src/bench/dataset.ts";
|
||||
import type { Anchor } from "../../src/align/warp.ts";
|
||||
|
||||
const BIG = 120_000;
|
||||
let root = "", srv: AlignServer, cookie = "";
|
||||
const url = (p: string) => new URL(p, srv.origin).toString();
|
||||
const api = (p: string, init: RequestInit = {}) => fetch(url(p), { ...init, headers: { cookie, ...(init.headers ?? {}) }, redirect: "manual" });
|
||||
const json = (body: unknown, method = "POST"): RequestInit => ({ method, body: JSON.stringify(body), headers: { "content-type": "application/json" } });
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "stsheets-align-"));
|
||||
const dir = await buildSong("t1a", 1);
|
||||
const { song, truth } = await loadSongDir(dir);
|
||||
const midi = join(root, "fixture.mid");
|
||||
await Bun.write(midi, writeSmf(truth.tracks.map((t, i) => ({ name: t.name, program: song.tracks[i]!.program, isDrums: t.role === "drums", notes: t.notes })), song.bpm));
|
||||
await addSong({ id: "fix", title: "Fixture Song", audio: join(dir, "mix.wav"), midi, capo: {} }, root);
|
||||
srv = await startServer({ root, host: "127.0.0.1", port: 0, quiet: true });
|
||||
}, BIG);
|
||||
afterAll(async () => { await srv?.stop(); if (root) await rm(root, { recursive: true, force: true }); });
|
||||
|
||||
describe("access token", () => {
|
||||
test("requests without the token get 401", async () => {
|
||||
for (const p of ["/", "/api/songs", "/api/song/fix", "/api/songs?key=wrong"]) expect((await fetch(url(p), { redirect: "manual" })).status).toBe(401);
|
||||
expect((await fetch(url("/api/songs"), { headers: { cookie: "stsheets_align=nope" } })).status).toBe(401);
|
||||
});
|
||||
test("printed URL carries the token; ?key= sets an HttpOnly cookie and redirects to a clean URL", async () => {
|
||||
expect(srv.url).toContain(`?key=${srv.token}`);
|
||||
const r = await fetch(url(`/?key=${srv.token}`), { redirect: "manual" });
|
||||
expect(r.status).toBe(302);
|
||||
expect(r.headers.get("location")).toBe("/");
|
||||
const sc = r.headers.get("set-cookie") ?? "";
|
||||
expect(sc).toContain("HttpOnly");
|
||||
cookie = sc.split(";")[0]!;
|
||||
expect(cookie).toBe(`stsheets_align=${srv.token}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("page", () => {
|
||||
test("GET / serves the HTML page and its bundled script", async () => {
|
||||
const r = await api("/");
|
||||
expect(r.status).toBe(200);
|
||||
const html = await r.text();
|
||||
expect(html).toContain("<canvas");
|
||||
const src = html.match(/<script[^>]+src="([^"]+)"/)?.[1];
|
||||
expect(src).toBeTruthy();
|
||||
const js = await api(src!);
|
||||
expect(js.status).toBe(200);
|
||||
expect((await js.text()).length).toBeGreaterThan(1000);
|
||||
expect((await fetch(url(src!))).status).toBe(401); // assets are behind the token too
|
||||
});
|
||||
});
|
||||
|
||||
describe("API", () => {
|
||||
test("songs list", async () => {
|
||||
expect(await (await api("/api/songs")).json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: false }]);
|
||||
});
|
||||
test("unknown and path-traversal ids are 404", async () => {
|
||||
for (const p of ["/api/song/nope", "/api/song/%2E%2E", "/api/song/..%2F..%2Fetc", "/api/song/%2E%2E/audio", "/api/song/nope/render"]) {
|
||||
expect((await api(p, p.endsWith("render") ? json({ anchors: [] }) : {})).status).toBe(404);
|
||||
}
|
||||
});
|
||||
let first: any;
|
||||
test("song: peaks, auto anchors (unsaved), stretches with MIDI and effective bpm, fit, tempo map", async () => {
|
||||
const r = await api("/api/song/fix");
|
||||
expect(r.status).toBe(200);
|
||||
first = await r.json();
|
||||
expect(first.id).toBe("fix");
|
||||
expect(first.title).toBe("Fixture Song");
|
||||
expect(first.audioDur).toBeGreaterThan(20);
|
||||
expect(first.midiDur).toBeGreaterThan(20);
|
||||
expect(first.tracks.length).toBeGreaterThan(0);
|
||||
expect(first.tracks.every((t: any) => typeof t.name === "string" && "role" in t)).toBe(true);
|
||||
expect(first.recordingPeaks.bucketSec).toBeGreaterThan(0);
|
||||
expect(first.recordingPeaks.max.length).toBeGreaterThan(first.audioDur * 20);
|
||||
expect(Math.max(...first.recordingPeaks.max)).toBeGreaterThan(0.05);
|
||||
expect(first.source).toBe("auto");
|
||||
expect(first.savedAt).toBeNull();
|
||||
expect(first.anchors.length).toBeGreaterThan(2);
|
||||
expect(first.anchors.every((a: Anchor) => a.kind === "auto")).toBe(true);
|
||||
expect(first.stretches.length).toBeGreaterThanOrEqual(first.anchors.length); // +1, minus an empty edge stretch
|
||||
expect(first.stretches[1].midiBpm).toBeGreaterThan(30);
|
||||
expect(first.stretches[1].bpm).toBeCloseTo(first.stretches[1].midiBpm / first.stretches[1].ratio, 6);
|
||||
expect(first.fit.matchRate).toBeGreaterThan(0.5);
|
||||
expect(first.midiTempo[0].bpm).toBeGreaterThan(30);
|
||||
expect(await Bun.file(join(root, "fix", "recording.wav")).exists()).toBe(true);
|
||||
expect(await Bun.file(join(root, "fix", "peaks.json")).exists()).toBe(true);
|
||||
}, BIG);
|
||||
test("audio: cached Ogg Opus", async () => {
|
||||
const r = await api("/api/song/fix/audio");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toContain("audio/ogg");
|
||||
const b = new Uint8Array(await r.arrayBuffer());
|
||||
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("OggS");
|
||||
expect(await Bun.file(join(root, "fix", "audio.ogg")).exists()).toBe(true);
|
||||
}, BIG);
|
||||
test("render: RIFF WAV, mono 22.05 kHz, cached by anchors", async () => {
|
||||
const body = json({ anchors: first.anchors });
|
||||
const r = await api("/api/song/fix/render", body);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toBe("audio/wav");
|
||||
const b = new Uint8Array(await r.arrayBuffer());
|
||||
const dv = new DataView(b.buffer);
|
||||
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("RIFF");
|
||||
expect(dv.getUint16(22, true)).toBe(1);
|
||||
expect(dv.getUint32(24, true)).toBe(22050);
|
||||
expect(b.length).toBeGreaterThan(first.audioDur * 22050 * 2);
|
||||
const again = await api("/api/song/fix/render", body);
|
||||
expect(again.headers.get("x-cache")).toBe("hit");
|
||||
expect(again.headers.get("x-anchors-hash")).toBe(r.headers.get("x-anchors-hash"));
|
||||
}, BIG);
|
||||
test("fit: re-scores arbitrary anchors", async () => {
|
||||
const r = await api("/api/song/fix/fit", json({ anchors: [{ midiSec: 1, audioSec: 1.4, kind: "manual" }] }));
|
||||
const j = await r.json();
|
||||
expect(j.fit.windows.length).toBeGreaterThan(0);
|
||||
expect(j.fit.matchRate).toBeLessThan(first.fit.matchRate);
|
||||
expect(j.stretches.length).toBe(2);
|
||||
});
|
||||
test("bad anchors are rejected with 400", async () => {
|
||||
const bad = [{ anchors: "x" }, { anchors: [{ midiSec: 1, audioSec: 2, kind: "manual" }, { midiSec: 2, audioSec: 1, kind: "manual" }] }, { anchors: [{ midiSec: "1", audioSec: 2, kind: "manual" }] }, { anchors: [{ midiSec: 1, audioSec: 2, kind: "weird" }] }];
|
||||
for (const b of bad) expect((await api("/api/song/fix/fit", json(b))).status).toBe(400);
|
||||
expect((await api("/api/song/fix/fit", { method: "POST", body: "{not json" })).status).toBe(400);
|
||||
});
|
||||
test("auto: keeps manual anchors and re-solves autos around them", async () => {
|
||||
const manual: Anchor = { ...first.anchors[3], kind: "manual" };
|
||||
const r = await api("/api/song/fix/auto", json({ anchors: [manual, ...first.anchors.slice(5)] }));
|
||||
const j = await r.json();
|
||||
expect(j.anchors.filter((a: Anchor) => a.kind === "manual")).toEqual([manual]);
|
||||
expect(j.anchors.filter((a: Anchor) => a.kind === "auto").length).toBeGreaterThan(2);
|
||||
expect(j.fit.matchRate).toBeGreaterThan(0.5);
|
||||
expect(j.stretches.length).toBeGreaterThanOrEqual(j.anchors.length);
|
||||
}, BIG);
|
||||
test("reset: raw MIDI (no anchors)", async () => {
|
||||
const j = await (await api("/api/song/fix/reset", { method: "POST" })).json();
|
||||
expect(j.anchors).toEqual([]);
|
||||
expect(j.stretches).toHaveLength(1);
|
||||
expect(j.fit.windows.length).toBeGreaterThan(0);
|
||||
});
|
||||
test("PUT alignment then GET round-trips anchors; file holds anchors, savedAt, fit", async () => {
|
||||
const anchors: Anchor[] = [{ midiSec: 0.5, audioSec: 0.52, kind: "manual" }, { midiSec: 10, audioSec: 10.05, kind: "auto" }];
|
||||
const p = await api("/api/song/fix/alignment", json({ anchors }, "PUT"));
|
||||
expect(p.status).toBe(200);
|
||||
const saved = await p.json();
|
||||
expect(typeof saved.savedAt).toBe("string");
|
||||
const file = await Bun.file(join(root, "fix", "alignment.json")).json();
|
||||
expect(file.anchors).toEqual(anchors);
|
||||
expect(file.savedAt).toBe(saved.savedAt);
|
||||
expect(file.fit.windows.length).toBeGreaterThan(0);
|
||||
const g = await (await api("/api/song/fix")).json();
|
||||
expect(g.anchors).toEqual(anchors);
|
||||
expect(g.source).toBe("saved");
|
||||
expect(g.savedAt).toBe(saved.savedAt);
|
||||
expect(await (await api("/api/songs")).json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: true }]);
|
||||
});
|
||||
});
|
||||
+22
-1
@@ -1,6 +1,6 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { parseSmf } from "../../src/align/smf.ts";
|
||||
import { parseSmf, writeSmf } from "../../src/align/smf.ts";
|
||||
import { guessRole } from "../../src/align/roles.ts";
|
||||
import type { RefTrack } from "../../src/align/smf.ts";
|
||||
|
||||
@@ -114,3 +114,24 @@ describe("real GP export", () => {
|
||||
expect(s.durationSec).toBeGreaterThan(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeSmf", () => {
|
||||
test("round-trips tracks, programs, drums, tempo and note times through parseSmf", () => {
|
||||
const tracks = [
|
||||
{ name: "Guitar", program: 25, isDrums: false, notes: [{ onset: 0, offset: 0.5, pitch: 60, velocity: 100 }, { onset: 0.25, offset: 1.5, pitch: 64, velocity: 80 }] },
|
||||
{ name: "Drums", program: 0, isDrums: true, notes: [{ onset: 1, offset: 1.1, pitch: 36, velocity: 110 }] },
|
||||
];
|
||||
const s = parseSmf(writeSmf(tracks, 90));
|
||||
expect(s.tempoMap).toEqual([{ sec: 0, bpm: expect.closeTo(90, 3) }]);
|
||||
expect(s.tracks.map((t) => [t.name, t.program, t.isDrums])).toEqual([["Guitar", 25, false], ["Drums", 0, true]]);
|
||||
const g = s.tracks[0]!.notes;
|
||||
expect(g.map((n) => n.pitch)).toEqual([60, 64]);
|
||||
for (const [k, n] of tracks[0]!.notes.entries()) {
|
||||
expect(g[k]!.onset).toBeCloseTo(n.onset, 3);
|
||||
expect(g[k]!.offset).toBeCloseTo(n.offset, 3);
|
||||
expect(g[k]!.velocity).toBe(n.velocity);
|
||||
}
|
||||
expect(s.tracks[1]!.notes[0]!.channel).toBe(9);
|
||||
expect(s.durationSec).toBeCloseTo(1.5, 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { avgBpm, stretchInfo, stretchLabel } from "../../src/align/tempo.ts";
|
||||
import type { Stretch } from "../../src/align/warp.ts";
|
||||
|
||||
const map = [{ sec: 0, bpm: 120 }, { sec: 10, bpm: 60 }];
|
||||
|
||||
test("avgBpm: beats over seconds across tempo changes", () => {
|
||||
expect(avgBpm(map, 0, 10)).toBeCloseTo(120, 9);
|
||||
expect(avgBpm(map, 10, 20)).toBeCloseTo(60, 9);
|
||||
expect(avgBpm(map, 5, 15)).toBeCloseTo((10 + 5) / 10 * 60, 9); // 10 beats + 5 beats in 10 s = 90 bpm
|
||||
expect(avgBpm([], 0, 4)).toBeCloseTo(120, 9); // implicit 120
|
||||
expect(avgBpm(map, 3, 3)).toBeCloseTo(120, 9); // empty span: tempo at that point
|
||||
});
|
||||
|
||||
test("stretchInfo: effective bpm = MIDI bpm / ratio, signed percent", () => {
|
||||
const s: Stretch[] = [{ fromMidi: 0, toMidi: 10, ratio: 120 / 121.68, warn: false }, { fromMidi: 10, toMidi: 20, ratio: 60 / 55, warn: true }];
|
||||
const [a, b] = stretchInfo(s, map);
|
||||
expect(a!.midiBpm).toBeCloseTo(120, 9);
|
||||
expect(a!.bpm).toBeCloseTo(121.68, 6);
|
||||
expect(a!.pct).toBeCloseTo(1.4, 6);
|
||||
expect(b!.bpm).toBeCloseTo(55, 6);
|
||||
expect(stretchLabel(a!)).toBe("120.0 → 121.7 bpm (+1.4%)");
|
||||
expect(stretchLabel(b!)).toBe("60.0 → 55.0 bpm (−8.3%) ⚠");
|
||||
expect(stretchLabel({ ...a!, bpm: 120, pct: 0.01 })).toBe("120.0 → 120.0 bpm");
|
||||
});
|
||||
Reference in new issue
Block a user