summaryrefslogtreecommitdiff
path: root/apps/web/src/voiceover/live-waveform.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/voiceover/live-waveform.tsx')
-rw-r--r--apps/web/src/voiceover/live-waveform.tsx171
1 files changed, 171 insertions, 0 deletions
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<HTMLCanvasElement>(null);
+ const levelRef = useRef(0);
+ const historyRef = useRef<number[]>(
+ 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 (
+ <div className={cn("space-y-2", className)}>
+ <div className="bg-muted relative h-16 overflow-hidden rounded-sm border">
+ <canvas ref={canvasRef} className="absolute inset-0 size-full" />
+ </div>
+ <div className="bg-muted h-1.5 overflow-hidden rounded-full">
+ <div
+ className="bg-primary h-full rounded-full transition-[width]"
+ style={{ width: `${Math.min(100, Math.round(level * 140))}%` }}
+ />
+ </div>
+ </div>
+ );
+}
+
+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();
+}