From aa982680fa2315d9c70209225679c3d12c25550c Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 1 Jun 2026 08:43:03 +0200 Subject: [PATCH] fix: STT recording lifecycle - stop on send, sync voiceActive with hook status, fix mobile re-record Co-authored-by: Cursor --- src/hooks/useSpeechAudioCapture.ts | 29 +++++++++++++------- src/pages/views/workspace/WorkspaceInput.tsx | 15 +++++++++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSpeechAudioCapture.ts b/src/hooks/useSpeechAudioCapture.ts index 6645289..6d765fb 100644 --- a/src/hooks/useSpeechAudioCapture.ts +++ b/src/hooks/useSpeechAudioCapture.ts @@ -98,9 +98,9 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi _closeWs(); _releaseDevices(); setInterimText(''); - _setStatus('idle'); + _setStatusTracked('idle'); stoppingRef.current = false; - }, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]); + }, [_stopRecorder, _closeWs, _releaseDevices, _setStatusTracked]); const _buildOpenPayload = useCallback(() => { const o = sttOpenOptsRef.current; @@ -113,16 +113,25 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi }; }, []); + const statusRef = useRef('idle'); + const _setStatusTracked = useCallback((next: VoiceStreamStatus) => { + statusRef.current = next; + _setStatus(next); + }, [_setStatus]); + const start = useCallback(async (language?: string, sttOpenOptions?: SttStreamOpenOptions) => { - if (status === 'listening' || status === 'connecting') return; + if (statusRef.current === 'listening' || statusRef.current === 'connecting') return; stoppingRef.current = false; reconnectAttemptsRef.current = 0; languageRef.current = language || 'de-DE'; sttOpenOptsRef.current = sttOpenOptions; - _setStatus('connecting'); + _setStatusTracked('connecting'); try { - if (!streamRef.current) { + const existingStream = streamRef.current; + const tracksAlive = existingStream?.getTracks().some(t => t.readyState === 'live'); + if (!existingStream || !tracksAlive) { + if (existingStream) existingStream.getTracks().forEach(t => t.stop()); streamRef.current = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, }); @@ -160,7 +169,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi }; recorder.start(_RECORDING_CHUNK_MS); - _setStatus('listening'); + _setStatusTracked('listening'); }; ws.onmessage = (event) => { @@ -191,23 +200,23 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi ws.onerror = () => { if (!stoppingRef.current) { cbRef.current.onError?.(new Error('WebSocket connection error')); - _setStatus('error'); + _setStatusTracked('error'); } }; ws.onclose = () => { if (!stoppingRef.current) { - _setStatus('idle'); + _setStatusTracked('idle'); } }; } catch (err) { cbRef.current.onError?.(err); - _setStatus('error'); + _setStatusTracked('error'); _releaseDevices(); throw err; } - }, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]); + }, [_setStatusTracked, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]); useEffect(() => { return () => { diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index e881153..1236ec2 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -339,6 +339,13 @@ export const WorkspaceInput = forwardRef { if ((!prompt.trim() && attachments.length === 0) || isProcessing) return; + if (voiceActive) { + voiceStream.stop(); + setVoiceActive(false); + promptBeforeVoiceRef.current = ''; + finalizedTextRef.current = ''; + currentInterimRef.current = ''; + } const inlineFileIds = _extractFileRefs(prompt); const attachedFileIds = attachments.flatMap(a => a.type === 'file' ? [a.id] : a.fileIds); const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; @@ -347,7 +354,7 @@ export const WorkspaceInput = forwardRef { @@ -435,6 +442,12 @@ export const WorkspaceInput = forwardRef { console.warn('Workspace voice stream error', error); + setVoiceActive(false); + }, + onStatusChange: (nextStatus) => { + if (nextStatus === 'idle' || nextStatus === 'error') { + setVoiceActive(false); + } }, });