"use client"; import { useEffect, useRef, useState } from "react"; export type CorpusPitch = { lo: number; hi: number; gate: number; bins: number[]; n: number; median: number; p95: number; peak: number; }; type Props = { corpus: CorpusPitch | null; /** This clip's pitch. 0 or missing draws no needle. */ f0: number; /** Every candidate f0 from the SAME source video. */ sourceF0s: number[]; /** Horizontal on a narrow screen, vertical otherwise. */ flat?: boolean; }; // The rail: a permanent instrument, and pure information. // // It answers one question a number on a card cannot: is this clip's pitch // NORMAL for this corpus? The distribution is real (median 103Hz, p95 127Hz), // the 115Hz line is the one arrange-poly.mjs actually cuts flagged sources on, // and the faint marks behind the needle are this source's OWN clips -- because // the guest problem is per-video. A source whose clips fall into two clumps with // the current clip in the high one is a diagnosis; a source forming one clump // is evidence that a high reading is an octave error instead. // // It wears `meter` teal and nothing else. A reading must never be mistaken for // a verdict, which is why none of the three verdict hues appear here. export default function PitchRail({ corpus, f0, sourceF0s, flat = false }: Props) { const ref = useRef(null); const [size, setSize] = useState({ w: 64, h: 320 }); useEffect(() => { const el = ref.current?.parentElement; if (!el) return; const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); ro.observe(el); setSize({ w: el.clientWidth, h: el.clientHeight }); return () => ro.disconnect(); }, []); useEffect(() => { const cv = ref.current; if (!cv || !corpus) return; const { w, h } = size; if (w < 4 || h < 4) return; const dpr = window.devicePixelRatio || 1; cv.width = Math.floor(w * dpr); cv.height = Math.floor(h * dpr); const g = cv.getContext("2d"); if (!g) return; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h); const span = corpus.hi - corpus.lo; // Vertical: high pitch at the top. Flat: high pitch to the right. const pos = (hz: number) => { const t = (hz - corpus.lo) / span; return flat ? t * w : h - t * h; }; const LABEL = flat ? 0 : 30; // room for the Hz numbers down the left const ribbonFrom = LABEL; const ribbonTo = flat ? h : w; g.fillStyle = "#161c24"; g.fillRect(0, 0, w, h); // -- the region above the gate, shaded ------------------------------------ g.fillStyle = "rgba(210,153,34,0.07)"; if (flat) { const x = pos(corpus.gate); g.fillRect(x, 0, w - x, h); } else { g.fillRect(0, 0, w, pos(corpus.gate)); } // -- the corpus, as a density ribbon -------------------------------------- const peak = Math.max(1, corpus.peak); g.fillStyle = "rgba(86,212,196,0.30)"; for (let i = 0; i < corpus.bins.length; i += 1) { const c = corpus.bins[i]; if (!c) continue; // Square-root, not linear: the distribution is sharply peaked and a // linear ribbon shows one spike and nothing either side of it, which is // the part you need to read a clip against. const mag = Math.sqrt(c / peak); const a = pos(corpus.lo + i); const b = pos(corpus.lo + i + 1); if (flat) { const len = mag * (h - 2); g.fillRect(Math.min(a, b), h - len, Math.max(1, Math.abs(b - a)), len); } else { const len = mag * (ribbonTo - ribbonFrom - 2); g.fillRect(ribbonFrom, Math.min(a, b), len, Math.max(1, Math.abs(b - a))); } } // -- this source's own clips, faint marks behind the needle --------------- g.fillStyle = "rgba(231,237,244,0.42)"; for (const s of sourceF0s) { if (s < corpus.lo || s > corpus.hi) continue; const p = pos(s); if (flat) g.fillRect(p, h - 9, 1, 9); else g.fillRect(ribbonTo - 9, p, 9, 1); } // -- ticks: median, p95, and the gate ------------------------------------- g.font = "9px ui-monospace, monospace"; const tick = (hz: number, colour: string, label: string, hard: boolean) => { const p = pos(hz); g.strokeStyle = colour; g.lineWidth = hard ? 1.5 : 1; if (!hard) g.setLineDash([2, 3]); g.beginPath(); if (flat) { g.moveTo(p, 0); g.lineTo(p, h); } else { g.moveTo(ribbonFrom, p); g.lineTo(ribbonTo, p); } g.stroke(); g.setLineDash([]); g.fillStyle = colour; if (flat) g.fillText(label, Math.min(w - 22, p + 2), 9); else g.fillText(label, 2, Math.max(8, Math.min(h - 2, p + 3))); }; tick(corpus.median, "#8496a8", String(Math.round(corpus.median)), false); tick(corpus.p95, "#8496a8", String(Math.round(corpus.p95)), false); tick(corpus.gate, "#d29922", String(corpus.gate), true); // -- this clip, as a bright needle ---------------------------------------- if (f0 > 0) { const clamped = Math.max(corpus.lo, Math.min(corpus.hi, f0)); const p = pos(clamped); g.fillStyle = "#56d4c4"; if (flat) { g.fillRect(p - 1, 0, 2, h); g.beginPath(); g.moveTo(p, h - 1); g.lineTo(p - 5, h - 8); g.lineTo(p + 5, h - 8); g.closePath(); g.fill(); } else { g.fillRect(ribbonFrom, p - 1, ribbonTo - ribbonFrom, 2); g.beginPath(); g.moveTo(ribbonTo - 1, p); g.lineTo(ribbonTo - 8, p - 5); g.lineTo(ribbonTo - 8, p + 5); g.closePath(); g.fill(); } // Off the end of the scale is itself the finding, so say so. if (f0 > corpus.hi || f0 < corpus.lo) { g.fillStyle = "#f85149"; g.font = "9px ui-monospace, monospace"; g.fillText("off scale", flat ? Math.max(2, p - 20) : 2, flat ? h - 12 : Math.max(9, p - 5)); } } }, [corpus, f0, sourceF0s, size, flat]); return (
); }