diff --git a/ui/src/components/VoiceMicButton.tsx b/ui/src/components/VoiceMicButton.tsx new file mode 100644 index 00000000..6fa8a566 --- /dev/null +++ b/ui/src/components/VoiceMicButton.tsx @@ -0,0 +1,60 @@ +import { Mic, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { VoiceWaveform } from "./VoiceWaveform"; +import { useVadRecorder } from "../hooks/useVadRecorder"; + +interface VoiceMicButtonProps { + onTranscript: (text: string) => void; + disabled?: boolean; +} + +export function VoiceMicButton({ onTranscript, disabled }: VoiceMicButtonProps) { + const { state, start, stop, mediaStream } = useVadRecorder({ onTranscript }); + + // Idle state (also used when disabled) + if (state === "idle") { + return ( + + ); + } + + // Recording state — show waveform with primary ring, click to stop + if (state === "recording") { + return ( + + ); + } + + // Processing state — transcribing, disabled + return ( + + ); +} diff --git a/ui/src/components/VoiceWaveform.tsx b/ui/src/components/VoiceWaveform.tsx new file mode 100644 index 00000000..b1d4f2ab --- /dev/null +++ b/ui/src/components/VoiceWaveform.tsx @@ -0,0 +1,98 @@ +import { useRef, useEffect } from "react"; + +interface VoiceWaveformProps { + stream: MediaStream | null; + active: boolean; // controls animation loop +} + +export function VoiceWaveform({ stream, active }: VoiceWaveformProps) { + const canvasRef = useRef(null); + const audioCtxRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const animFrameRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !stream || !active) return; + + // Create or resume AudioContext (lazily — reused across start/stop cycles) + if (!audioCtxRef.current) { + audioCtxRef.current = new AudioContext(); + } + const audioCtx = audioCtxRef.current; + + if (audioCtx.state === "suspended") { + audioCtx.resume(); + } + + // Set up analyser + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 64; // 32 frequency bins + analyserRef.current = analyser; + + const source = audioCtx.createMediaStreamSource(stream); + sourceRef.current = source; + source.connect(analyser); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); // 32 bins + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + const ctx2d = canvas.getContext("2d"); + + // Get primary color from CSS variable + const primaryColor = + getComputedStyle(document.documentElement).getPropertyValue("--primary").trim() || + "#1e66f5"; + + const draw = () => { + analyser.getByteFrequencyData(dataArray); + + if (ctx2d) { + ctx2d.clearRect(0, 0, canvasWidth, canvasHeight); + ctx2d.fillStyle = primaryColor; + + // Draw 20 bars, skipping every other bin (using bins 0, 2, 4, ... 38) + const barCount = 20; + const barWidth = 2; + const barGap = 2; + const totalWidth = barCount * barWidth + (barCount - 1) * barGap; + const startX = Math.floor((canvasWidth - totalWidth) / 2); + + for (let i = 0; i < barCount; i++) { + const binValue = dataArray[i * 2] ?? 0; + const barHeight = Math.max(2, (binValue / 255) * canvasHeight); + const x = startX + i * (barWidth + barGap); + const y = canvasHeight - barHeight; + ctx2d.fillRect(x, y, barWidth, barHeight); + } + } + + animFrameRef.current = requestAnimationFrame(draw); + }; + + animFrameRef.current = requestAnimationFrame(draw); + + return () => { + // Cleanup on unmount or when active becomes false + if (animFrameRef.current !== null) { + cancelAnimationFrame(animFrameRef.current); + animFrameRef.current = null; + } + source.disconnect(); + sourceRef.current = null; + analyserRef.current = null; + // Do NOT close AudioContext — reuse across start/stop cycles + }; + }, [stream, active]); + + return ( +