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);
+ }
},
});