← 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
M components/DesktopBubbe.tsxM components/MobileBubbe.tsx
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 →