"use client"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/utils/ui"; import { getActiveVoiceOverRecordingStream } from "@/voiceover/recorder"; const FFT_SIZE = 1024; const HISTORY_SIZE = 96; export function VoiceOverLiveWaveform({ recording, className, }: { recording: boolean; className?: string; }) { const canvasRef = useRef(null); const levelRef = useRef(0); const historyRef = useRef( Array.from({ length: HISTORY_SIZE }, () => 0), ); const [level, setLevel] = useState(0); useEffect(() => { const canvas = canvasRef.current; if (!canvas || !recording) { drawWaveform({ canvas, history: historyRef.current, level: 0, }); setLevel(0); return; } const stream = getActiveVoiceOverRecordingStream(); if (!stream) { return; } const audioContext = createAudioContext(); const source = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); analyser.fftSize = FFT_SIZE; analyser.smoothingTimeConstant = 0.72; source.connect(analyser); const samples = new Uint8Array(analyser.fftSize); let animationFrame = 0; let lastLevelUpdate = 0; const render = (timestamp: number) => { analyser.getByteTimeDomainData(samples); const rms = getRmsLevel({ samples }); levelRef.current = rms; historyRef.current = [...historyRef.current.slice(1), rms]; drawWaveform({ canvas, history: historyRef.current, level: rms, }); if (timestamp - lastLevelUpdate > 80) { lastLevelUpdate = timestamp; setLevel(rms); } animationFrame = requestAnimationFrame(render); }; animationFrame = requestAnimationFrame(render); return () => { cancelAnimationFrame(animationFrame); source.disconnect(); analyser.disconnect(); void audioContext.close(); }; }, [recording]); return (
); } function getRmsLevel({ samples }: { samples: Uint8Array }): number { let sum = 0; for (let index = 0; index < samples.length; index++) { const centered = ((samples[index] ?? 128) - 128) / 128; sum += centered * centered; } return Math.sqrt(sum / samples.length); } function drawWaveform({ canvas, history, level, }: { canvas: HTMLCanvasElement | null; history: number[]; level: number; }): void { if (!canvas) { return; } const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const width = Math.max(1, Math.round(rect.width * dpr)); const height = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } const ctx = canvas.getContext("2d"); if (!ctx) { return; } ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height); const centerY = height / 2; const step = width / Math.max(1, history.length); ctx.strokeStyle = "rgba(148, 163, 184, 0.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(width, centerY); ctx.stroke(); for (let index = 0; index < history.length; index++) { const value = Math.max(0, history[index] ?? 0); const barHeight = Math.max(1, Math.min(height, value * height * 2.8)); const x = Math.round(index * step); const y = Math.round(centerY - barHeight / 2); ctx.fillStyle = value > 0.62 ? "rgba(239, 68, 68, 0.9)" : value > 0.32 ? "rgba(245, 158, 11, 0.9)" : "rgba(34, 197, 94, 0.9)"; ctx.fillRect(x, y, Math.max(1, Math.floor(step - 1)), barHeight); } if (level > 0.78) { ctx.fillStyle = "rgba(239, 68, 68, 0.18)"; ctx.fillRect(0, 0, width, height); } } function createAudioContext(): AudioContext { const AudioContextConstructor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }) .webkitAudioContext; return new AudioContextConstructor(); }