import { useCallback, useEffect, useRef, useState } from 'react'; import { transcribeVoice } from '../../../lib/voiceApi'; // Mobile-safe recording: iOS Safari 18.4+ supports webm/opus; older iOS needs mp4. const MIME_CANDIDATES = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', ]; function pickMime(): string { for (const t of MIME_CANDIDATES) { try { if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) return t; } catch { /* isTypeSupported can throw on some iOS versions */ } } return ''; } export type VoiceInputState = 'idle' | 'recording' | 'transcribing'; /** * Push-to-talk dictation. Records the mic, uploads to /api/voice/transcribe * (an OpenAI-compatible speech-to-text backend via the Express proxy), and * returns the transcript through onTranscript. */ export function useVoiceInput( onTranscript: (text: string, send?: boolean) => void, onError?: (msg: string) => void, ) { const [state, setState] = useState('idle'); const recorderRef = useRef(null); const chunksRef = useRef([]); const streamRef = useRef(null); const cancelledRef = useRef(false); const startingRef = useRef(false); // Whether the in-progress stop should auto-send the transcript (vs just fill the box). const sendRef = useRef(false); const stopTracks = () => { streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; }; // Stop the mic if the component unmounts mid-recording. useEffect(() => { cancelledRef.current = false; return () => { cancelledRef.current = true; startingRef.current = false; streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; recorderRef.current = null; }; }, []); const start = useCallback(async () => { if (startingRef.current || (recorderRef.current && recorderRef.current.state !== 'inactive')) return; startingRef.current = true; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, }); if (cancelledRef.current) { stream.getTracks().forEach((t) => t.stop()); return; } streamRef.current = stream; const mimeType = pickMime(); const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); recorderRef.current = rec; chunksRef.current = []; rec.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; rec.onstop = async () => { stopTracks(); if (cancelledRef.current) return; // Capture and clear the send intent for this stop before any async work. const shouldSend = sendRef.current; sendRef.current = false; const type = rec.mimeType || 'audio/webm'; const blob = new Blob(chunksRef.current, { type }); if (blob.size < 800) { setState('idle'); onError?.('Recording too short'); return; } setState('transcribing'); try { const ext = type.includes('mp4') ? 'm4a' : type.includes('ogg') ? 'ogg' : 'webm'; const res = await transcribeVoice(blob, `recording.${ext}`); if (!res.ok) throw new Error(`transcribe ${res.status}`); const data = await res.json(); if (cancelledRef.current) return; const text = String(data?.text || '').trim(); if (text) onTranscript(text, shouldSend); else onError?.('No speech detected'); } catch (e) { if (!cancelledRef.current) { onError?.(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`); } } finally { if (!cancelledRef.current) setState('idle'); } }; rec.start(); setState('recording'); } catch (e) { recorderRef.current = null; stopTracks(); if (cancelledRef.current) return; const err = e as { name?: string; message?: string }; let msg = `Mic error: ${err?.message || e}`; if (err?.name === 'NotAllowedError') msg = 'Microphone access denied.'; else if (err?.name === 'NotFoundError') msg = 'No microphone found.'; onError?.(msg); setState('idle'); } finally { startingRef.current = false; } }, [onTranscript, onError]); // Stop recording. Pass { send: true } to auto-send the transcript once it's ready. // Guard on the recorder's own state (not React state) so a double tap, or the mic // and Send buttons both firing, can't call stop() on an already-inactive recorder. const stop = useCallback((opts?: { send?: boolean }) => { const rec = recorderRef.current; if (rec && rec.state !== 'inactive') { sendRef.current = opts?.send ?? false; rec.stop(); } }, []); const toggle = useCallback(() => { if (state === 'recording') stop(); else if (state === 'idle') start(); }, [state, start, stop]); return { state, toggle, stop }; }