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();
_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<VoiceStreamStatus>('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 () => {

View file

@ -339,6 +339,13 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
const _handleSend = useCallback(() => {
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<WorkspaceInputHandle, WorkspaceInputPro
setPrompt('');
setShowAutocomplete(false);
setAttachments([]);
}, [prompt, isProcessing, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
}, [prompt, isProcessing, voiceActive, voiceStream, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@ -435,6 +442,12 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
},
onError: (error) => {
console.warn('Workspace voice stream error', error);
setVoiceActive(false);
},
onStatusChange: (nextStatus) => {
if (nextStatus === 'idle' || nextStatus === 'error') {
setVoiceActive(false);
}
},
});