import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Mic, Square, X } from 'lucide-react' import { assertVoiceMemoBlobSize, formatVoiceDuration, pickMediaRecorderMimeType, VOICE_MEMO_MAX_DURATION_SEC } from '../utils/audioBlob.js' interface LiveVoiceCaptureProps { open: boolean busy?: boolean caption?: string onCaptionChange?: (value: string) => void onClose: () => void onSave: (blob: Blob, mimeType: string, durationSec: number) => void } type Phase = 'idle' | 'recording' | 'preview' export default function LiveVoiceCapture({ open, busy = false, caption = '', onCaptionChange, onClose, onSave }: LiveVoiceCaptureProps) { const { t } = useTranslation() const mediaRecorderRef = useRef(null) const streamRef = useRef(null) const chunksRef = useRef([]) const previewUrlRef = useRef(null) const startedAtRef = useRef(0) const timerRef = useRef(null) const [phase, setPhase] = useState('idle') const [micError, setMicError] = useState(null) const [elapsedSec, setElapsedSec] = useState(0) const [previewBlob, setPreviewBlob] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) const [previewMime, setPreviewMime] = useState('audio/webm') const [previewDurationSec, setPreviewDurationSec] = useState(0) const [saving, setSaving] = useState(false) const log = useCallback((msg: string) => { console.log(`[VoiceDebug] ${msg}`) }, []) const previewAudioRef = useRef(null) useEffect(() => { const el = previewAudioRef.current if (!el) { log('previewAudioRef is null') return } log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src) const handleLoadedMetadata = () => { log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration) if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) { log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...') el.currentTime = 1e10 const onTimeUpdate = () => { log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration) el.currentTime = 0 el.removeEventListener('timeupdate', onTimeUpdate) log('currentTime reset to 0. Final duration=' + el.duration) } el.addEventListener('timeupdate', onTimeUpdate) } else { log('Duration correction skipped (duration is valid)') } } if (el.readyState >= 1) { log('readyState >= 1. Executing hack immediately...') handleLoadedMetadata() } else { log('readyState = 0. Adding loadedmetadata event listener...') el.addEventListener('loadedmetadata', handleLoadedMetadata) } log('Calling el.load() to force loading of the media resource...') el.load() return () => { el.removeEventListener('loadedmetadata', handleLoadedMetadata) } }, [previewUrl, log]) const stopStream = useCallback(() => { for (const track of streamRef.current?.getTracks() ?? []) { track.stop() } streamRef.current = null }, []) const clearPreview = useCallback(() => { if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current) previewUrlRef.current = null } setPreviewUrl(null) setPreviewBlob(null) }, []) const clearTimer = useCallback(() => { if (timerRef.current != null) { window.clearInterval(timerRef.current) timerRef.current = null } }, []) const resetAll = useCallback(() => { if (mediaRecorderRef.current?.state === 'recording') { mediaRecorderRef.current.stop() } mediaRecorderRef.current = null chunksRef.current = [] clearTimer() stopStream() clearPreview() setPhase('idle') setMicError(null) setElapsedSec(0) setSaving(false) }, [stopStream, clearPreview, clearTimer]) useEffect(() => { if (!open) { resetAll() } }, [open, resetAll]) useEffect(() => { return () => { resetAll() } }, [resetAll]) const finishRecording = useCallback((blob: Blob, mimeType: string, durationSec: number) => { clearPreview() const url = URL.createObjectURL(blob) previewUrlRef.current = url setPreviewBlob(blob) setPreviewUrl(url) setPreviewMime(mimeType) setPreviewDurationSec(durationSec) setPhase('preview') }, [clearPreview]) const stopRecording = useCallback(() => { const recorder = mediaRecorderRef.current if (!recorder || recorder.state !== 'recording') return recorder.stop() clearTimer() }, [clearTimer]) const startRecording = async () => { setMicError(null) chunksRef.current = [] log('startRecording flow triggered') if (!navigator.mediaDevices?.getUserMedia) { log('navigator.mediaDevices.getUserMedia is unavailable') setMicError(t('logs.live_voice_mic_denied')) return } try { log('Requesting getUserMedia audio stream...') const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) streamRef.current = stream log('Stream obtained successfully. active=' + stream.active) stream.getTracks().forEach((track, i) => { log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`) }) const mimeType = pickMediaRecorderMimeType() log('MIME type candidates support check:') const MIME_CANDIDATES = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus' ] MIME_CANDIDATES.forEach(mime => { log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`) }) log('Selected MIME from picker: ' + mimeType) const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream) mediaRecorderRef.current = recorder const resolvedMime = recorder.mimeType || mimeType || 'audio/webm' log('MediaRecorder created. Resolved mime=' + resolvedMime) recorder.ondataavailable = (ev) => { log(`ondataavailable event: data size=${ev.data?.size} bytes`) if (ev.data && ev.data.size > 0) { chunksRef.current.push(ev.data) } } recorder.onstop = () => { const durationSec = Math.min( VOICE_MEMO_MAX_DURATION_SEC, Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000)) ) log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`) setTimeout(() => { log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`) const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0) log(`Total raw chunks size: ${totalChunksSize} bytes`) const blob = new Blob(chunksRef.current, { type: resolvedMime }) chunksRef.current = [] stopStream() log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`) try { assertVoiceMemoBlobSize(blob) log('Blob size assertion passed. Calling finishRecording...') finishRecording(blob, resolvedMime, durationSec) } catch (err) { log('Blob size assertion failed (too large)') setMicError(t('logs.live_voice_too_large')) setPhase('idle') } }, 50) } recorder.onerror = (ev) => { log('MediaRecorder onerror triggered: ' + JSON.stringify(ev)) setMicError(t('logs.live_voice_record_failed')) resetAll() } startedAtRef.current = Date.now() log('Calling recorder.start()...') recorder.start() log('recorder.start() called. State=' + recorder.state) setPhase('recording') setElapsedSec(0) timerRef.current = window.setInterval(() => { const sec = Math.floor((Date.now() - startedAtRef.current) / 1000) setElapsedSec(sec) if (sec >= VOICE_MEMO_MAX_DURATION_SEC) { log('Max duration reached. Stopping recording...') stopRecording() } }, 250) } catch (err: any) { log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err))) setMicError(t('logs.live_voice_mic_denied')) stopStream() } } const handleSave = async () => { if (!previewBlob || saving || busy) { log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy) return } log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec) setSaving(true) try { log('Invoking onSave callback...') await onSave(previewBlob, previewMime, previewDurationSec) log('onSave callback successfully finished!') } catch (err: any) { log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err))) } finally { setSaving(false) } } if (!open) return null return (
{ if (e.target === e.currentTarget && !busy && !saving && phase !== 'recording') onClose() }} >
e.stopPropagation()}>

{t('logs.live_voice_btn')}

{micError &&

{micError}

} {phase === 'idle' && ( <>

{t('logs.live_voice_hint')}

)} {phase === 'recording' && ( <>

{t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })}

)} {phase === 'preview' && previewUrl && ( <>
) }