import { useState, useCallback, useRef, useEffect } from "react"; export type MicrophoneStatus = | "idle" | "requesting" | "ready" | "recording" | "denied" | "error"; export interface MicrophoneState { status: MicrophoneStatus; error: string | null; sampleRate: number; channels: number; volume: number; } interface UseMicrophoneOptions { sampleRate?: number; onAudioData?: (samples: Float32Array) => void; } function getErrorMessage(err: unknown): { message: string; status: MicrophoneStatus } { if (err instanceof DOMException) { switch (err.name) { case "NotAllowedError": case "PermissionDeniedError": return { message: "Microphone permission denied", status: "denied" }; case "NotFoundError": return { message: "No microphone found", status: "error" }; case "NotReadableError": case "TrackStartError": return { message: "Microphone is in use by another application", status: "error" }; case "OverconstrainedError": return { message: "Microphone does not support requested settings", status: "error" }; case "AbortError": return { message: "Microphone access was aborted", status: "error" }; case "SecurityError": return { message: "Microphone access blocked (requires HTTPS)", status: "error" }; default: return { message: `Microphone error: ${err.name} - ${err.message}`, status: "error" }; } } if (err instanceof Error) { return { message: err.message, status: "error" }; } return { message: "Failed to access microphone", status: "error" }; } export function useMicrophone(options: UseMicrophoneOptions = {}) { const { onAudioData } = options; const [state, setState] = useState({ status: "idle", error: null, sampleRate: 48000, channels: 1, volume: 0, }); const streamRef = useRef(null); const audioContextRef = useRef(null); const processorRef = useRef(null); const sourceRef = useRef(null); const onAudioDataRef = useRef(onAudioData); // Keep callback ref updated useEffect(() => { onAudioDataRef.current = onAudioData; }, [onAudioData]); // Check if microphone permission is already granted const checkPermission = useCallback(async (): Promise => { try { const result = await navigator.permissions.query({ name: "microphone" as PermissionName, }); return result.state === "granted"; } catch { return false; } }, []); // Request microphone permission without starting recording const requestPermission = useCallback(async (): Promise => { setState((s) => ({ ...s, status: "requesting", error: null })); // Check for secure context if (typeof window !== "undefined" && !window.isSecureContext) { setState((s) => ({ ...s, status: "error", error: "Microphone requires HTTPS (or localhost)" })); return false; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Permission granted - stop the stream immediately stream.getTracks().forEach((track) => track.stop()); setState((s) => ({ ...s, status: "ready", error: null })); return true; } catch (err) { const { message, status } = getErrorMessage(err); setState((s) => ({ ...s, status, error: message })); return false; } }, []); const start = useCallback(async (): Promise => { if (state.status === "recording") return true; setState((s) => ({ ...s, status: "requesting", error: null })); // Check for secure context if (typeof window !== "undefined" && !window.isSecureContext) { setState((s) => ({ ...s, status: "error", error: "Microphone requires HTTPS (or localhost)" })); return false; } let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { const { message, status } = getErrorMessage(err); setState((s) => ({ ...s, status, error: message })); return false; } try { streamRef.current = stream; // Create audio context const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; const audioContext = new AudioContextClass(); audioContextRef.current = audioContext; // Resume audio context if it's suspended if (audioContext.state === "suspended") { await audioContext.resume(); } // Create source from microphone const source = audioContext.createMediaStreamSource(stream); sourceRef.current = source; // Use ScriptProcessor for audio processing const bufferSize = 4096; const processor = audioContext.createScriptProcessor(bufferSize, 1, 1); processorRef.current = processor; processor.onaudioprocess = (event) => { const inputData = event.inputBuffer.getChannelData(0); const samples = new Float32Array(inputData.length); samples.set(inputData); // Calculate RMS volume (0-1 range) let sum = 0; for (let i = 0; i < samples.length; i++) { sum += samples[i] * samples[i]; } const rms = Math.sqrt(sum / samples.length); // Normalize and clamp to 0-1 range (typical speech is around 0.1-0.3 RMS) const normalizedVolume = Math.min(1, rms * 3); setState((s) => ({ ...s, volume: normalizedVolume })); if (onAudioDataRef.current) { onAudioDataRef.current(samples); } }; source.connect(processor); processor.connect(audioContext.destination); setState((s) => ({ ...s, status: "recording", sampleRate: audioContext.sampleRate, error: null, })); return true; } catch (err) { stream.getTracks().forEach((track) => track.stop()); streamRef.current = null; const { message, status } = getErrorMessage(err); setState((s) => ({ ...s, status, error: message })); return false; } }, [state.status]); const stop = useCallback(() => { if (processorRef.current && sourceRef.current) { try { sourceRef.current.disconnect(processorRef.current); processorRef.current.disconnect(); } catch { // Already disconnected } processorRef.current = null; sourceRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; } setState((s) => ({ ...s, status: "idle", error: null, volume: 0 })); }, []); // Cleanup on unmount useEffect(() => { return () => { if (processorRef.current && sourceRef.current) { try { sourceRef.current.disconnect(processorRef.current); processorRef.current.disconnect(); } catch { // Already disconnected } } if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); } if (audioContextRef.current) { audioContextRef.current.close(); } }; }, []); return { ...state, start, stop, checkPermission, requestPermission, isRecording: state.status === "recording", isDenied: state.status === "denied", }; }