import { readdir } from "node:fs/promises"; import { dataFile, stateFile } from "./paths"; import { readJson, readState } from "./state"; import type { Candidate } from "./types"; // --------------------------------------------------------------------------- // What the corpus SOUNDS LIKE, in pitch. // // Every clip is judged against a distribution nobody can see. The numbers are // well known -- median 103Hz, p95 127Hz, and arrange-poly cuts flagged sources // at 115Hz -- but they live in comments and shell variables, so a clip at 173Hz // looks like any other number on a card. Putting the distribution on screen is // what turns "f0 173" into "far outside everything else in this corpus". // // Two sources of the number, in preference order: // // corepitch.json f0 as MEASURED on the accepted window. Authoritative, but // only exists for clips that have been through the salvage // machinery. // cand2/ the candidate's own f0 from mining, for accepted clips // that never went through salvage. // // Both are filtered to the ACCEPTED set -- see the note in corpusPitch, which // is where the choice actually bites. // --------------------------------------------------------------------------- /** The candidate range the arranger itself gates on. */ export const F0_LO = 70; export const F0_HI = 200; /** SUSPECT_F0 -- the line arrange-poly.mjs actually cuts flagged sources on. */ export const F0_GATE = 115; export type CorpusPitch = { lo: number; hi: number; gate: number; /** Counts per 1Hz bin from lo (inclusive) to hi (exclusive). */ bins: number[]; n: number; median: number; p95: number; /** The tallest bin, so a client can scale the ribbon without a second pass. */ peak: number; }; const pct = (sorted: number[], q: number) => sorted.length ? sorted[Math.max(0, Math.min(sorted.length - 1, Math.floor(q * (sorted.length - 1))))] : 0; let cached: { at: number; value: CorpusPitch } | null = null; /** Re-read at most this often. The corpus moves by a clip at a time. */ const TTL_MS = 60_000; export async function corpusPitch(): Promise { if (cached && Date.now() - cached.at < TTL_MS) return cached.value; // THE ACCEPTED PALETTE, not every candidate. // // Which set is measured changes the answer materially, and the reference has // to be the one a clip is really being judged against. Over all candidates // this corpus reads median 105.3 / p95 151.6 -- but that includes everything // already rejected, much of it rejected FOR being the wrong pitch, so it // describes the mining output rather than the palette. Over the accepted set // it reads ~103 / ~127, which is the figure arrange-poly's 115Hz gate was // chosen against and the one quoted everywhere else in this project. // // Using the candidate distribution would make the rail flatter and push p95 // up by 25Hz, so a genuinely unusual clip would look ordinary -- the opposite // of what the instrument is for. const st = await readState(); const accepted = new Set(st.accepted); const core = await readJson>( stateFile("corepitch.json"), {}, ); const f0s: number[] = []; const seen = new Set(); // corepitch is f0 as MEASURED on the accepted window, so it wins where it exists. for (const [k, v] of Object.entries(core)) { if (accepted.has(k) && v?.f0 > 0) { f0s.push(v.f0); seen.add(k); } } try { const dir = dataFile("cand2"); for (const f of (await readdir(dir)).filter((x) => x.endsWith(".json"))) { const one = await readJson<{ candidates: Candidate[] }>(`${dir}/${f}`, { candidates: [] }); for (const c of one.candidates ?? []) { const k = `${c.video}@${(+c.start).toFixed(2)}`; if (seen.has(k) || !accepted.has(k)) continue; seen.add(k); if ((c.f0 ?? 0) > 0) f0s.push(c.f0 as number); } } } catch { /* no cand2 in a fixture is fine -- corepitch alone still draws a ribbon */ } const bins = new Array(F0_HI - F0_LO).fill(0); for (const f of f0s) { const i = Math.floor(f) - F0_LO; if (i >= 0 && i < bins.length) bins[i] += 1; } const sorted = f0s.slice().sort((a, b) => a - b); const value: CorpusPitch = { lo: F0_LO, hi: F0_HI, gate: F0_GATE, bins, n: f0s.length, median: +pct(sorted, 0.5).toFixed(1), p95: +pct(sorted, 0.95).toFixed(1), peak: bins.reduce((a, b) => (b > a ? b : a), 0), }; cached = { at: Date.now(), value }; return value; } export type SourcePitch = { f0s: number[]; n: number; lo: number; hi: number; /** The full spread, in semitones -- the number that is actually diagnostic. */ spread: number; wide: boolean; }; /** * Every candidate f0 for ONE source, and HOW FAR APART they are. * * This is the mark that makes the rail load-bearing rather than decorative: the * guest problem is per video, so what this source's own clips look like is the * evidence. * * It reports a SPREAD rather than a cluster count, and that is a correction. A * gap-based splitter looked obvious -- "start a new cluster wherever the gap * exceeds 18Hz" -- and it does not work on the very source it was written for: * vfMRAdhSSrc is documented as splitting 78.9-92.8Hz (Jer) and 125-218Hz (the * guest), but its 29 clips fill the space between well enough that any gap * threshold loose enough to be robust merges them into one clump of 27. The * splitter would have reported "one clump" for the textbook two-speaker source, * which is worse than saying nothing. * * The spread survives that. 78.7-218Hz is 17.6 semitones from one speaker's * filler sounds, which is not plausible however the values are distributed -- * and it stays true whether the second speaker's clips are sparse or dense. * Above an octave is the flag; the rail's own marks show the shape. */ export async function sourcePitch(video: string): Promise { const one = await readJson<{ candidates: Candidate[] }>(dataFile("cand2", `${video}.json`), { candidates: [], }); const f0s = (one.candidates ?? []) .map((c) => c.f0 ?? 0) .filter((f) => f > 0) .sort((a, b) => a - b); if (!f0s.length) return { f0s: [], n: 0, lo: 0, hi: 0, spread: 0, wide: false }; const lo = f0s[0]; const hi = f0s[f0s.length - 1]; const spread = 12 * Math.log2(hi / lo); return { f0s: f0s.map((f) => +f.toFixed(1)), n: f0s.length, lo: +lo.toFixed(1), hi: +hi.toFixed(1), spread: +spread.toFixed(1), // An octave. One person's ums do not span one; two people's easily do. wide: spread > 12 && f0s.length >= 6, }; }