import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, } from "react"; const VOICE_RECORDING_MAX_MS = 120_000; const VOICE_RECORDING_MIN_MS = 650; const VOICE_NO_INPUT_HINT_MS = 1_100; const VOICE_HOLD_START_MS = 140; const VOICE_WAVEFORM_BAR_COUNT = 64; const VOICE_WAVEFORM_SILENT_HEIGHT = 3; const VOICE_WAVEFORM_MIN_HEIGHT = 7; const VOICE_WAVEFORM_MAX_HEIGHT = 34; const VOICE_MIN_LEVEL = 0.018; const VOICE_WAVEFORM_IDLE_LEVELS = Array.from( { length: VOICE_WAVEFORM_BAR_COUNT }, () => VOICE_WAVEFORM_SILENT_HEIGHT, ); const VOICE_MIME_CANDIDATES = [ "audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus", ] as const; export type VoiceRecorderState = "idle" | "recording" | "transcribing"; export type VoiceRecorderErrorKey = | "failed" | "noInput" | "notConfigured" | "permission" | "tooLong" | "tooShort" | "unsupported"; interface VoiceRecorderOptions { disabled?: boolean; onClearError: () => void; onError: (key: VoiceRecorderErrorKey) => void; onTranscript: (text: string) => void; onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise; } export function useVoiceRecorder({ disabled, onClearError, onError, onTranscript, onTranscribeAudio, }: VoiceRecorderOptions) { const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const streamRef = useRef(null); const audioRef = useRef(null); const startedAtRef = useRef(0); const maxTimerRef = useRef | null>(null); const inputHintTimerRef = useRef | null>(null); const holdTimerRef = useRef | null>(null); const holdActiveRef = useRef(false); const startPendingRef = useRef(false); const stopAfterStartRef = useRef(false); const suppressClickRef = useRef(false); const suppressClickTimerRef = useRef | null>(null); const shortcutActiveRef = useRef(false); const levelObservedRef = useRef(false); const peakLevelRef = useRef(0); const levelReliableRef = useRef(false); const noInputHintVisibleRef = useRef(false); const [state, setState] = useState("idle"); const [elapsedMs, setElapsedMs] = useState(0); const [levels, setLevels] = useState(VOICE_WAVEFORM_IDLE_LEVELS); const clearInputHintTimer = useCallback(() => clearTimer(inputHintTimerRef), []); const clearSuppressClickTimer = useCallback(() => clearTimer(suppressClickTimerRef), []); const suppressNextClick = useCallback(() => { clearSuppressClickTimer(); suppressClickRef.current = true; suppressClickTimerRef.current = setTimeout(() => { suppressClickRef.current = false; suppressClickTimerRef.current = null; }, 500); }, [clearSuppressClickTimer]); const stopWaveform = useCallback(() => { const audio = audioRef.current; audioRef.current = null; if (!audio) return; if (audio.frame !== null) cancelAnimationFrame(audio.frame); audio.source.disconnect(); audio.analyser.disconnect(); void audio.context.close().catch(() => undefined); }, []); const startWaveform = useCallback((stream: MediaStream) => { const AudioContextCtor = audioContextConstructor(); if (!AudioContextCtor) return; stopWaveform(); setLevels(VOICE_WAVEFORM_IDLE_LEVELS); try { const context = new AudioContextCtor(); const source = context.createMediaStreamSource(stream); const analyser = context.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.68; source.connect(analyser); const audio: VoiceAudioState = { analyser, context, data: new Uint8Array(analyser.fftSize), frame: null, source, }; const tick = () => { const current = audioRef.current; if (!current) return; if (current.context.state !== "running") { void current.context.resume().catch(() => undefined); current.frame = requestAnimationFrame(tick); return; } current.analyser.getByteTimeDomainData(current.data); const level = voiceLevelFromSamples(current.data); levelReliableRef.current = true; levelObservedRef.current = true; peakLevelRef.current = Math.max(peakLevelRef.current, level); if (level >= VOICE_MIN_LEVEL) { clearInputHintTimer(); if (noInputHintVisibleRef.current) { noInputHintVisibleRef.current = false; onClearError(); } } setLevels((currentLevels) => [ ...currentLevels.slice(1), waveformHeightFromLevel(level), ]); current.frame = requestAnimationFrame(tick); }; audioRef.current = audio; void context.resume().catch(() => undefined); audio.frame = requestAnimationFrame(tick); } catch { stopWaveform(); } }, [clearInputHintTimer, onClearError, stopWaveform]); const cleanupRecording = useCallback(() => { clearTimer(holdTimerRef); clearInputHintTimer(); clearTimer(maxTimerRef); stopWaveform(); streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; mediaRecorderRef.current = null; startPendingRef.current = false; shortcutActiveRef.current = false; noInputHintVisibleRef.current = false; }, [clearInputHintTimer, stopWaveform]); const stopRecording = useCallback(() => { const recorder = mediaRecorderRef.current; if (!recorder || recorder.state === "inactive") return; recorder.stop(); }, []); const stopRecordingWhenReady = useCallback(() => { const recorder = mediaRecorderRef.current; if (recorder && recorder.state !== "inactive") { stopRecording(); } else if (startPendingRef.current) { stopAfterStartRef.current = true; } }, [stopRecording]); const startRecording = useCallback(async () => { if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return; if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { onError("unsupported"); return; } startPendingRef.current = true; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream, mediaRecorderOptions()); chunksRef.current = []; streamRef.current = stream; mediaRecorderRef.current = recorder; startedAtRef.current = Date.now(); levelObservedRef.current = false; peakLevelRef.current = 0; levelReliableRef.current = false; noInputHintVisibleRef.current = false; setElapsedMs(0); startWaveform(stream); recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); }; recorder.onstop = () => { const chunks = chunksRef.current.splice(0); const durationMs = Math.max(0, Date.now() - startedAtRef.current); const mimeType = recorder.mimeType || "audio/webm"; const hasMeasuredSilence = levelReliableRef.current && levelObservedRef.current && peakLevelRef.current < VOICE_MIN_LEVEL; cleanupRecording(); if (chunks.length === 0) { setState("idle"); return; } if (durationMs < VOICE_RECORDING_MIN_MS) { setState("idle"); onError("tooShort"); return; } if (hasMeasuredSilence) { setState("idle"); onError("noInput"); return; } setState("transcribing"); void blobToDataUrl(new Blob(chunks, { type: mimeType })) .then((dataUrl) => onTranscribeAudio(dataUrl, { durationMs })) .then(onTranscript) .catch((error) => onError(transcriptionErrorKey(error))) .finally(() => setState("idle")); }; recorder.start(); setState("recording"); onClearError(); maxTimerRef.current = setTimeout(stopRecording, VOICE_RECORDING_MAX_MS); inputHintTimerRef.current = setTimeout(() => { const recording = mediaRecorderRef.current?.state === "recording"; if ( !recording || !levelReliableRef.current || !levelObservedRef.current || peakLevelRef.current >= VOICE_MIN_LEVEL ) { return; } noInputHintVisibleRef.current = true; onError("noInput"); }, VOICE_NO_INPUT_HINT_MS); } catch { cleanupRecording(); setState("idle"); onError("permission"); } }, [ cleanupRecording, onClearError, onError, onTranscribeAudio, onTranscript, startWaveform, state, stopRecording, ]); const startRecordingWithDeferredStop = useCallback(() => { stopAfterStartRef.current = false; void startRecording().then(() => { if (!stopAfterStartRef.current) return; stopAfterStartRef.current = false; stopRecording(); }); }, [startRecording, stopRecording]); const beginPress = useCallback((event: ReactPointerEvent) => { if (event.pointerType === "mouse" && event.button !== 0) return; if (!onTranscribeAudio || disabled || state !== "idle") return; clearTimer(holdTimerRef); try { event.currentTarget.setPointerCapture(event.pointerId); } catch { // Some embedded runtimes do not expose pointer capture for toolbar buttons. } holdTimerRef.current = setTimeout(() => { holdTimerRef.current = null; holdActiveRef.current = true; suppressNextClick(); startRecordingWithDeferredStop(); }, VOICE_HOLD_START_MS); }, [disabled, onTranscribeAudio, startRecordingWithDeferredStop, state, suppressNextClick]); const endPress = useCallback(() => { const wasHoldRecording = holdActiveRef.current; clearTimer(holdTimerRef); if (!wasHoldRecording) return; holdActiveRef.current = false; suppressNextClick(); stopRecordingWhenReady(); }, [stopRecordingWhenReady, suppressNextClick]); const handleClick = useCallback(() => { if (suppressClickRef.current) { clearSuppressClickTimer(); suppressClickRef.current = false; return; } if (state === "recording") stopRecording(); else void startRecording(); }, [clearSuppressClickTimer, startRecording, state, stopRecording]); const beginShortcutHold = useCallback(() => { if (!onTranscribeAudio || disabled || state !== "idle" || shortcutActiveRef.current) return; shortcutActiveRef.current = true; startRecordingWithDeferredStop(); }, [disabled, onTranscribeAudio, startRecordingWithDeferredStop, state]); const endShortcutHold = useCallback(() => { if (!shortcutActiveRef.current) return; shortcutActiveRef.current = false; stopRecordingWhenReady(); }, [stopRecordingWhenReady]); useEffect(() => { if (state !== "recording") { setElapsedMs(0); return; } const updateElapsed = () => { setElapsedMs(Math.max(0, Date.now() - startedAtRef.current)); }; updateElapsed(); const interval = window.setInterval(updateElapsed, 250); return () => window.clearInterval(interval); }, [state]); useEffect(() => cleanupRecording, [cleanupRecording]); useEffect(() => () => clearSuppressClickTimer(), [clearSuppressClickTimer]); return { beginShortcutHold, beginPress, buttonDisabled: disabled || state === "transcribing", elapsedLabel: formatVoiceElapsed(elapsedMs), endShortcutHold, endPress, handleClick, isRecording: state === "recording", levels, state, }; } interface VoiceAudioState { analyser: AnalyserNode; context: AudioContext; data: Uint8Array; frame: number | null; source: MediaStreamAudioSourceNode; } function clearTimer(ref: { current: ReturnType | null }) { if (ref.current !== null) { clearTimeout(ref.current); ref.current = null; } } function mediaRecorderOptions(): MediaRecorderOptions | undefined { if (typeof MediaRecorder === "undefined") return undefined; const mimeType = VOICE_MIME_CANDIDATES.find((type) => MediaRecorder.isTypeSupported(type)); return mimeType ? { mimeType } : undefined; } function formatVoiceElapsed(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); const minutes = Math.floor(seconds / 60); return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; } function audioContextConstructor(): typeof AudioContext | undefined { if (typeof window === "undefined") return undefined; return window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; } function voiceLevelFromSamples(samples: ArrayLike): number { if (samples.length === 0) return 0; let sum = 0; for (let index = 0; index < samples.length; index += 1) { const centered = (samples[index] - 128) / 128; sum += centered * centered; } const rms = Math.sqrt(sum / samples.length); return Math.min(1, Math.pow(rms * 4.2, 0.72)); } function waveformHeightFromLevel(level: number): number { if (level < VOICE_MIN_LEVEL) return VOICE_WAVEFORM_SILENT_HEIGHT; const activeLevel = Math.min(1, (level - VOICE_MIN_LEVEL) / (1 - VOICE_MIN_LEVEL)); return Math.round( VOICE_WAVEFORM_MIN_HEIGHT + activeLevel * (VOICE_WAVEFORM_MAX_HEIGHT - VOICE_WAVEFORM_MIN_HEIGHT), ); } function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === "string") resolve(reader.result); else reject(new Error("invalid_data_url")); }; reader.onerror = () => reject(reader.error ?? new Error("read_failed")); reader.readAsDataURL(blob); }); } function transcriptionErrorKey(error: unknown): VoiceRecorderErrorKey { const detail = error instanceof Error ? error.message : ""; if (detail === "not_configured") return "notConfigured"; if (detail === "duration") return "tooLong"; return "failed"; }