"use client"; import { Check, Loader2, Mic, MicOff, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, type Variants, } from "motion/react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { TextShimmer } from "@/components/motion/text-shimmer"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type VoiceInputState = | "idle" | "requesting" | "recording" | "processing" | "done" | "error"; export type VoiceInputMode = "recorder" | "speech"; export interface VoiceInputProps { /** * Turns the recorded audio into text. Receives the MediaRecorder blob (an * empty blob when `simulate` is on). Not called when `mode="speech"` and the * browser's SpeechRecognition API handled the transcript. */ onTranscribe?: (audio: Blob) => Promise; /** Called with the final transcript when the done state is reached. */ onTranscript?: (text: string) => void; /** Called when recording or transcription is cancelled. */ onCancel?: () => void; onStateChange?: (state: VoiceInputState) => void; onError?: (error: unknown) => void; /** * `recorder` captures audio and delegates to `onTranscribe`. `speech` uses * the browser SpeechRecognition API when available, falling back to * `recorder` otherwise. */ mode?: VoiceInputMode; /** BCP-47 language for `mode="speech"`. Defaults to the browser language. */ lang?: string; /** Fake an animated waveform without touching the microphone. */ simulate?: boolean; /** Number of waveform bars. */ bars?: number; /** Auto-confirm after this many seconds of recording. */ maxDuration?: number; /** How long the done state shows the transcript before returning to idle (ms). */ doneDuration?: number; disabled?: boolean; className?: string; } /* ------------------------------------------------------------------------ */ /* Browser API shapes (SpeechRecognition is not in every TS DOM lib). */ /* ------------------------------------------------------------------------ */ interface SpeechAlternativeLike { transcript: string; } interface SpeechResultLike { readonly isFinal: boolean; readonly length: number; readonly [index: number]: SpeechAlternativeLike; } interface SpeechEventLike { resultIndex: number; results: ArrayLike; } interface SpeechRecognitionLike { continuous: boolean; interimResults: boolean; lang: string; onresult: ((event: SpeechEventLike) => void) | null; onerror: ((event: { error: string }) => void) | null; onend: (() => void) | null; start(): void; stop(): void; abort(): void; } type SpeechRecognitionCtor = new () => SpeechRecognitionLike; function getSpeechRecognition(): SpeechRecognitionCtor | undefined { if (typeof window === "undefined") return undefined; const w = window as unknown as { SpeechRecognition?: SpeechRecognitionCtor; webkitSpeechRecognition?: SpeechRecognitionCtor; }; return w.SpeechRecognition ?? w.webkitSpeechRecognition; } function getAudioContext(): typeof AudioContext | undefined { if (typeof window === "undefined") return undefined; return ( window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }) .webkitAudioContext ); } /* ------------------------------------------------------------------------ */ /* Resource bookkeeping — everything that must be released lives here. */ /* ------------------------------------------------------------------------ */ type Resources = { stream?: MediaStream; ctx?: AudioContext; analyser?: AnalyserNode; recorder?: MediaRecorder; chunks: Blob[]; recognition?: SpeechRecognitionLike; speechFinal: string; speechInterim: string; raf?: number; tick?: number; maxTimer?: number; }; function freshResources(): Resources { return { chunks: [], speechFinal: "", speechInterim: "" }; } function stopLoops(r: Resources) { if (r.raf !== undefined) cancelAnimationFrame(r.raf); if (r.tick !== undefined) window.clearInterval(r.tick); if (r.maxTimer !== undefined) window.clearTimeout(r.maxTimer); r.raf = undefined; r.tick = undefined; r.maxTimer = undefined; } function release(r: Resources) { stopLoops(r); if (r.recorder) { r.recorder.ondataavailable = null; r.recorder.onstop = null; if (r.recorder.state !== "inactive") { try { r.recorder.stop(); } catch { // already stopped } } } if (r.recognition) { r.recognition.onresult = null; r.recognition.onerror = null; r.recognition.onend = null; try { r.recognition.abort(); } catch { // already ended } } if (r.stream) { for (const track of r.stream.getTracks()) track.stop(); } if (r.ctx && r.ctx.state !== "closed") r.ctx.close().catch(() => {}); r.stream = undefined; r.ctx = undefined; r.analyser = undefined; r.recorder = undefined; r.recognition = undefined; } function stopRecorder(r: Resources): Promise { const recorder = r.recorder; const type = recorder?.mimeType || "audio/webm"; if (!recorder || recorder.state === "inactive") { return Promise.resolve(new Blob(r.chunks, { type })); } return new Promise((resolve) => { recorder.onstop = () => resolve(new Blob(r.chunks, { type })); recorder.stop(); }); } /** Stop recognition and wait for its final results (bounded). */ function stopRecognition(r: Resources): Promise { const recognition = r.recognition; const read = () => `${r.speechFinal} ${r.speechInterim}`.trim(); if (!recognition) return Promise.resolve(read()); return new Promise((resolve) => { const timer = window.setTimeout(() => resolve(read()), 1500); recognition.onend = () => { window.clearTimeout(timer); resolve(read()); }; try { recognition.stop(); } catch { window.clearTimeout(timer); resolve(read()); } }); } function errorMessage(error: unknown) { const name = error instanceof DOMException ? error.name : ""; if (name === "NotAllowedError" || name === "SecurityError") { return "Microphone access was blocked."; } if (name === "NotFoundError" || name === "OverconstrainedError") { return "No microphone was found."; } if (name === "NotReadableError") return "The microphone is in use."; return "Couldn't start the microphone."; } function formatTime(seconds: number) { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, "0")}`; } /** RMS of the time-domain signal, mapped to a perceptual 0..1 level. */ function readLevel(analyser: AnalyserNode, buffer: Uint8Array) { analyser.getByteTimeDomainData(buffer); let sum = 0; for (const sample of buffer) { const v = (sample - 128) / 128; sum += v * v; } const rms = Math.sqrt(sum / buffer.length); return Math.min(1, Math.sqrt(rms) * 1.6); } /** Speech-like bursts: a slow envelope over faster syllable wobble + noise. */ function simulatedLevel(time: number) { const t = time / 1000; const envelope = 0.5 + 0.5 * Math.sin(t * 1.3) * Math.sin(t * 0.7 + 1); const syllable = Math.abs(Math.sin(t * 7.3)) * 0.6 + Math.abs(Math.sin(t * 3.1)) * 0.4; return Math.min(1, Math.max(0.04, envelope * syllable * 0.95 + Math.random() * 0.18)); } const BAR_FLOOR = 0.12; /** Waveform history step. Reduced motion samples slower so bars move less. */ const SAMPLE_MS = 55; const SAMPLE_MS_REDUCED = 180; /* ------------------------------------------------------------------------ */ /* Motion */ /* ------------------------------------------------------------------------ */ const CONTENT_VARIANTS: Variants = { initial: { opacity: 0, filter: "blur(4px)", x: -6 }, animate: { opacity: 1, filter: "blur(0px)", x: 0, transition: { ...SPRING_SWAP, opacity: { duration: 0.18, delay: 0.04 } }, }, exit: { opacity: 0, filter: "blur(4px)", transition: { duration: 0.12, ease: EASE_OUT }, }, }; const ICON_VARIANTS: Variants = { initial: { opacity: 0, scale: 0.4, filter: "blur(6px)" }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: SPRING_SWAP }, exit: { opacity: 0, scale: 0.4, filter: "blur(6px)", transition: { duration: 0.12, ease: EASE_OUT }, }, }; const FADE_VARIANTS: Variants = { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.15 } }, exit: { opacity: 0, transition: { duration: 0.1 } }, }; /* ------------------------------------------------------------------------ */ function PillButton({ label, onClick, tone = "muted", children, }: { label: string; onClick: () => void; tone?: "muted" | "primary"; children: ReactNode; }) { const reduce = useReducedMotion() ?? false; return ( {children} ); } export function VoiceInput({ onTranscribe, onTranscript, onCancel, onStateChange, onError, mode = "recorder", lang, simulate = false, bars = 24, maxDuration, doneDuration = 2400, disabled = false, className, }: VoiceInputProps) { const reduce = useReducedMotion() ?? false; const [state, setState] = useState("idle"); const [elapsed, setElapsed] = useState(0); const [error, setError] = useState(); const [transcript, setTranscript] = useState(""); const micRef = useRef(null); const barRefs = useRef<(HTMLSpanElement | null)[]>([]); const history = useRef([]); const resources = useRef(freshResources()); const session = useRef(0); const doneTimer = useRef(undefined); const phase = useRef("idle"); // Latest props for async flows that outlive the render that started them. const latest = useRef({ onTranscribe, onTranscript, onCancel, onStateChange, onError, mode, lang, simulate, maxDuration, doneDuration, reduce, }); latest.current = { onTranscribe, onTranscript, onCancel, onStateChange, onError, mode, lang, simulate, maxDuration, doneDuration, reduce, }; const barCount = Math.max(4, Math.floor(bars)); useEffect(() => { const r = resources.current; return () => { session.current++; release(r); if (doneTimer.current) window.clearTimeout(doneTimer.current); }; }, []); const transition = (next: VoiceInputState) => { phase.current = next; setState(next); latest.current.onStateChange?.(next); }; const fail = (message: string, cause: unknown) => { release(resources.current); setError(message); transition("error"); latest.current.onError?.(cause); }; const paint = () => { const values = history.current; barRefs.current.forEach((el, i) => { if (!el) return; const v = values[i] ?? 0; el.style.transform = `scaleY(${BAR_FLOOR + v * (1 - BAR_FLOOR)})`; }); }; const beginRecording = ( id: number, stream: MediaStream | null, speech: SpeechRecognitionCtor | undefined, ) => { const r = resources.current; const { simulate: fake, lang: language } = latest.current; if (stream) { r.stream = stream; const Ctx = getAudioContext(); if (Ctx) { try { const ctx = new Ctx(); const analyser = ctx.createAnalyser(); analyser.fftSize = 512; ctx.createMediaStreamSource(stream).connect(analyser); r.ctx = ctx; r.analyser = analyser; } catch { // No analyser — the waveform falls back to a flat idle line. } } if (!speech) { try { const recorder = new MediaRecorder(stream); recorder.ondataavailable = (event) => { if (event.data.size > 0) r.chunks.push(event.data); }; recorder.start(250); r.recorder = recorder; } catch (cause) { fail("Recording isn't supported in this browser.", cause); return; } } } if (speech && !fake) { try { const recognition = new speech(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = language ?? (typeof navigator !== "undefined" ? navigator.language : "en-US"); recognition.onresult = (event) => { let interim = ""; for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i]; const text = result?.[0]?.transcript ?? ""; if (result?.isFinal) r.speechFinal += `${text} `; else interim += text; } r.speechInterim = interim; }; recognition.onerror = (event) => { if (session.current !== id) return; if (event.error === "not-allowed" || event.error === "service-not-allowed") { fail("Speech recognition was blocked.", event); } }; recognition.start(); r.recognition = recognition; } catch (cause) { fail("Speech recognition couldn't start.", cause); return; } } history.current = Array.from({ length: barCount }, () => 0); const buffer = r.analyser ? new Uint8Array(new ArrayBuffer(r.analyser.fftSize)) : null; let lastPush = 0; const loop = (now: number) => { if (session.current !== id) return; const step = latest.current.reduce ? SAMPLE_MS_REDUCED : SAMPLE_MS; if (now - lastPush >= step) { lastPush = now; const level = r.analyser && buffer ? readLevel(r.analyser, buffer) : fake ? simulatedLevel(now) : 0; history.current.shift(); history.current.push(level); paint(); } r.raf = requestAnimationFrame(loop); }; r.raf = requestAnimationFrame(loop); const started = performance.now(); setElapsed(0); r.tick = window.setInterval(() => { setElapsed(Math.floor((performance.now() - started) / 1000)); }, 250); const limit = latest.current.maxDuration; if (limit && limit > 0) { r.maxTimer = window.setTimeout(() => { if (session.current === id) void confirm(); }, limit * 1000); } transition("recording"); }; const start = async () => { if (disabled) return; const id = ++session.current; if (doneTimer.current) window.clearTimeout(doneTimer.current); release(resources.current); resources.current = freshResources(); setError(undefined); setTranscript(""); setElapsed(0); const { simulate: fake, mode: currentMode } = latest.current; const speech = currentMode === "speech" ? getSpeechRecognition() : undefined; if (fake) { beginRecording(id, null, speech); return; } transition("requesting"); if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) { fail("Voice input isn't supported in this browser.", new Error("getUserMedia unavailable")); return; } if (!speech && typeof MediaRecorder === "undefined") { fail("Recording isn't supported in this browser.", new Error("MediaRecorder unavailable")); return; } let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (cause) { if (session.current !== id) return; fail(errorMessage(cause), cause); return; } if (session.current !== id) { for (const track of stream.getTracks()) track.stop(); return; } beginRecording(id, stream, speech); }; async function confirm() { if (phase.current !== "recording") return; const id = session.current; const r = resources.current; stopLoops(r); transition("processing"); try { let text: string; if (r.recognition) { text = await stopRecognition(r); } else { const audio = latest.current.simulate ? new Blob([], { type: "audio/webm" }) : await stopRecorder(r); release(r); const handler = latest.current.onTranscribe; text = handler ? await handler(audio) : ""; } if (session.current !== id) return; release(r); const clean = text.trim(); setTranscript(clean); transition("done"); latest.current.onTranscript?.(clean); doneTimer.current = window.setTimeout(() => { if (session.current === id) transition("idle"); }, latest.current.doneDuration); } catch (cause) { if (session.current !== id) return; fail("Couldn't transcribe that. Try again.", cause); } } const cancel = () => { const wasActive = state !== "error" && state !== "done"; session.current++; if (doneTimer.current) window.clearTimeout(doneTimer.current); release(resources.current); setError(undefined); transition("idle"); if (wasActive) latest.current.onCancel?.(); // The X button unmounts with the pill content; keep focus in the widget. micRef.current?.focus(); }; const busy = state === "requesting" || state === "processing"; const recording = state === "recording"; const expanded = state !== "idle"; const onMic = () => { if (disabled || busy) return; if (recording) void confirm(); else void start(); }; const micLabel = recording ? "Stop recording and transcribe" : state === "error" ? "Retry voice input" : "Start voice input"; const status: Record = { idle: "", requesting: "Requesting microphone access", recording: "Recording", processing: "Transcribing", done: transcript ? `Transcribed: ${transcript}` : "No speech detected", error: error ?? "Voice input failed", }; const icons: Record = { idle: , requesting: , recording: , processing: reduce ? ( ) : ( ), done: , error: , }; const iconVariants = reduce ? FADE_VARIANTS : ICON_VARIANTS; const contentVariants = reduce ? FADE_VARIANTS : CONTENT_VARIANTS; let content: ReactNode = null; if (state === "requesting") { content = ( <> Waiting for microphone… ); } else if (state === "recording") { content = ( <>