From f1043734438c8518a302f698d9ae6583a75f0c14 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 16 Aug 2026 06:16:56 +0200 Subject: feat: add transitions, track mixing, and voiceover controls --- apps/web/src/voiceover/live-waveform.tsx | 171 +++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 apps/web/src/voiceover/live-waveform.tsx (limited to 'apps/web/src/voiceover/live-waveform.tsx') diff --git a/apps/web/src/voiceover/live-waveform.tsx b/apps/web/src/voiceover/live-waveform.tsx new file mode 100644 index 00000000..cd25c833 --- /dev/null +++ b/apps/web/src/voiceover/live-waveform.tsx @@ -0,0 +1,171 @@ +"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(); +} -- cgit v1.3.1