"use client"; import { useCallback, useEffect, useRef, useState } from "react"; export type Analysis = { label: string; duration: number; envRate: number; peak: number[]; ratioRate: number; ratio: number[]; flux: number[]; firstSound: number | null; steps: { at: number; gain: number }[]; }; export type Marker = "handover" | "start" | "end"; type Props = { bg: Analysis | null; body: Analysis | null; view: { from: number; to: number }; handover: number; fade: number; start: number; /** Already resolved: 0 never reaches here, the bench passes the body's end. */ end: number; playhead: number | null; onMove: (which: Marker, t: number, dragging: boolean) => void; onScrub: (t: number) => void; height?: number; }; const GRAB = 14; // px // Two lanes, one clock. // // The top lane is the background and the bottom is the song, and the ONE thing // this drawing has to make obvious is where a cue starts in the background -- // because that is the number every handover is really asking for, and it is not // visible in an envelope. The Pokemon route music and its encounter alarm are // within a decibel of each other; on an envelope the alarm does not exist. So // the teal curve over the top lane is the high/low band ratio, which steps from // ~0.2 to ~0.9 exactly where the alarm begins and stays there. That is the // picture that would have saved a wrong render. export default function MixLanes({ bg, body, view, handover, fade, start, end, playhead, onMove, onScrub, height = 210, }: Props) { const ref = useRef(null); const [width, setWidth] = useState(900); const drag = useRef(null); 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); const AXIS = 18; const laneH = (height - AXIS) / 2; const lanes: { a: Analysis | null; top: number; title: string; isBg: boolean }[] = [ { a: bg, top: 0, title: bg ? `background · ${bg.label}` : "background — none loaded", isBg: true }, { a: body, top: laneH, title: body ? `song · ${body.label}` : "song — none loaded", isBg: false }, ]; g.fillStyle = "#1e2732"; // panel-2: this is a well g.fillRect(0, 0, width, height); for (const { a, top, title, isBg } of lanes) { const mid = top + laneH / 2; g.save(); g.beginPath(); g.rect(0, top, width, laneH); g.clip(); g.fillStyle = "#161c24"; g.fillRect(0, top, width, laneH); if (a && a.peak.length) { // The ducked stretch of the background is drawn dimmer, so the lane // shows what will be HEARD rather than what is in the file. for (let x = 0; x < width; x += 1) { const t = toT(x); const i = Math.round(t * a.envRate); if (i < 0 || i >= a.peak.length) continue; const p = a.peak[i]; const h = Math.min(laneH / 2 - 2, p * (laneH / 2 - 2) * 1.6); const audible = !isBg || t < handover + fade; g.fillStyle = audible ? "#4a5d72" : "#28323e"; g.fillRect(x, mid - h, 1, Math.max(1, h * 2)); } } // The measurement curve, and the only teal in the drawing. if (a && isBg && a.ratio.length) { g.strokeStyle = "#56d4c4"; g.lineWidth = 1.5; g.beginPath(); let started = false; for (let x = 0; x < width; x += 1) { const i = Math.round(toT(x) * a.ratioRate); if (i < 0 || i >= a.ratio.length) continue; const v = Math.min(1.6, a.ratio[i]) / 1.6; const y = top + laneH - 3 - v * (laneH - 8); if (started) g.lineTo(x, y); else { g.moveTo(x, y); started = true; } } g.stroke(); // Step candidates: the onsets worth snapping a handover to. for (const s of a.steps) { const x = toX(s.at); if (x < -6 || x > width + 6) continue; g.fillStyle = "rgba(86,212,196,0.85)"; g.beginPath(); g.moveTo(x, top + 2); g.lineTo(x - 4, top + 9); g.lineTo(x + 4, top + 9); g.closePath(); g.fill(); } } // Where the song actually begins -- the number that used to need ffmpeg. if (a && !isBg && a.firstSound != null) { const x = toX(a.firstSound); if (x > -2 && x < width + 2) { g.setLineDash([2, 3]); g.strokeStyle = "rgba(86,212,196,0.7)"; g.lineWidth = 1; g.beginPath(); g.moveTo(x, top); g.lineTo(x, top + laneH); g.stroke(); g.setLineDash([]); } } g.restore(); g.fillStyle = "#8496a8"; g.font = "10px ui-monospace, monospace"; g.fillText(title.toUpperCase(), 6, top + 12); g.strokeStyle = "#2c3743"; g.lineWidth = 1; g.beginPath(); g.moveTo(0, top + laneH + 0.5); g.lineTo(width, top + laneH + 0.5); g.stroke(); } // Outside [start, end] is not in the output at all. g.fillStyle = "rgba(11,14,18,0.62)"; const sx = toX(start); const ex = toX(end); if (sx > 0) g.fillRect(0, 0, sx, height - AXIS); if (ex < width) g.fillRect(ex, 0, width - ex, height - AXIS); // -- the axis ------------------------------------------------------------- g.fillStyle = "#161c24"; g.fillRect(0, height - AXIS, width, AXIS); g.fillStyle = "#8496a8"; g.font = "10px ui-monospace, monospace"; const step = span > 120 ? 30 : span > 60 ? 10 : span > 20 ? 5 : span > 6 ? 1 : 0.5; for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) { const x = toX(t); g.strokeStyle = "#2c3743"; g.beginPath(); g.moveTo(x, height - AXIS); g.lineTo(x, height - AXIS + 4); g.stroke(); g.fillText(t.toFixed(step < 1 ? 1 : 0), x + 3, height - 5); } // -- markers -------------------------------------------------------------- const line = (t: number, colour: string, label: string, wide: boolean) => { const x = toX(t); if (x < -20 || x > width + 20) return; g.strokeStyle = colour; g.lineWidth = wide ? 2 : 1; g.beginPath(); g.moveTo(x, 0); g.lineTo(x, height - AXIS); g.stroke(); g.fillStyle = colour; g.fillRect(x - 5, 0, 10, 7); g.fillRect(x - 5, height - AXIS - 7, 10, 7); g.font = "10px ui-monospace, monospace"; const w = g.measureText(label).width; g.fillText(label, Math.min(width - w - 3, Math.max(3, x + 7)), 20); }; line(start, "#58a6ff", "start", false); line(end, "#58a6ff", "end", false); // The ramp itself, so a fade is a shape rather than a number in a box. if (fade > 0.0005) { const a = toX(handover); const b = toX(handover + fade); g.fillStyle = "rgba(86,212,196,0.16)"; g.fillRect(a, 0, Math.max(1, b - a), height - AXIS); } line(handover, "#56d4c4", `handover ${handover.toFixed(3)}s`, true); if (playhead != null && playhead >= view.from && playhead <= view.to) { g.fillStyle = "#e7edf4"; g.fillRect(toX(playhead), 0, 1, height - AXIS); } }, [width, height, bg, body, view, handover, fade, start, end, playhead, toX, toT]); const pick = (x: number): Marker | null => { const near: [Marker, number][] = [ ["handover", Math.abs(x - toX(handover))], ["start", Math.abs(x - toX(start))], ["end", Math.abs(x - toX(end))], ]; near.sort((a, b) => a[1] - b[1]); return near[0][1] <= GRAB ? near[0][0] : null; }; const at = (clientX: number) => { const cv = ref.current; if (!cv) return 0; const rect = cv.getBoundingClientRect(); return toT(clientX - rect.left); }; return (
{ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); const rect = e.currentTarget.getBoundingClientRect(); const which = pick(e.clientX - rect.left); drag.current = which; if (which) onMove(which, at(e.clientX), true); else onScrub(at(e.clientX)); }} onPointerMove={(e) => { if (drag.current) onMove(drag.current, at(e.clientX), true); }} onPointerUp={(e) => { if (drag.current) { onMove(drag.current, at(e.clientX), false); drag.current = null; } }} onPointerCancel={() => { drag.current = null; }} />
); }