import { useCallback, useRef, useState } from 'react'; import { SPEECH_TRANSCRIBE_FAILED_ERROR, transcribeAudio } from './speechToTextUtils'; import type { SuperagentVoiceRecorderAdapter } from './useSuperagentRuntime'; export type SpeechToTextState = 'idle' | 'recording' | 'processing'; export type UseSpeechToTextResult = { state: SpeechToTextState; error: string | null; /** idle → record; recording → stop + transcribe (resolves to the transcript). */ toggle: () => Promise; /** Stop and discard any in-flight recording without transcribing (e.g. on composer unmount). */ cancel: () => Promise; /** Live mic level (0..1) for the recording waveform, when the recorder can meter. */ getLevel?: () => number; }; const MIC_PERMISSION_DENIED_ERROR = 'Microphone access denied. Please allow microphone access to use dictation.'; /** * Tap-to-start / tap-to-stop dictation state machine. The recorder adapter (host) * owns the native capture; this hook owns the state, the upload, and error copy. * Branchable logic lives in `speechToTextUtils.ts` (unit-tested) — this is just the * RN-stateful wiring, mirroring how `onStartLiveVoice` sequences its audio adapter. */ export function useSpeechToText({ baseUrl, getAccessToken, getHeaders, recorder, }: { baseUrl: string; getAccessToken: () => Promise | string | null | undefined; getHeaders?: () => Promise | undefined> | Record | undefined; recorder: SuperagentVoiceRecorderAdapter; }): UseSpeechToTextResult { const [state, setState] = useState('idle'); const [error, setError] = useState(null); // Latest phase for the stable `toggle`/`cancel` callbacks: `cancel` is wired into // the composer's unmount cleanup, so it must not change identity per render. const stateRef = useRef(state); stateRef.current = state; // Set synchronously when a transition begins: `state` only flips on the next // render, too late to gate re-entry — without this, two taps landing in the // same frame (e.g. multi-touching stop-review + send-now) would both stop and // transcribe the recording. const transitionRef = useRef(false); const start = useCallback(async () => { setError(null); try { const granted = await recorder.requestMicrophonePermission?.(); if (granted === false) { setError(MIC_PERMISSION_DENIED_ERROR); return; } await recorder.startRecording(); setState('recording'); } catch (err) { setError(getErrorMessage(err)); setState('idle'); } }, [recorder]); const stop = useCallback(async (): Promise => { setState('processing'); try { const file = await recorder.stopRecording(); const token = await getAccessToken(); if (!token) throw new Error(SPEECH_TRANSCRIBE_FAILED_ERROR); const headers = await getHeaders?.(); return await transcribeAudio({ authToken: token, baseUrl, file, headers }); } catch (err) { setError(getErrorMessage(err)); return null; } finally { setState('idle'); } }, [baseUrl, getAccessToken, getHeaders, recorder]); const cancel = useCallback(async () => { if (transitionRef.current || stateRef.current !== 'recording') return; // nothing to discard transitionRef.current = true; setState('idle'); setError(null); try { if (recorder.cancelRecording) await recorder.cancelRecording(); else await recorder.stopRecording(); // no cancel support — stop and drop the clip } catch { // Best-effort cleanup: leaving the conversation shouldn't surface an error. } finally { transitionRef.current = false; } }, [recorder]); const toggle = useCallback(async (): Promise => { if (transitionRef.current) return null; // a start/stop/cancel is already in flight transitionRef.current = true; try { if (stateRef.current === 'recording') return await stop(); if (stateRef.current === 'idle') await start(); return null; // 'processing' — ignore taps until the current transcription settles } finally { transitionRef.current = false; } }, [start, stop]); return { cancel, error, getLevel: recorder.getInputLevel, state, toggle }; } function getErrorMessage(err: unknown) { return err instanceof Error && err.message ? err.message : SPEECH_TRANSCRIBE_FAILED_ERROR; }