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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:38:36 +01:00

70 lines
3.8 KiB
TypeScript

import { parseArgs } from "node:util";
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 [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 } } });
const [audio, midi] = positionals;
if (!audio || !midi || !values.id) throw new Error(USAGE);
const smf = parseSmf(new Uint8Array(await Bun.file(midi).arrayBuffer()));
const keys = new Set(trackKeys(smf.tracks));
const capo: Record<string, number> = {};
for (const c of values.capo ?? []) {
const i = c.lastIndexOf("=");
const k = c.slice(0, i), n = Number(c.slice(i + 1));
if (i < 0 || !Number.isInteger(n) || n < 0) throw new Error(`bad --capo ${JSON.stringify(c)}; expected "<track name>=<fret>"`);
if (!keys.has(k)) throw new Error(`--capo: no track named ${JSON.stringify(k)}; tracks: ${[...keys].map((x) => JSON.stringify(x)).join(", ")}`);
capo[k] = n;
}
const e = await addSong({ id: values.id, title: values.title ?? values.id, audio, midi, capo });
console.log(`added ${e.id} (${e.title})`);
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);
process.exit(1);
}