import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile, rename } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { CACHE_DIR, cacheFile } from "@/lib/paths"; import { sourceWav } from "@/lib/clips"; import { readWavWindow, encodeWav, peakOver } from "@/lib/wav"; const run = promisify(execFile); export const dynamic = "force-dynamic"; // THE route that removes the boundary problem. // // The old page sliced +/-0.12s at build time and inlined it as base64, so // whatever fell outside was unreachable no matter how the handles were dragged. // Ten of 23 free-text notes in the last pass said "cut off", and six of those // were filed as "not an um" -- rejected for the window rather than for the clip. // Here the window is a parameter, so any part of any episode is one range-read // away. // // Two details that matter: // // * The gain comes from a FIXED reference region (the candidate +/-1s), not // from the window being served. Normalising each view to its own peak would // make the um quieter the moment you widened into a loud word, which reads // as the edit having damaged the clip. // * Short windows are served as WAV, long ones as mp3. WAV is sample-exact and // needs no encoder, which is what a tool for judging exact edges wants; mp3 // only earns its ffmpeg spawn once a window is big enough for the bytes to // matter. Both are cached. const WAV_LIMIT = 30; // seconds export async function GET(request: Request, ctx: { params: Promise<{ key: string }> }) { const { key } = await ctx.params; const url = new URL(request.url); const from = Number(url.searchParams.get("from")); const to = Number(url.searchParams.get("to")); if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) { return new Response("bad window", { status: 400 }); } const at = decodeURIComponent(key).lastIndexOf("@"); const decoded = decodeURIComponent(key); const video = at > 0 ? decoded.slice(0, at) : decoded; const candStart = at > 0 ? Number(decoded.slice(at + 1)) : from; const file = sourceWav(video); if (!existsSync(file)) return new Response("source missing", { status: 404 }); const asMp3 = to - from > WAV_LIMIT; const stamp = createHash("sha1") .update(`${video}|${from.toFixed(3)}|${to.toFixed(3)}|${asMp3 ? "mp3" : "wav"}`) .digest("hex") .slice(0, 16); // Through cacheFile, never a join here: see its comment in lib/paths.mjs. const cached = cacheFile(`${stamp}.${asMp3 ? "mp3" : "wav"}`); const type = asMp3 ? "audio/mpeg" : "audio/wav"; if (existsSync(cached)) { return new Response(new Uint8Array(await readFile(cached)), { headers: { "content-type": type, "cache-control": "no-store", "x-window": `${from},${to}` }, }); } const region = await readWavWindow(file, from, to); if (!region.x.length) return new Response("empty window", { status: 404 }); // Reference peak: the candidate plus a second each side. Stable across every // widening of the view, so the level never jumps mid-judgement. const ref = await peakOver(file, candStart - 1, candStart + 2); const peak = ref > 1e-4 ? ref : 1; const gain = 0.85 / peak; const x = region.x; for (let i = 0; i < x.length; i += 1) x[i] *= gain; await mkdir(CACHE_DIR, { recursive: true }); const wav = encodeWav(x, region.sampleRate); let body: Buffer = wav; if (asMp3) { const tmpWav = cacheFile(`${stamp}.in.wav`); const tmpMp3 = cacheFile(`${stamp}.out.mp3`); await writeFile(tmpWav, wav); await run("ffmpeg", [ "-nostdin", "-v", "error", "-y", "-i", tmpWav, "-c:a", "libmp3lame", "-b:a", "96k", "-ac", "1", tmpMp3, ]); body = await readFile(tmpMp3); await rename(tmpMp3, cached).catch(() => {}); } else { const tmp = cacheFile(`${stamp}.wav.tmp`); await writeFile(tmp, wav); await rename(tmp, cached).catch(() => {}); } return new Response(new Uint8Array(body), { headers: { "content-type": type, "cache-control": "no-store", // The server clamps to the file, so the client is told what it actually got. "x-window": `${region.from},${region.to}`, "x-duration": String(region.duration), }, }); }