fix: STT recording lifecycle - stop on send, sync voiceActive with hook status, fix mobile re-record
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Failing after 29s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-06-01 08:43:03 +02:00
parent e727996a18
commit aa982680fa
2 changed files with 33 additions and 11 deletions

View file

@ -98,9 +98,9 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
_closeWs(); _closeWs();
_releaseDevices(); _releaseDevices();
setInterimText(''); setInterimText('');
_setStatus('idle'); _setStatusTracked('idle');
stoppingRef.current = false; stoppingRef.current = false;
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]); }, [_stopRecorder, _closeWs, _releaseDevices, _setStatusTracked]);
const _buildOpenPayload = useCallback(() => { const _buildOpenPayload = useCallback(() => {
const o = sttOpenOptsRef.current; const o = sttOpenOptsRef.current;
@ -113,16 +113,25 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
}; };
}, []); }, []);
const statusRef = useRef<VoiceStreamStatus>('idle');
const _setStatusTracked = useCallback((next: VoiceStreamStatus) => {
statusRef.current = next;
_setStatus(next);
}, [_setStatus]);
const start = useCallback(async (language?: string, sttOpenOptions?: SttStreamOpenOptions) => { 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; stoppingRef.current = false;
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
languageRef.current = language || 'de-DE'; languageRef.current = language || 'de-DE';
sttOpenOptsRef.current = sttOpenOptions; sttOpenOptsRef.current = sttOpenOptions;
_setStatus('connecting'); _setStatusTracked('connecting');
try { 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({ streamRef.current = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 },
}); });
@ -160,7 +169,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
}; };
recorder.start(_RECORDING_CHUNK_MS); recorder.start(_RECORDING_CHUNK_MS);
_setStatus('listening'); _setStatusTracked('listening');
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@ -191,23 +200,23 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
ws.onerror = () => { ws.onerror = () => {
if (!stoppingRef.current) { if (!stoppingRef.current) {
cbRef.current.onError?.(new Error('WebSocket connection error')); cbRef.current.onError?.(new Error('WebSocket connection error'));
_setStatus('error'); _setStatusTracked('error');
} }
}; };
ws.onclose = () => { ws.onclose = () => {
if (!stoppingRef.current) { if (!stoppingRef.current) {
_setStatus('idle'); _setStatusTracked('idle');
} }
}; };
} catch (err) { } catch (err) {
cbRef.current.onError?.(err); cbRef.current.onError?.(err);
_setStatus('error'); _setStatusTracked('error');
_releaseDevices(); _releaseDevices();
throw err; throw err;
} }
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]); }, [_setStatusTracked, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]);
useEffect(() => { useEffect(() => {
return () => { return () => {

View file

@ -339,6 +339,13 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
const _handleSend = useCallback(() => { const _handleSend = useCallback(() => {
if ((!prompt.trim() && attachments.length === 0) || isProcessing) return; 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 inlineFileIds = _extractFileRefs(prompt);
const attachedFileIds = attachments.flatMap(a => a.type === 'file' ? [a.id] : a.fileIds); const attachedFileIds = attachments.flatMap(a => a.type === 'file' ? [a.id] : a.fileIds);
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
@ -347,7 +354,7 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
setPrompt(''); setPrompt('');
setShowAutocomplete(false); setShowAutocomplete(false);
setAttachments([]); setAttachments([]);
}, [prompt, isProcessing, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]); }, [prompt, isProcessing, voiceActive, voiceStream, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
const _handleKeyDown = useCallback( const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -435,6 +442,12 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
}, },
onError: (error) => { onError: (error) => {
console.warn('Workspace voice stream error', error); console.warn('Workspace voice stream error', error);
setVoiceActive(false);
},
onStatusChange: (nextStatus) => {
if (nextStatus === 'idle' || nextStatus === 'error') {
setVoiceActive(false);
}
}, },
}); });