[object Object]

← back to Dear Bubbe Nextjs

auto-save: 2026-07-16T17:13:50 (2 files) — components/DesktopBubbe.tsx components/MobileBubbe.tsx

bffe4c61f4313a2d4f2c3f81ec5b967429315ed5 · 2026-07-16 17:13:53 -0700 · Steve Abrams

Files touched

Diff

commit bffe4c61f4313a2d4f2c3f81ec5b967429315ed5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 17:13:53 2026 -0700

    auto-save: 2026-07-16T17:13:50 (2 files) — components/DesktopBubbe.tsx components/MobileBubbe.tsx
---
 components/DesktopBubbe.tsx | 83 ++++++++++++++++++++++++---------------------
 components/MobileBubbe.tsx  | 63 +++++++++++++++++++---------------
 2 files changed, 80 insertions(+), 66 deletions(-)

diff --git a/components/DesktopBubbe.tsx b/components/DesktopBubbe.tsx
index 6497593..1b20f2a 100644
--- a/components/DesktopBubbe.tsx
+++ b/components/DesktopBubbe.tsx
@@ -15,6 +15,7 @@ export default function DesktopBubbe() {
   const messagesEndRef = useRef<HTMLDivElement>(null)
   const recognitionRef = useRef<any>(null)
   const audioRef = useRef<HTMLAudioElement | null>(null)
+  const isSpeakingRef = useRef(false) // synchronous lock so voices never overlap
   const isAutoStartedRef = useRef(false)
 
   useEffect(() => {
@@ -66,22 +67,20 @@ export default function DesktopBubbe() {
       }
     }
 
-    // Initialize audio element
-    audioRef.current = new Audio()
-    audioRef.current.addEventListener('ended', () => {
-      setIsSpeaking(false)
-      // Auto-start listening after speech ends
-      if (voiceEnabled && hasStarted) {
-        setTimeout(() => {
-          startListening()
-        }, 500)
-      }
-    })
-
-    return () => {
-      if (audioRef.current) {
-        audioRef.current.removeEventListener('ended', () => {})
-      }
+    // Initialize the ONE audio element only once (recreating it on every state
+    // change spawned overlapping Audio elements that played over each other).
+    if (!audioRef.current) {
+      audioRef.current = new Audio()
+      audioRef.current.addEventListener('ended', () => {
+        isSpeakingRef.current = false
+        setIsSpeaking(false)
+        // Auto-start listening after speech ends
+        if (voiceEnabled && hasStarted) {
+          setTimeout(() => {
+            startListening()
+          }, 500)
+        }
+      })
     }
   }, [voiceEnabled, hasStarted, isSpeaking, isLoading])
 
@@ -101,10 +100,26 @@ export default function DesktopBubbe() {
   }, [])
 
   const playBubbeVoice = async (text: string) => {
-    if (!text || isSpeaking || !voiceEnabled) return
-    
+    // Synchronous lock so a second voice can NEVER start over a playing one.
+    if (!text || isSpeakingRef.current || !voiceEnabled) return
+    isSpeakingRef.current = true
     setIsSpeaking(true)
-    
+
+    // Never speak over another: hard-stop whatever is currently playing.
+    if (audioRef.current) {
+      try {
+        audioRef.current.pause()
+        audioRef.current.currentTime = 0
+        if (audioRef.current.src?.startsWith('blob:')) URL.revokeObjectURL(audioRef.current.src)
+      } catch {}
+    }
+
+    const unlock = () => {
+      isSpeakingRef.current = false
+      setIsSpeaking(false)
+      if (voiceEnabled && hasStarted) setTimeout(() => startListening(), 500)
+    }
+
     try {
       // Stop any current listening
       if (isListening) {
@@ -122,46 +137,36 @@ export default function DesktopBubbe() {
 
       if (!response.ok) {
         console.error('TTS failed:', response.status)
-        setIsSpeaking(false)
-        // Continue conversation even if TTS fails
-        if (voiceEnabled && hasStarted) {
-          setTimeout(() => startListening(), 500)
-        }
+        unlock()
         return
       }
 
       const audioBlob = await response.blob()
-      
+
       if (audioBlob.size === 0) {
         console.log('Empty audio response, continuing without voice')
-        setIsSpeaking(false)
-        if (voiceEnabled && hasStarted) {
-          setTimeout(() => startListening(), 500)
-        }
+        unlock()
         return
       }
 
       const audioUrl = URL.createObjectURL(audioBlob)
-      
+
       if (audioRef.current) {
+        // Stop again in case a clip slipped in while we were fetching.
+        audioRef.current.pause()
         audioRef.current.src = audioUrl
-        
         try {
           await audioRef.current.play()
         } catch (playError) {
           console.error('Autoplay failed:', playError)
-          setIsSpeaking(false)
-          if (voiceEnabled && hasStarted) {
-            setTimeout(() => startListening(), 500)
-          }
+          unlock()
         }
+      } else {
+        unlock()
       }
     } catch (error) {
       console.error('TTS error:', error)
-      setIsSpeaking(false)
-      if (voiceEnabled && hasStarted) {
-        setTimeout(() => startListening(), 500)
-      }
+      unlock()
     }
   }
 
diff --git a/components/MobileBubbe.tsx b/components/MobileBubbe.tsx
index 5c160c3..7ee3dac 100644
--- a/components/MobileBubbe.tsx
+++ b/components/MobileBubbe.tsx
@@ -18,6 +18,7 @@ export default function MobileBubbe() {
   const messagesEndRef = useRef<HTMLDivElement>(null)
   const recognitionRef = useRef<any>(null)
   const audioRef = useRef<HTMLAudioElement | null>(null)
+  const isSpeakingRef = useRef(false) // synchronous lock so voices never overlap
   const isAutoStartedRef = useRef(false)
 
   useEffect(() => {
@@ -68,17 +69,15 @@ export default function MobileBubbe() {
       }
     }
 
-    // Initialize audio element for TTS playback
-    audioRef.current = new Audio()
-    audioRef.current.addEventListener('ended', () => {
-      setIsSpeaking(false)
-      // Don't auto-start listening - wait for user tap
-    })
-
-    return () => {
-      if (audioRef.current) {
-        audioRef.current.removeEventListener('ended', () => {})
-      }
+    // Initialize the ONE audio element for TTS playback — only once.
+    // (Previously this ran on every state change, spawning new Audio elements
+    // that played over each other → overlapping voices.)
+    if (!audioRef.current) {
+      audioRef.current = new Audio()
+      audioRef.current.addEventListener('ended', () => {
+        isSpeakingRef.current = false
+        setIsSpeaking(false)
+      })
     }
   }, [mode, hasStarted, isSpeaking, isLoading])
 
@@ -91,10 +90,23 @@ export default function MobileBubbe() {
   }, [])
 
   const playBubbeVoice = async (text: string) => {
-    if (!text || isSpeaking) return
-    
+    // Synchronous lock so a second voice can NEVER start over a playing one
+    // (React state `isSpeaking` updates too late to block a rapid second call).
+    if (!text || isSpeakingRef.current) return
+    isSpeakingRef.current = true
     setIsSpeaking(true)
-    
+
+    // Never speak over another: hard-stop whatever is currently playing.
+    if (audioRef.current) {
+      try {
+        audioRef.current.pause()
+        audioRef.current.currentTime = 0
+        if (audioRef.current.src?.startsWith('blob:')) URL.revokeObjectURL(audioRef.current.src)
+      } catch {}
+    }
+
+    const unlock = () => { isSpeakingRef.current = false; setIsSpeaking(false) }
+
     try {
       // Stop any current listening
       if (isListening) {
@@ -112,40 +124,37 @@ export default function MobileBubbe() {
 
       if (!response.ok) {
         console.error('TTS failed:', response.status)
-        setIsSpeaking(false)
-        // Don't auto-continue on TTS failure
+        unlock()
         return
       }
 
       const audioBlob = await response.blob()
-      
+
       // Check if we got actual audio content
       if (audioBlob.size === 0) {
         console.log('Empty audio response (likely quota exceeded), continuing without voice')
-        setIsSpeaking(false)
-        // Don't auto-continue
+        unlock()
         return
       }
 
       const audioUrl = URL.createObjectURL(audioBlob)
-      
+
       if (audioRef.current) {
+        // Stop again in case a clip slipped in while we were fetching.
+        audioRef.current.pause()
         audioRef.current.src = audioUrl
-        
-        // Try to play with autoplay handling
         try {
           await audioRef.current.play()
         } catch (playError) {
           console.error('Autoplay failed:', playError)
-          // Try to play on user interaction or continue without audio
-          setIsSpeaking(false)
-          // Don't auto-continue
+          unlock()
         }
+      } else {
+        unlock()
       }
     } catch (error) {
       console.error('TTS error:', error)
-      setIsSpeaking(false)
-      // Don't auto-continue on error
+      unlock()
     }
   }
 

← c7ca51c feat(bubbe.ai): free chat — 3 messages before Google sign-in  ·  back to Dear Bubbe Nextjs  ·  fix(voice): Bubbe never speaks over herself + slightly faste f7be260 →