import { readdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { stateFile, dataFile } from "./paths"; import { readJson, readState, readSalvaged } from "./state"; import { readWavWindow, wavInfo } from "./wav"; import type { AsrWord, Candidate, Clip, ClipStub, ClipPage, Mode } from "./types"; import { clipWindow } from "../song/clipwindow.mjs"; import { stripTrailingPop } from "../song/deplosive.mjs"; import { scoreWith } from "../song/orderfeat.mjs"; import { DELIBERATE_CODES } from "../song/reasons.mjs"; export const keyOf = (c: { video: string; start: number }) => `${c.video}@${(+c.start).toFixed(2)}`; type ManifestItem = { k: string; v: string; d: string; tok: string; clipStart: number; clipLen: number; selA: number; selB: number; f0: number; title: string; t: number; cut?: number; why?: string; wordIn?: string; risk?: string; adj?: { b: string; a: string }; ws?: { s: number; e: number; w: string }[]; }; // --------------------------------------------------------------------------- // Loading. Everything here is re-read per request: the CLI scripts write the // same files, and a cached candidate list would go stale the moment a // paste-back landed. // --------------------------------------------------------------------------- async function loadCandidates(): Promise { const dir = dataFile("cand2"); const files = (await readdir(dir)).filter((f) => f.endsWith(".json")); const all: Candidate[] = []; for (const f of files) { const one = await readJson<{ candidates: Candidate[] }>(`${dir}/${f}`, { candidates: [] }); all.push(...(one.candidates ?? [])); } // Overlapping Whisper detections yield two entries for one vowel. They collide // in clip-keyed state, so a verdict lands on whichever the map saw last -- the // same dedupe build-um.mjs does, and it is not optional. Applying the 284-clip // pass showed what it costs to get near-misses wrong: five re-trimmed windows // landed on a key another detection already owned, and merged into it. const seen = new Set(); return all.filter((c) => { const k = keyOf(c); if (seen.has(k)) return false; seen.add(k); return true; }); } async function loadManifest(): Promise> { const m = await readJson<{ items: ManifestItem[] }>(stateFile("um-manifest.json"), { items: [] }); return new Map((m.items ?? []).map((it) => [it.k, it])); } const asrCache = new Map(); export async function wordsFor(video: string): Promise { const hit = asrCache.get(video); if (hit) return hit; const w = await readJson<{ words: AsrWord[] }>(dataFile("asr", `${video}.json`), { words: [] }); return primeWords(video, w.words ?? []); } /** * Publish a freshly-read word list into the shared cache, and hand it back. * * lib/phrases.ts re-reads an episode's ASR whenever its (mtime, size) changes, * which is a read this cache would otherwise miss -- leaving /browse/find and a * clip card quoting different words for the same second, with nothing to say * which was stale. One parse, one cache, both surfaces. * * Sorts, because it is the SAME invariant wordsFor has always guaranteed: * clipWindow scans outward for the nearest word each side and stops at the * first hit, so an out-of-order list silently returns the wrong neighbour. */ export function primeWords(video: string, words: AsrWord[]): AsrWord[] { const sorted = words.slice().sort((a, b) => a.start - b.start); asrCache.set(video, sorted); return sorted; } export const sourceWav = (video: string) => dataFile("wav48", `${video}.wav`); // media/ holds the same episodes as video, on the SAME timeline as wav48/ -- // render-poly seeks both with the same timestamps. Present for most sources but // not guaranteed, so callers must treat a missing file as normal. export const sourceVideo = (video: string) => dataFile("media", `${video}.mp4`); // --------------------------------------------------------------------------- // The default window, without reading a 70 MB episode. // // clipWindow() indexes its buffer in ABSOLUTE episode seconds, which is why // build-um.mjs loads whole wavs. Give it a slice plus the same shift applied to // every time it is handed and the answer is identical -- MAX_EXT is 0.25s, so // 1.5s of context each side is far more than it can reach. The point is to keep // using the shared implementation, not to grow a second one that drifts. // --------------------------------------------------------------------------- const CONTEXT = 1.5; export async function defaultWindowFor( c: { video: string; start: number; end: number }, words: AsrWord[], ): Promise<{ start: number; end: number; stripMs: number; stripWhy: string }> { const region = await readWavWindow(sourceWav(c.video), c.start - CONTEXT, c.end + CONTEXT); const off = region.from; const shifted = { start: c.start - off, end: c.end - off }; const near = words .filter((w) => w.end > region.from && w.start < region.to) .map((w) => ({ start: w.start - off, end: w.end - off, w: w.w })); const dw = clipWindow(region.x, region.sampleRate, shifted, near); // The stripper runs on the RAW candidate window, then the earlier end wins. // Run on clipWindow's output it fires almost never, because that function has // already pulled the end back -- but it leaves up to one 43ms analysis window // of tail behind, and that residue is the audible pop. let stripMs = 0; let stripWhy = ""; const raw = stripTrailingPop(region.x, region.sampleRate, shifted.start, shifted.end, { words: near, }); const end = Math.min(dw.e, raw.cutMs ? raw.end : dw.e); if (end < dw.e - 0.004 && end - dw.s >= 0.14) { stripMs = Math.round((dw.e - end) * 1000); stripWhy = raw.why; dw.e = end; } return { start: +(dw.s + off).toFixed(4), end: +(dw.e + off).toFixed(4), stripMs, stripWhy }; } // --------------------------------------------------------------------------- // The list. Cheap by construction: no wav is opened here, because a mode can // hold thousands of clips and only one is ever on screen. // --------------------------------------------------------------------------- export async function loadClipList({ mode, limit = 400, only, }: { mode: Mode; limit?: number; /** * Exact clip keys, bypassing the mode's pile. This is how a clip that was * REJECTED can be looked at again -- nothing offers a rejection, and six of * them in the last pass were rejected for the window rather than for the * clip, which is precisely what this tool fixes. */ only?: string[]; }): Promise { const [cands, manifest, st, dates, titles] = await Promise.all([ loadCandidates(), loadManifest(), readState(), readJson>(stateFile("dates.json"), {}), readJson>(stateFile("titles.json"), {}), ]); const accepted = new Set(st.accepted); const rejected = new Set(st.rejected); const unclean = new Set(st.unclean); const risky = await readJson<{ k: string; why: string }[]>(stateFile("risky.json"), []); const riskWhy = new Map(risky.map((r) => [r.k, r.why])); let list: Candidate[]; if (only?.length) { const want = new Set(only); const found = cands.filter((c) => want.has(keyOf(c))); // Keep the order the caller asked for, not cand2's. list = only.map((k) => found.find((c) => keyOf(c) === k)).filter(Boolean) as Candidate[]; } else if (mode === "salvage") { list = cands.filter((c) => unclean.has(keyOf(c))); } else if (mode === "keeps") { // Every clip kept ON PURPOSE for a stray word, whichever code it was given. const want = new Set( Object.entries(st.reasons) .filter(([, v]) => v.verdict === 2 && DELIBERATE_CODES.has(v.code)) .map(([k]) => k), ); // Ones never sided come first; already-sided are still offered, to revise. list = cands .filter((c) => want.has(keyOf(c))) .sort((a, b) => (st.keepside[keyOf(a)] ? 1 : 0) - (st.keepside[keyOf(b)] ? 1 : 0)); } else if (mode === "verify") { // Already-accepted clips that find-risky.mjs flagged, riskiest first, minus // anything settled in an earlier partial pass. const order = new Map(risky.map((r, i) => [r.k, i])); list = cands .filter((c) => order.has(keyOf(c)) && !st.decided[keyOf(c)]) .sort((a, b) => order.get(keyOf(a))! - order.get(keyOf(b))!); } else { // sort: never judged, and not already parked. Machine-accepted (provisional) // clips are excluded -- they are already in the palette. list = cands.filter((c) => { const k = keyOf(c); return !accepted.has(k) && !rejected.has(k) && !unclean.has(k) && dates[c.video]; }); } const total = list.length; // Ordering is FITTED to real verdicts (fit-order.mjs). The old min-margin // heuristic scored AUC 0.488 against 700 graded clips -- no signal at all -- // which is why a "cleanest-first" page came back only 40% good. let orderNote = ""; if (mode === "sort" || mode === "salvage") { const model = await readJson<{ b: number; feats: string[]; w: number[]; mean: Record; sd: Record; testAuc?: number; n?: number; } | null>(stateFile("order-model.json"), null); if (model) { const score = new Map(); for (const c of list) score.set(keyOf(c), scoreWith(model, c, await wordsFor(c.video))); list.sort((a, b) => score.get(keyOf(b))! - score.get(keyOf(a))!); orderNote = `fitted model, held-out AUC ${(model.testAuc ?? 0).toFixed(3)}`; } else { list.sort( (a, b) => (dates[a.video] ?? "").localeCompare(dates[b.video] ?? "") || a.start - b.start, ); orderNote = "date order (no order-model.json)"; } } else { orderNote = mode === "verify" ? "riskiest first" : "unsided first"; } const capped = list.length > limit ? list.slice(0, limit) : list; const clips: ClipStub[] = capped.map((c) => { const key = keyOf(c); const m = manifest.get(key); return { key, video: c.video, title: titles[c.video] ?? m?.title ?? c.video, date: dates[c.video] ?? m?.d ?? "", token: c.token ?? m?.tok ?? "", candStart: c.start, candEnd: c.end, f0: Math.round(c.f0 ?? m?.f0 ?? 0), risk: riskWhy.get(key) ?? m?.risk ?? "", word: st.words[key]?.word, side: st.keepside[key], sourceMissing: !existsSync(sourceWav(c.video)), }; }); return { mode, clips, total, orderNote, counts: { accepted: st.accepted.length, rejected: st.rejected.length, unclean: st.unclean.length, impure: st.impure.length, }, }; } // --------------------------------------------------------------------------- // The detail, fetched one clip at a time. This is where a wav is opened, and // it opens a window, not an episode. // --------------------------------------------------------------------------- export async function loadClipDetail(key: string): Promise { const [cands, manifest, st, dates, titles, salvaged] = await Promise.all([ loadCandidates(), loadManifest(), readState(), readJson>(stateFile("dates.json"), {}), readJson>(stateFile("titles.json"), {}), readSalvaged(), ]); const c = cands.find((x) => keyOf(x) === key); const m = manifest.get(key); if (!c && !m) return null; const video = c?.video ?? m!.v; const candStart = c?.start ?? m!.t; const candEnd = c?.end ?? m!.t + 0.2; const words = await wordsFor(video); const sourceMissing = !existsSync(sourceWav(video)); // Window priority: a hand-set window (absolute, in _salvaged.json, and what // the arranger actually sounds), then the manifest's precomputed default, // then compute one. const hand = (salvaged.candidates as unknown as Candidate[]).find((x) => keyOf(x) === key); let selStart: number; let selEnd: number; let stripMs = m?.cut ?? 0; let stripWhy = m?.why ?? ""; if (hand) { selStart = hand.start; selEnd = hand.end; } else if (m) { selStart = +(m.clipStart + m.selA).toFixed(4); selEnd = +(m.clipStart + m.selB).toFixed(4); } else if (sourceMissing) { selStart = candStart; selEnd = candEnd; } else { const dw = await defaultWindowFor({ video, start: candStart, end: candEnd }, words); selStart = dw.start; selEnd = dw.end; stripMs = dw.stripMs; stripWhy = dw.stripWhy; } let sourceDuration = 0; if (!sourceMissing) { try { sourceDuration = (await wavInfo(sourceWav(video))).duration; } catch { sourceDuration = 0; } } const before = words.filter((w) => w.end <= selStart + 0.05 && w.end > selStart - 0.6).slice(-1)[0]; const after = words.filter((w) => w.start >= selEnd - 0.05 && w.start < selEnd + 0.6)[0]; const risky = await readJson<{ k: string; why: string }[]>(stateFile("risky.json"), []); return { key, video, title: titles[video] ?? m?.title ?? video, date: dates[video] ?? m?.d ?? "", token: c?.token ?? m?.tok ?? "", candStart, candEnd, selStart, selEnd, defaultStart: selStart, defaultEnd: selEnd, sourceDuration, f0: Math.round(c?.f0 ?? m?.f0 ?? 0), // Enough words for the expansion guards and the selector's clip-specific // offers, in absolute seconds. The view can widen well past these; the // guards only ever matter within a second or two of the sound. words: words .filter((w) => w.end > candStart - 15 && w.start < candEnd + 15) .map((w) => ({ start: +w.start.toFixed(3), end: +w.end.toFixed(3), w: w.w })), adjBefore: before?.w ?? m?.adj?.b ?? "", adjAfter: after?.w ?? m?.adj?.a ?? "", wordIn: words.find((w) => w.end > selStart + 0.02 && w.start < selEnd - 0.02)?.w ?? m?.wordIn ?? "", risk: risky.find((r) => r.k === key)?.why ?? m?.risk ?? "", stripMs, stripWhy, word: st.words[key]?.word, sourceMissing, side: st.keepside[key], }; }