"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { AsrWord } from "@/lib/types"; export type Peaks = { from: number; to: number; min: number[]; max: number[] }; type Props = { view: { from: number; to: number }; sel: { from: number; to: number }; cand: { from: number; to: number }; words: AsrWord[]; peaks: Peaks | null; playhead: number | null; /** * Sounds ALREADY taken from this same window, with the verdict each was * given. Drawn so a second pass over one window cannot take the same sound * twice, and so what has been banked is visible while choosing the next. */ captured?: { from: number; to: number; verdict: number }[]; /** * `edge` is WHICH HANDLE moved, and it is not decoration: a caller that * auditions the change has to play the end when the end moved, or moving the * end plays the start and tells you nothing. Optional, so the callers that * do not care are unchanged. */ onSel: ( sel: { from: number; to: number }, dragging: boolean, edge?: "start" | "end" | null, ) => void; /** A handle dragged to the edge asks for more audio on that side. */ onReachEdge: (side: "start" | "end") => void; height?: number; }; const HANDLE_GRAB = 10; // px export default function Waveform({ view, sel, cand, words, peaks, playhead, captured, onSel, onReachEdge, height = 168, }: Props) { const ref = useRef(null); const [width, setWidth] = useState(900); const drag = useRef<"start" | "end" | null>(null); const edgeFired = useRef(false); useEffect(() => { const el = ref.current?.parentElement; if (!el) return; const ro = new ResizeObserver(() => setWidth(el.clientWidth)); ro.observe(el); setWidth(el.clientWidth); return () => ro.disconnect(); }, []); const span = Math.max(1e-6, view.to - view.from); const toX = useCallback((t: number) => ((t - view.from) / span) * width, [view.from, span, width]); const toT = useCallback((x: number) => view.from + (x / width) * span, [view.from, span, width]); useEffect(() => { const cv = ref.current; if (!cv) return; const dpr = window.devicePixelRatio || 1; cv.width = Math.max(1, Math.floor(width * dpr)); cv.height = Math.floor(height * dpr); const g = cv.getContext("2d"); if (!g) return; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, width, height); // ground g.fillStyle = "#0f141a"; g.fillRect(0, 0, width, height); const mid = height / 2; // outside the selection is dimmed, so the selection reads without a border const sx = toX(sel.from); const ex = toX(sel.to); g.fillStyle = "#131b24"; g.fillRect(Math.max(0, sx), 0, Math.max(0, ex - sx), height); // waveform if (peaks && peaks.max.length) { const n = peaks.max.length; // The peaks cover [peaks.from, peaks.to], which may be narrower than the // view when the window was clamped at an episode edge. const pSpan = Math.max(1e-6, peaks.to - peaks.from); for (let i = 0; i < n; i += 1) { const t0 = peaks.from + (i / n) * pSpan; const x = toX(t0); if (x < -2 || x > width + 2) continue; const inSel = t0 >= sel.from && t0 <= sel.to; g.fillStyle = inSel ? "#e7edf4" : "#3d4b5a"; const hi = peaks.max[i]; const lo = peaks.min[i]; const y0 = mid - hi * (mid - 6); const y1 = mid - lo * (mid - 6); g.fillRect(x, Math.min(y0, y1), Math.max(1, width / n), Math.max(1, Math.abs(y1 - y0))); } } else { g.fillStyle = "#26313d"; g.fillRect(0, mid - 1, width, 2); } // ASR words, so a window that is about to swallow one says so g.font = "11px ui-monospace, monospace"; for (const w of words) { if (w.end < view.from || w.start > view.to) continue; const a = toX(w.start); const b = toX(w.end); g.fillStyle = "rgba(88,166,255,0.10)"; g.fillRect(a, height - 22, Math.max(1, b - a), 22); g.fillStyle = "#6f8296"; g.fillText(w.w, a + 3, height - 7); } // Whisper's own detection, as a dashed reference -- the window may be well // outside it, and that is the point. g.setLineDash([3, 3]); g.strokeStyle = "#4a5a6b"; g.lineWidth = 1; g.beginPath(); g.moveTo(toX(cand.from), 0); g.lineTo(toX(cand.from), height); g.moveTo(toX(cand.to), 0); g.lineTo(toX(cand.to), height); g.stroke(); g.setLineDash([]); // Sounds already taken from this window. Each wears its VERDICT's colour -- // the one place a verdict hue is right outside the verdict row, because // that is exactly what is being reported: what this span was judged to be. for (const c of captured ?? []) { const a = toX(c.from); const b = toX(c.to); if (b < -2 || a > width + 2) continue; const colour = c.verdict === 1 ? "#3fb950" : c.verdict === 0 ? "#f85149" : "#d29922"; g.fillStyle = `${colour}22`; g.fillRect(a, 0, Math.max(1, b - a), height); g.fillStyle = colour; g.fillRect(a, 0, Math.max(1, b - a), 3); g.fillRect(a, 0, 1, height); g.fillRect(b - 1, 0, 1, height); g.font = "10px ui-monospace, monospace"; g.fillText("taken", a + 3, 14); } // handles for (const [x, colour] of [ [sx, "#58a6ff"], [ex, "#58a6ff"], ] as const) { g.fillStyle = colour; g.fillRect(x - 1, 0, 2, height); g.fillRect(x - 4, 0, 8, 10); g.fillRect(x - 4, height - 10, 8, 10); } if (playhead != null && playhead >= view.from && playhead <= view.to) { g.fillStyle = "#f0f6fc"; g.fillRect(toX(playhead), 0, 1, height); } }, [width, height, peaks, sel, cand, words, view, toX, playhead, captured]); const pick = (x: number): "start" | "end" => { const ds = Math.abs(x - toX(sel.from)); const de = Math.abs(x - toX(sel.to)); if (ds <= HANDLE_GRAB || de <= HANDLE_GRAB) return ds <= de ? "start" : "end"; // Clicking away from both handles moves the nearer one, rather than doing // nothing -- coarse positioning without hunting for a 10px target. return ds <= de ? "start" : "end"; }; const move = (clientX: number, dragging: boolean) => { const cv = ref.current; if (!cv) return; const rect = cv.getBoundingClientRect(); const x = clientX - rect.left; const t = toT(x); const which = drag.current!; let next = { ...sel }; if (which === "start") next.from = Math.min(t, sel.to - 0.02); else next.to = Math.max(t, sel.from + 0.02); onSel(next, dragging, which); // Dragged to within 2% of the view edge: ask for another second on that // side. This is what makes the window unbounded in practice -- you pull and // it keeps giving, rather than stopping at a boundary set at build time. const frac = x / width; if (!edgeFired.current) { if (which === "start" && frac < 0.02) { edgeFired.current = true; onReachEdge("start"); } else if (which === "end" && frac > 0.98) { edgeFired.current = true; onReachEdge("end"); } } else if (frac > 0.06 && frac < 0.94) { edgeFired.current = false; } }; return (
{ (e.target as HTMLElement).setPointerCapture(e.pointerId); const rect = (e.target as HTMLElement).getBoundingClientRect(); drag.current = pick(e.clientX - rect.left); edgeFired.current = false; move(e.clientX, true); }} onPointerMove={(e) => { if (drag.current) move(e.clientX, true); }} onPointerUp={(e) => { if (drag.current) { move(e.clientX, false); drag.current = null; } }} />
); }