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>
70 lines
3.8 KiB
TypeScript
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);
|
|
}
|