import { useEffect, useRef } from 'react'; interface CenteredWaveformProps { sourceNode: AudioNode; /** Canvas size in CSS pixels. */ width?: number; height?: number; barWidth?: number; gap?: number; /** Bars are drawn with currentColor — set a text-* class to color them. */ className?: string; } // Voice band fanned out from the center bar (lows) to the edges (highs). const MIN_FREQ_HZ = 100; const MAX_FREQ_HZ = 4500; // Analyser dB range mapped onto bar height; the defaults (-100/-30) waste // most of the range on inaudible levels. const MIN_DB = -75; const MAX_DB = -25; // Per-bar asymmetric smoothing time constants. const ATTACK_MS = 40; const RELEASE_MS = 220; // Edge bars keep a fraction of their response so the whole row stays alive // instead of only the middle moving. const EDGE_RESPONSE = 0.3; /** * Centered live voice indicator, like a meeting app's "speaking" glyph. * Bars fan out symmetrically from the middle: the center tracks the low * frequencies where voice energy lives, the edges track the highs, and a * cosine envelope tapers the response so the shape blooms from the center * while someone speaks and settles into a dot line in silence. * * Renders on canvas with an rAF loop and no React state. No reduced-motion * branch: the animation is signal-bearing audio feedback, not decoration, * and only runs while audio is being captured or played. */ export function CenteredWaveform({ sourceNode, width = 168, height = 56, barWidth = 4, gap = 4, className, }: CenteredWaveformProps) { const canvasRef = useRef(null); useEffect(() => { const canvasEl = canvasRef.current; const ctx2d = canvasEl?.getContext('2d'); if (!canvasEl || !ctx2d) return; // Rebind post-guard so the narrowed types carry into the closures below. const canvas = canvasEl; const drawCtx = ctx2d; const { analyser, detach } = attachAnalyser(sourceNode, 512); analyser.smoothingTimeConstant = 0.8; analyser.minDecibels = MIN_DB; analyser.maxDecibels = MAX_DB; const bins = new Uint8Array(analyser.frequencyBinCount); const stride = barWidth + gap; // Odd count so one bar sits exactly at the center. let barCount = Math.floor((width + gap) / stride); if (barCount % 2 === 0) barCount -= 1; const half = (barCount - 1) / 2; // Map bar k (0 = center) to a frequency bin, log-spaced so the busy low // end of the voice spectrum spreads across several bars. const binHz = sourceNode.context.sampleRate / analyser.fftSize; const binIndex = new Uint16Array(half + 1); for (let k = 0; k <= half; k++) { const freq = MIN_FREQ_HZ * Math.pow(MAX_FREQ_HZ / MIN_FREQ_HZ, half === 0 ? 0 : k / half); binIndex[k] = Math.min(bins.length - 1, Math.round(freq / binHz)); } const levels = new Float32Array(half + 1); const ensureBackingStore = createBackingStoreScaler( canvas, drawCtx, width, height, ); // currentColor, cached and refreshed periodically (theme switches show // within 200ms) instead of paying a computed-style read every frame. let fillColor = ''; let lastColorTime = 0; let lastTime = performance.now(); let rafId = 0; function tick() { const now = performance.now(); const dt = now - lastTime; lastTime = now; analyser.getByteFrequencyData(bins); for (let k = 0; k <= half; k++) { const raw = bins[binIndex[k]] / 255; const envelope = half === 0 ? 1 : EDGE_RESPONSE + (1 - EDGE_RESPONSE) * Math.cos(((k / half) * Math.PI) / 2); // Gamma keeps the floor quiet so silence reads as a dot line. const target = Math.pow(raw, 1.4) * envelope; // Asymmetric exponential smoothing (frame-rate independent) const timeConstant = target > levels[k] ? ATTACK_MS : RELEASE_MS; const alpha = 1 - Math.exp(-dt / timeConstant); levels[k] += alpha * (target - levels[k]); } if (ensureBackingStore() || !fillColor || now - lastColorTime > 200) { fillColor = getComputedStyle(canvas).color; lastColorTime = now; } drawCtx.clearRect(0, 0, width, height); drawCtx.fillStyle = fillColor; const centerX = width / 2 - barWidth / 2; drawCtx.beginPath(); for (let k = 0; k <= half; k++) { const h = Math.max(barWidth, levels[k] * height); const y = (height - h) / 2; drawCtx.roundRect(centerX + k * stride, y, barWidth, h, barWidth / 2); if (k > 0) { drawCtx.roundRect(centerX - k * stride, y, barWidth, h, barWidth / 2); } } drawCtx.fill(); rafId = requestAnimationFrame(tick); } rafId = requestAnimationFrame(tick); return () => { cancelAnimationFrame(rafId); detach(); }; }, [sourceNode, width, height, barWidth, gap]); return ( ); } /** * Connects an AnalyserNode to the source for visualization. * * The analyser is routed to the destination via a silent gain node — without * this, Chromium suspends processing on disconnected audio graphs. */ function attachAnalyser( sourceNode: AudioNode, fftSize: number, ): { analyser: AnalyserNode; detach: () => void } { const ctx = sourceNode.context; const analyser = ctx.createAnalyser(); analyser.fftSize = fftSize; sourceNode.connect(analyser); const silentGain = ctx.createGain(); silentGain.gain.value = 0; analyser.connect(silentGain); silentGain.connect(ctx.destination); return { analyser, detach() { try { sourceNode.disconnect(analyser); analyser.disconnect(silentGain); silentGain.disconnect(ctx.destination); } catch { // Nodes may already be disconnected } }, }; } /** * Keeps the canvas backing store sized to width × height CSS pixels at the * current devicePixelRatio, rescaling if the DPR changes (window moved * between monitors). Call the returned function each frame; it returns true * when the store was (re)initialized so callers can refresh cached state. */ function createBackingStoreScaler( canvas: HTMLCanvasElement, drawCtx: CanvasRenderingContext2D, width: number, height: number, ): () => boolean { let dpr = 0; return () => { const currentDpr = window.devicePixelRatio || 1; if (currentDpr === dpr) return false; dpr = currentDpr; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); drawCtx.setTransform(dpr, 0, 0, dpr, 0, 0); return true; }; }