Files
STSheets/src/ref/manifest.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

115 lines
5.0 KiB
TypeScript

import { join, resolve } from "node:path";
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 {
id: string; title: string; audio: string; midi: string;
roles: Record<string, Role | null>; capo: Record<string, number>; tuning?: Record<string, number[]>;
}
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
export function songDir(id: string, root = "reference"): string {
if (!ID_RE.test(id)) throw new Error(`invalid song id: ${JSON.stringify(id)}`);
return join(root, id);
}
export async function readManifest(root = "reference"): Promise<SongEntry[]> {
const f = Bun.file(join(root, "songs.json"));
if (!(await f.exists())) return [];
return (await f.json()) as SongEntry[];
}
export { trackKeys };
export async function addSong(e: Omit<SongEntry, "roles"> & { roles?: SongEntry["roles"] }, root = "reference"): Promise<SongEntry> {
const dir = songDir(e.id, root);
const all = await readManifest(root);
if (all.some((s) => s.id === e.id)) throw new Error(`duplicate song id: ${e.id}`);
let roles = e.roles;
if (!roles) {
const tracks = parseSmf(new Uint8Array(await Bun.file(e.midi).arrayBuffer())).tracks;
const keys = trackKeys(tracks);
roles = Object.fromEntries(tracks.map((t, i) => [keys[i]!, guessRole(t)]));
}
const entry: SongEntry = { ...e, audio: resolve(e.audio), midi: resolve(e.midi), roles };
await mkdir(dir, { recursive: true });
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 };
}