#!/usr/bin/env node // One-off: recover the word labels trapped in the free-text (code 99) notes. // // Before the triage tool had a word selector, the only way to say what a clip // actually contained was a note. One 284-clip pass produced "do uh", "to uh", // "when uhh", "uhh literally", "duuhh", "uh-huh" and three separate clean // "and"s -- all real human judgement, none of it filterable, because // arrange-poly.mjs gates on the ASR token and never sees a note. // // This turns the unambiguous ones into words.json entries. It is deliberately // conservative and it PRINTS EVERY SKIP: a wrong label is worse than a missing // one, because the arranger would then act on it. // // node notes-to-words.mjs report only // node notes-to-words.mjs --write write words.json import { readFileSync, writeFileSync, existsSync } from "node:fs"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); const WRITE = process.argv.includes("--write"); const load = (f, d) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return d; } }; const reasons = load(path.join(DIR, "reasons.json"), {}); const salvaged = load(path.join(SONG_DATA, "cand2", "_salvaged.json"), { candidates: [] }); // A note about a WINDOW is not a note about a word. These are exactly the clips // the served-audio tool can now rescue, so labelling them from a complaint // about the old boundary would bake in the wrong answer. const WINDOW_COMPLAINT = /cut ?off|cut uh|cut "uh"/i; // The quoted phrase is the label. Prose around it is commentary. const QUOTED = /[""„«]?"([^"]{1,40})"[""»]?/; // A note with no quotes at all is only usable when it IS the word: one short // token, no sentence. const BARE_WORD = /^[A-Za-z'’\-.]{2,20}$/; const rows = []; const skips = []; for (const [key, v] of Object.entries(reasons)) { if (v.code !== 99) continue; const text = (v.text ?? "").trim(); if (!text) { skips.push([key, text, "no text"]); continue; } if (WINDOW_COMPLAINT.test(text)) { skips.push([key, text, "complains about the window, not the word"]); continue; } const quotes = text.match(/"/g)?.length ?? 0; let word = null; let how = ""; if (quotes === 2) { const m = text.match(QUOTED); if (m) { word = m[1].trim(); how = "quoted phrase"; } } else if (quotes === 0 && BARE_WORD.test(text)) { word = text; how = "bare word"; } else if (quotes > 2) { skips.push([key, text, `${quotes / 2} quoted phrases -- ambiguous`]); continue; } if (!word) { skips.push([key, text, "no unambiguous word in it"]); continue; } // "clean", "Isolated", "long" and friends describe the clip, not the word. // "literal" is deliberately NOT in this list: "Literal uh" means the word was // spoken AS a word rather than as filler, which is exactly the distinction the // arranger needs, and stripping it would flatten the label to plain "uh". word = word.replace(/^(clean|isolated|quick|long)\s+/i, "").trim(); if (!word) { skips.push([key, text, "nothing left after dropping the qualifier"]); continue; } if (word.split(/\s+/).length > 4) { skips.push([key, text, "longer than four words"]); continue; } rows.push({ key, word, how, text }); } // A re-trimmed clip lives in the palette under a NEW key, so the label has to be // written there too or the arranger never finds it. const alias = new Map(); for (const c of salvaged.candidates ?? []) { const from = c.salvagedFrom ?? c.sortedFrom; if (!from) continue; alias.set(from, `${c.video}@${(+c.start).toFixed(2)}`); } const words = load(path.join(DIR, "words.json"), {}); let written = 0; for (const r of rows) { const entry = { word: r.word, from: "note" }; words[r.key] = entry; written += 1; const nk = alias.get(r.key); if (nk && nk !== r.key) { words[nk] = entry; written += 1; } } console.log(`converted ${rows.length} notes into ${written} label entries`); for (const r of rows) console.log(` ${r.key.padEnd(26)} ${JSON.stringify(r.word).padEnd(22)} <- ${r.text}`); console.log(`\nskipped ${skips.length}:`); for (const [key, text, why] of skips) console.log(` ${key.padEnd(26)} ${why.padEnd(42)} ${JSON.stringify(text)}`); if (WRITE) { writeFileSync(path.join(DIR, "words.json"), JSON.stringify(words, null, 1)); console.log(`\nwrote words.json (${Object.keys(words).length} labels)`); } else { console.log(`\n(report only -- pass --write to save)`); } if (!existsSync(path.join(SONG_DATA, "cand2"))) { console.log("note: SONG_DATA/cand2 not found, so re-trimmed aliases were not resolved"); }