From 94ca3fa3f4a0f67c2fb07f3320c52a8673be4119 Mon Sep 17 00:00:00 2001 From: Alex P Date: Tue, 5 Aug 2025 09:02:21 +0300 Subject: [PATCH] Stability: prevent race condition when clicking on Mic Start, Stop buttons in quick succession --- internal/audio/input.go | 5 +- internal/audio/nonblocking_audio.go | 4 ++ ui/src/hooks/useMicrophone.ts | 73 +++++++++++++++++++++++++---- web.go | 31 ++++++++++++ 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/internal/audio/input.go b/internal/audio/input.go index c51b929..1fdcfc8 100644 --- a/internal/audio/input.go +++ b/internal/audio/input.go @@ -64,8 +64,7 @@ func (aim *AudioInputManager) Stop() { aim.logger.Info().Msg("Stopping audio input manager") // Stop the non-blocking audio input stream - // Note: This is handled by the global non-blocking audio manager - // Individual input streams are managed centrally + StopNonBlockingAudioInput() // Drain the input buffer go func() { @@ -78,6 +77,8 @@ func (aim *AudioInputManager) Stop() { } } }() + + aim.logger.Info().Msg("Audio input manager stopped") } // WriteOpusFrame writes an Opus frame to the input buffer diff --git a/internal/audio/nonblocking_audio.go b/internal/audio/nonblocking_audio.go index aeadaf8..c055964 100644 --- a/internal/audio/nonblocking_audio.go +++ b/internal/audio/nonblocking_audio.go @@ -413,6 +413,10 @@ func (nam *NonBlockingAudioManager) StopAudioInput() { // Stop only the input coordinator atomic.StoreInt32(&nam.inputRunning, 0) + // Allow coordinator thread to process the stop signal and update state + // This prevents race conditions in state queries immediately after stopping + time.Sleep(50 * time.Millisecond) + nam.logger.Info().Msg("audio input stopped") } diff --git a/ui/src/hooks/useMicrophone.ts b/ui/src/hooks/useMicrophone.ts index f53a449..53cb444 100644 --- a/ui/src/hooks/useMicrophone.ts +++ b/ui/src/hooks/useMicrophone.ts @@ -327,11 +327,18 @@ export function useMicrophone() { for (let attempt = 1; attempt <= 3; attempt++) { try { - // If this is a retry, first try to stop the backend microphone to reset state + // If this is a retry, first try to reset the backend microphone state if (attempt > 1) { console.log(`Backend start attempt ${attempt}, first trying to reset backend state...`); try { - await api.POST("/microphone/stop", {}); + // Try the new reset endpoint first + const resetResp = await api.POST("/microphone/reset", {}); + if (resetResp.ok) { + console.log("Backend reset successful"); + } else { + // Fallback to stop + await api.POST("/microphone/stop", {}); + } // Wait a bit for the backend to reset await new Promise(resolve => setTimeout(resolve, 200)); } catch (resetError) { @@ -358,6 +365,24 @@ export function useMicrophone() { console.log("Backend response data:", responseData); if (responseData.status === "already running") { console.info("Backend microphone was already running"); + + // If we're on the first attempt and backend says "already running", + // but frontend thinks it's not active, this might be a stuck state + if (attempt === 1 && !isMicrophoneActive) { + console.warn("Backend reports 'already running' but frontend is not active - possible stuck state"); + console.log("Attempting to reset backend state and retry..."); + + try { + const resetResp = await api.POST("/microphone/reset", {}); + if (resetResp.ok) { + console.log("Backend reset successful, retrying start..."); + await new Promise(resolve => setTimeout(resolve, 200)); + continue; // Retry the start + } + } catch (resetError) { + console.warn("Failed to reset stuck backend state:", resetError); + } + } } console.log("Backend microphone start successful"); backendSuccess = true; @@ -457,15 +482,47 @@ export function useMicrophone() { const resetBackendMicrophoneState = useCallback(async (): Promise => { try { console.log("Resetting backend microphone state..."); - await api.POST("/microphone/stop", {}); - // Wait for backend to process the stop - await new Promise(resolve => setTimeout(resolve, 300)); - return true; + const response = await api.POST("/microphone/reset", {}); + + if (response.ok) { + const data = await response.json(); + console.log("Backend microphone reset successful:", data); + + // Update frontend state to match backend + setMicrophoneActive(false); + setMicrophoneMuted(false); + + // Clean up any orphaned streams + if (microphoneStreamRef.current) { + console.log("Cleaning up orphaned stream after reset"); + await stopMicrophoneStream(); + } + + // Wait a bit for everything to settle + await new Promise(resolve => setTimeout(resolve, 200)); + + // Sync state to ensure consistency + await syncMicrophoneState(); + + return true; + } else { + console.error("Backend microphone reset failed:", response.status); + return false; + } } catch (error) { console.warn("Failed to reset backend microphone state:", error); - return false; + // Fallback to old method + try { + console.log("Trying fallback reset method..."); + await api.POST("/microphone/stop", {}); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (fallbackError) { + console.error("Fallback reset also failed:", fallbackError); + return false; + } } - }, []); + }, [setMicrophoneActive, setMicrophoneMuted, stopMicrophoneStream, syncMicrophoneState]); // Stop microphone const stopMicrophone = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => { diff --git a/web.go b/web.go index ed0ef9c..b019168 100644 --- a/web.go +++ b/web.go @@ -398,6 +398,37 @@ func setupRouter() *gin.Engine { }) }) + protected.POST("/microphone/reset", func(c *gin.Context) { + if currentSession == nil { + c.JSON(400, gin.H{"error": "no active session"}) + return + } + + if currentSession.AudioInputManager == nil { + c.JSON(500, gin.H{"error": "audio input manager not available"}) + return + } + + logger.Info().Msg("forcing microphone state reset") + + // Force stop both the AudioInputManager and NonBlockingAudioManager + currentSession.AudioInputManager.Stop() + audio.StopNonBlockingAudioInput() + + // Wait a bit to ensure everything is stopped + time.Sleep(100 * time.Millisecond) + + // Broadcast microphone state change via WebSocket + broadcaster := audio.GetAudioEventBroadcaster() + broadcaster.BroadcastMicrophoneStateChanged(false, true) + + c.JSON(200, gin.H{ + "status": "reset", + "audio_input_running": currentSession.AudioInputManager.IsRunning(), + "nonblocking_input_running": audio.IsNonBlockingAudioInputRunning(), + }) + }) + // Catch-all route for SPA r.NoRoute(func(c *gin.Context) { if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML {