← back to Dear Bubbe Nextjs
refactor(bubbe): delete dead ExpandableChat + VoiceManager
3899565343030d41bfd4966d72e626e45b28d0c9 · 2026-07-17 07:41:08 -0700 · Steve Abrams
Both unreachable from the live page→BubbeResponsive→Mobile/Desktop chain
(ExpandableChat had 0 importers; VoiceManager only imported by ExpandableChat).
Removes the Finding-C overlap/retry-storm landmine the contrarian flagged.
Build clean, homepage 200.
Files touched
D components/ExpandableChat.tsxD components/VoiceManager.tsx
Diff
commit 3899565343030d41bfd4966d72e626e45b28d0c9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 17 07:41:08 2026 -0700
refactor(bubbe): delete dead ExpandableChat + VoiceManager
Both unreachable from the live page→BubbeResponsive→Mobile/Desktop chain
(ExpandableChat had 0 importers; VoiceManager only imported by ExpandableChat).
Removes the Finding-C overlap/retry-storm landmine the contrarian flagged.
Build clean, homepage 200.
---
components/ExpandableChat.tsx | 551 -------------------------------
components/VoiceManager.tsx | 749 ------------------------------------------
2 files changed, 1300 deletions(-)
diff --git a/components/ExpandableChat.tsx b/components/ExpandableChat.tsx
deleted file mode 100644
index a7893a8..0000000
--- a/components/ExpandableChat.tsx
+++ /dev/null
@@ -1,551 +0,0 @@
-'use client'
-
-import { useState, useEffect, useRef } from 'react'
-import VoiceManager, { VoiceStatus } from '@/components/VoiceManager'
-import GoogleSignIn from '@/components/GoogleSignIn'
-import InputModeSelector, { InputMode } from '@/components/InputModeSelector'
-import TextInput from '@/components/TextInput'
-import { motion, AnimatePresence } from 'framer-motion'
-
-interface Message {
- role: 'user' | 'assistant' | 'system'
- content: string
- timestamp: Date
-}
-
-export default function ExpandableChat() {
- const [isOpen, setIsOpen] = useState(false)
- const [messages, setMessages] = useState<Message[]>([])
- const [isLoading, setIsLoading] = useState(false)
- const [bubbeResponse, setBubbeResponse] = useState<string | null>(null)
- const lastResponseTimeRef = useRef(0)
- const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>('initializing')
- const [userId, setUserId] = useState<string>('')
- const [isMobile, setIsMobile] = useState(false)
- const [inputMode, setInputMode] = useState<InputMode | null>(null) // Start with null to show selector
- const [showModeSelector, setShowModeSelector] = useState(false)
- const hasGreeted = useRef(false)
- const messagesEndRef = useRef<HTMLDivElement>(null)
- const chatContainerRef = useRef<HTMLDivElement>(null)
-
- // Initialize user ID and detect mobile
- useEffect(() => {
- // Detect mobile device
- const checkMobile = () => {
- const mobile = window.innerWidth <= 768 || /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
- setIsMobile(mobile)
- return mobile
- }
-
- checkMobile()
- window.addEventListener('resize', checkMobile)
-
- // Get or create user ID
- let id = localStorage.getItem('bubbeUserId')
- if (!id) {
- id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`
- localStorage.setItem('bubbeUserId', id)
- }
- setUserId(id)
-
- return () => window.removeEventListener('resize', checkMobile)
- }, [])
-
- // Scroll to bottom when messages change
- useEffect(() => {
- if (isOpen && messagesEndRef.current) {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- }
- }, [messages, isOpen])
-
- // Handle opening/closing
- const handleToggle = () => {
- if (!isOpen) {
- setIsOpen(true)
- setShowModeSelector(true)
- } else {
- setIsOpen(false)
- setShowModeSelector(false)
- // Don't reset mode, keep it for next time
- }
- }
-
- // Handle mode selection
- const handleModeSelect = (mode: InputMode) => {
- setInputMode(mode)
- setShowModeSelector(false)
-
- // If this is the first time opening, trigger greeting
- if (!hasGreeted.current && mode === 'talk') {
- hasGreeted.current = true
- setTimeout(() => sendInitialGreeting(), 500)
- }
- }
-
- // Handle voice transcript from VoiceManager
- const handleVoiceTranscript = async (transcript: string) => {
- console.log('[ExpandableChat] 📨 RECEIVED TRANSCRIPT:', transcript)
-
- // Special init message - send greeting IMMEDIATELY
- if (transcript === '__voice_init__') {
- console.log('[ExpandableChat] 🎯 VOICE INIT DETECTED!')
- if (!hasGreeted.current) {
- hasGreeted.current = true
- sendInitialGreeting()
- }
- return
- }
-
- // Regular user message
- if (transcript && transcript.trim() !== '') {
- await sendMessage(transcript)
- }
- }
-
- // Send initial greeting
- const sendInitialGreeting = async () => {
- console.log('[ExpandableChat] Sending initial greeting...')
-
- setIsLoading(true)
-
- const defaultGreeting = "Oy vey, look who finally shows up! You couldn't call, you couldn't write? I've been sitting here waiting like a schmuck! So nu, what's so important that you couldn't visit your Bubbe sooner?"
-
- let userProfile = null
- try {
- const storedProfile = sessionStorage.getItem('userProfile')
- if (storedProfile) {
- userProfile = JSON.parse(storedProfile)
- }
- } catch (e) {
- console.log('[ExpandableChat] Could not get profile from session storage')
- }
-
- try {
- const controller = new AbortController()
- const timeout = setTimeout(() => controller.abort(), 5000)
-
- const response = await fetch('/api/chat', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- message: '__init__',
- userId: userId || `temp_${Date.now()}`,
- mode: 'bubbe',
- userProfile
- }),
- signal: controller.signal
- })
-
- clearTimeout(timeout)
-
- if (response.ok) {
- const data = await response.json()
- const greeting: Message = {
- role: 'assistant',
- content: data.response || defaultGreeting,
- timestamp: new Date()
- }
-
- setMessages([greeting])
-
- const now = Date.now()
- if (now - lastResponseTimeRef.current > 1000) {
- lastResponseTimeRef.current = now
- setBubbeResponse(greeting.content)
- }
- } else {
- throw new Error('API returned non-OK status')
- }
- } catch (error: any) {
- console.error('[ExpandableChat] Failed to get greeting, using default:', error.message)
-
- const greeting: Message = {
- role: 'assistant',
- content: defaultGreeting,
- timestamp: new Date()
- }
-
- setMessages([greeting])
-
- const now = Date.now()
- if (now - lastResponseTimeRef.current > 1000) {
- lastResponseTimeRef.current = now
- setBubbeResponse(greeting.content)
- }
- } finally {
- setIsLoading(false)
- }
- }
-
- // Send user message and get response
- const sendMessage = async (text: string) => {
- console.log('[ExpandableChat] Sending message:', text)
- setIsLoading(true)
-
- const userMessage: Message = {
- role: 'user',
- content: text,
- timestamp: new Date()
- }
- setMessages(prev => [...prev, userMessage])
-
- let userProfile = null
- try {
- const storedProfile = sessionStorage.getItem('userProfile')
- if (storedProfile) {
- userProfile = JSON.parse(storedProfile)
- }
- } catch (e) {
- console.log('[ExpandableChat] Could not get profile from session storage')
- }
-
- try {
- const response = await fetch('/api/chat', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- message: text,
- userId,
- mode: 'bubbe',
- messages: messages.slice(-10),
- userProfile
- })
- })
-
- if (response.ok) {
- const data = await response.json()
- const assistantMessage: Message = {
- role: 'assistant',
- content: data.response,
- timestamp: new Date()
- }
-
- setMessages(prev => [...prev, assistantMessage])
-
- const now = Date.now()
- if (now - lastResponseTimeRef.current > 1000) {
- lastResponseTimeRef.current = now
- setBubbeResponse(data.response)
- }
- }
- } catch (error) {
- console.error('[ExpandableChat] Failed to send message:', error)
- } finally {
- setIsLoading(false)
- }
- }
-
- // Handle voice status changes
- const handleVoiceStatusChange = (status: VoiceStatus) => {
- setVoiceStatus(status)
- }
-
- // Handle profile saved
- const handleProfileSaved = async (profile: any) => {
- console.log('[ExpandableChat] Profile saved, triggering Bubbe response')
- setIsLoading(true)
-
- try {
- const response = await fetch('/api/chat', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- message: '__profile_complete__',
- userId,
- mode: 'bubbe',
- userProfile: profile
- })
- })
-
- if (response.ok) {
- const data = await response.json()
- const profileMessage: Message = {
- role: 'assistant',
- content: data.response,
- timestamp: new Date()
- }
-
- setMessages(prev => [...prev, profileMessage])
-
- const now = Date.now()
- if (now - lastResponseTimeRef.current > 1000) {
- lastResponseTimeRef.current = now
- setBubbeResponse(data.response)
- }
- }
- } catch (error) {
- console.error('[ExpandableChat] Failed to get profile reaction:', error)
- } finally {
- setIsLoading(false)
- }
- }
-
- // Listen for profile save events
- useEffect(() => {
- const handleProfileEvent = (e: CustomEvent) => {
- handleProfileSaved(e.detail)
- }
-
- window.addEventListener('bubbeProfileSaved', handleProfileEvent as any)
- return () => {
- window.removeEventListener('bubbeProfileSaved', handleProfileEvent as any)
- }
- }, [userId])
-
- return (
- <>
- {/* Floating Action Button */}
- <AnimatePresence>
- {!isOpen && (
- <motion.button
- initial={{ scale: 0, opacity: 0 }}
- animate={{ scale: 1, opacity: 1 }}
- exit={{ scale: 0, opacity: 0 }}
- whileHover={{ scale: 1.1 }}
- whileTap={{ scale: 0.95 }}
- onClick={handleToggle}
- className="expandable-chat-fab"
- aria-label="Open chat with Bubbe"
- >
- <div className="fab-content">
- <img
- src="/bubbe.png"
- alt="Bubbe"
- className="fab-avatar"
- onError={(e) => {
- e.currentTarget.style.display = 'none'
- e.currentTarget.nextElementSibling?.classList.remove('hidden')
- }}
- />
- <span className="fab-emoji hidden">👵</span>
- <span className="fab-badge">Chat with Bubbe!</span>
- </div>
- </motion.button>
- )}
- </AnimatePresence>
-
- {/* Expandable Chat Container */}
- <AnimatePresence>
- {isOpen && (
- <>
- {/* Backdrop for desktop */}
- {!isMobile && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- onClick={handleToggle}
- className="expandable-chat-backdrop"
- />
- )}
-
- {/* Chat Container */}
- <motion.div
- ref={chatContainerRef}
- initial={isMobile ? { y: '100%' } : { scale: 0.8, opacity: 0 }}
- animate={isMobile ? { y: 0 } : { scale: 1, opacity: 1 }}
- exit={isMobile ? { y: '100%' } : { scale: 0.8, opacity: 0 }}
- transition={{
- type: 'spring',
- damping: 25,
- stiffness: 300
- }}
- className={`expandable-chat-container ${isMobile ? 'mobile' : 'desktop'}`}
- >
- {/* Header */}
- <div className="expandable-chat-header">
- <div className="header-content">
- <div className="header-avatar-wrapper">
- <img
- src="/bubbe.png"
- alt="Bubbe"
- className="header-avatar"
- onError={(e) => {
- e.currentTarget.style.display = 'none'
- e.currentTarget.nextElementSibling?.classList.remove('hidden')
- }}
- />
- <span className="header-emoji hidden">👵</span>
- </div>
- <div className="header-info">
- <h2 className="header-title">Dear Bubbe</h2>
- <p className="header-subtitle">Your sarcastic Jewish grandma</p>
- </div>
- </div>
- <button
- onClick={handleToggle}
- className="header-close-btn"
- aria-label="Close chat"
- >
- <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
-
- {/* Google Sign In */}
- {!inputMode && (
- <div className="expandable-chat-auth">
- <GoogleSignIn onProfileSaved={handleProfileSaved} />
- </div>
- )}
-
- {/* Mode Selector */}
- {showModeSelector && (
- <motion.div
- initial={{ opacity: 0, y: 20 }}
- animate={{ opacity: 1, y: 0 }}
- className="expandable-mode-selector"
- >
- <h3 className="mode-selector-title">How would you like to chat?</h3>
- <div className="mode-selector-options">
- <button
- onClick={() => handleModeSelect('talk')}
- className="mode-option-btn talk"
- >
- <span className="mode-option-icon">🎤</span>
- <span className="mode-option-title">Talk Only</span>
- <span className="mode-option-desc">Voice conversation</span>
- </button>
- <button
- onClick={() => handleModeSelect('text')}
- className="mode-option-btn text"
- >
- <span className="mode-option-icon">⌨️</span>
- <span className="mode-option-title">Text Only</span>
- <span className="mode-option-desc">Type messages</span>
- </button>
- <button
- onClick={() => handleModeSelect('both')}
- className="mode-option-btn both"
- >
- <span className="mode-option-icon">💬</span>
- <span className="mode-option-title">Both</span>
- <span className="mode-option-desc">Talk or type</span>
- </button>
- </div>
- </motion.div>
- )}
-
- {/* Chat Interface (shown after mode selection) */}
- {inputMode && !showModeSelector && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- className="expandable-chat-body"
- >
- {/* Messages Container */}
- <div className="expandable-messages-container">
- {messages.length === 0 && !isLoading && (
- <div className="expandable-empty-state">
- <p className="empty-state-text">
- {inputMode === 'talk'
- ? "🎤 Start talking, I'm listening!"
- : inputMode === 'text'
- ? "⌨️ Type something already!"
- : "💬 Talk or type, I'm ready!"}
- </p>
- </div>
- )}
-
- {messages.map((message, index) => (
- <motion.div
- key={index}
- initial={{ opacity: 0, y: 10 }}
- animate={{ opacity: 1, y: 0 }}
- className={`expandable-message ${message.role}`}
- >
- <div className={`expandable-message-bubble ${message.role}`}>
- <p className="message-content">{message.content}</p>
- <p className="message-time">
- {new Date(message.timestamp).toLocaleTimeString()}
- </p>
- </div>
- </motion.div>
- ))}
-
- {isLoading && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- className="expandable-loading"
- >
- <div className="loading-dots">
- <span></span>
- <span></span>
- <span></span>
- </div>
- </motion.div>
- )}
-
- <div ref={messagesEndRef} />
- </div>
-
- {/* Input Controls */}
- <div className="expandable-input-container">
- {/* Voice Manager - Only show when not in text-only mode */}
- {inputMode !== 'text' && (
- <div className="expandable-voice-section">
- <VoiceManager
- onTranscript={handleVoiceTranscript}
- onStatusChange={handleVoiceStatusChange}
- autoStart={inputMode === 'talk'}
- bubbeResponse={bubbeResponse}
- />
- </div>
- )}
-
- {/* Text Input - Only show when not in talk-only mode */}
- {inputMode !== 'talk' && (
- <div className="expandable-text-section">
- <TextInput
- onSend={sendMessage}
- disabled={isLoading}
- className="expandable-text-input"
- />
- </div>
- )}
-
- {/* Mode switcher button */}
- <button
- onClick={() => setShowModeSelector(true)}
- className="mode-switch-btn"
- title="Change chat mode"
- >
- <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
- </svg>
- </button>
- </div>
-
- {/* Status Bar */}
- <div className="expandable-status-bar">
- {inputMode === 'text' ? (
- isLoading ? "💭 Bubbe is thinking..." : "✨ Type your message"
- ) : inputMode === 'talk' ? (
- <>
- {voiceStatus === 'initializing' && "⏳ Waking up..."}
- {voiceStatus === 'listening' && "🎤 Listening..."}
- {voiceStatus === 'processing' && "🤔 Thinking..."}
- {voiceStatus === 'speaking' && "🗣️ Speaking..."}
- {voiceStatus === 'ready' && "✨ Ready"}
- {voiceStatus === 'permission-required' && "👆 Tap to allow mic"}
- {voiceStatus === 'error' && "❌ Oy vey!"}
- </>
- ) : (
- <>
- {voiceStatus === 'listening' ? "🎤 Listening..." :
- voiceStatus === 'speaking' ? "🗣️ Speaking..." :
- isLoading ? "💭 Processing..." : "✨ Ready"}
- </>
- )}
- </div>
- </motion.div>
- )}
- </motion.div>
- </>
- )}
- </AnimatePresence>
- </>
- )
-}
\ No newline at end of file
diff --git a/components/VoiceManager.tsx b/components/VoiceManager.tsx
deleted file mode 100644
index 3f84aa5..0000000
--- a/components/VoiceManager.tsx
+++ /dev/null
@@ -1,749 +0,0 @@
-'use client'
-
-import { useEffect, useRef, useState, useCallback } from 'react'
-import MobileStartOverlay from './MobileStartOverlay'
-
-interface VoiceManagerProps {
- onTranscript: (text: string) => void
- onStatusChange?: (status: VoiceStatus) => void
- autoStart?: boolean
- bubbeResponse?: string | null
-}
-
-export type VoiceStatus =
- | 'initializing'
- | 'ready'
- | 'listening'
- | 'processing'
- | 'speaking'
- | 'error'
- | 'permission-required'
-
-export default function VoiceManager({
- onTranscript,
- onStatusChange,
- autoStart = true,
- bubbeResponse = null
-}: VoiceManagerProps) {
- const [status, setStatus] = useState<VoiceStatus>('initializing')
- const [transcript, setTranscript] = useState('')
- const [errorMessage, setErrorMessage] = useState<string | null>(null)
- const [isAutoplayBlocked, setIsAutoplayBlocked] = useState(false)
- const [isPaused, setIsPaused] = useState(false)
- const [isMobile, setIsMobile] = useState(false)
-
- const recognitionRef = useRef<any>(null)
- const audioRef = useRef<HTMLAudioElement | null>(null)
- const isProcessingRef = useRef(false)
- const initializeAttempted = useRef(false)
- const pausedStateRef = useRef(false)
- const conversationActiveRef = useRef(true) // Keep conversation going by default
-
- // Detect mobile device
- useEffect(() => {
- const checkMobile = () => {
- const mobileCheck = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
- setIsMobile(mobileCheck)
- }
- checkMobile()
- window.addEventListener('resize', checkMobile)
- return () => window.removeEventListener('resize', checkMobile)
- }, [])
-
- // Update parent component when status changes
- useEffect(() => {
- onStatusChange?.(status)
- }, [status, onStatusChange])
-
- // Initialize speech recognition
- const initializeSpeechRecognition = useCallback(() => {
- if (typeof window === 'undefined') return false
-
- const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
- if (!SpeechRecognition) {
- console.error('[VoiceManager] Speech recognition not supported')
- setStatus('error')
- setErrorMessage('Speech recognition not supported in this browser')
- return false
- }
-
- if (!recognitionRef.current) {
- const recognition = new SpeechRecognition()
- recognition.continuous = true
- recognition.interimResults = true
- recognition.lang = 'en-US'
- recognition.maxAlternatives = 1
-
- let silenceTimer: NodeJS.Timeout | null = null
-
- recognition.onresult = (event: any) => {
- let currentTranscript = ''
-
- // Get the latest result
- for (let i = event.resultIndex; i < event.results.length; i++) {
- const result = event.results[i]
- if (result.isFinal) {
- currentTranscript += result[0].transcript + ' '
- } else {
- currentTranscript = result[0].transcript
- }
- }
-
- if (currentTranscript.trim()) {
- setTranscript(currentTranscript.trim())
-
- // Clear existing timer
- if (silenceTimer) {
- clearTimeout(silenceTimer)
- }
-
- // Set new timer for silence detection (2 seconds)
- silenceTimer = setTimeout(() => {
- if (currentTranscript.trim().length > 3 && !isProcessingRef.current) {
- console.log('[VoiceManager] Silence detected, processing:', currentTranscript)
- handleTranscriptComplete(currentTranscript.trim())
- }
- }, 2000)
- }
- }
-
- recognition.onerror = (event: any) => {
- console.error('[VoiceManager] Recognition error:', event.error)
- if (event.error === 'not-allowed') {
- setStatus('permission-required')
- setErrorMessage('Microphone permission denied')
- } else if (event.error === 'no-speech') {
- // Ignore no-speech errors, they're normal
- return
- } else {
- setStatus('error')
- setErrorMessage(`Speech recognition error: ${event.error}`)
- }
- }
-
- recognition.onend = () => {
- console.log('[VoiceManager] Recognition ended')
- // Auto-restart if we should be listening
- if (status === 'listening' && !isProcessingRef.current) {
- setTimeout(() => startListening(), 500)
- }
- }
-
- recognitionRef.current = recognition
- console.log('[VoiceManager] Speech recognition initialized')
- }
-
- return true
- }, [status])
-
- // Handle completed transcript
- const handleTranscriptComplete = useCallback((text: string) => {
- if (isProcessingRef.current) return
-
- isProcessingRef.current = true
- setStatus('processing')
- setTranscript('')
- stopListening()
-
- // Send to parent component
- onTranscript(text)
-
- // Reset processing flag after a short delay
- setTimeout(() => {
- isProcessingRef.current = false
- }, 1000)
- }, [onTranscript])
-
- // Pause/Resume functions
- const pauseConversation = useCallback(() => {
- console.log('[VoiceManager] Pausing conversation...')
- setIsPaused(true)
- pausedStateRef.current = true
- conversationActiveRef.current = false
-
- // Stop listening if currently listening
- if (recognitionRef.current && status === 'listening') {
- try {
- recognitionRef.current.stop()
- setStatus('ready')
- } catch (e) {
- console.error('[VoiceManager] Error stopping recognition:', e)
- }
- }
-
- // Pause audio if playing
- if (audioRef.current && !audioRef.current.paused) {
- audioRef.current.pause()
- setStatus('ready')
- }
- }, [status])
-
- // Internal start listening function that doesn't depend on startListening
- const startListeningInternal = useCallback(async () => {
- if (pausedStateRef.current) return
-
- // Initialize if needed
- if (!recognitionRef.current) {
- if (!initializeSpeechRecognition()) {
- return
- }
- }
-
- // Don't start if already listening
- if (status === 'listening') return
-
- try {
- // Request microphone permission if needed
- if (navigator.mediaDevices?.getUserMedia) {
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
- stream.getTracks().forEach(track => track.stop())
- } catch (micError) {
- console.error('[VoiceManager] Microphone permission denied:', micError)
- setStatus('permission-required')
- return
- }
- }
-
- setStatus('listening')
- setTranscript('')
- setErrorMessage(null)
- recognitionRef.current.start()
- console.log('[VoiceManager] Listening started from resume')
- } catch (error: any) {
- console.error('[VoiceManager] Failed to start listening:', error)
- }
- }, [status, initializeSpeechRecognition])
-
- const resumeConversation = useCallback(async () => {
- console.log('[VoiceManager] Resuming conversation...')
- setIsPaused(false)
- pausedStateRef.current = false
- conversationActiveRef.current = true
-
- // Resume audio if it was paused
- if (audioRef.current && audioRef.current.paused && audioRef.current.src) {
- try {
- await audioRef.current.play()
- setStatus('speaking')
- console.log('[VoiceManager] Resumed audio playback')
- } catch (e) {
- console.error('[VoiceManager] Failed to resume audio:', e)
- setStatus('ready')
- // Try to start listening after a delay
- setTimeout(() => {
- startListeningInternal()
- }, 500)
- }
- } else {
- // Start listening after a delay
- setStatus('ready')
- setTimeout(() => {
- startListeningInternal()
- }, 500)
- }
- }, [startListeningInternal])
-
- // Start listening
- const startListening = useCallback(async () => {
- console.log('[VoiceManager] Starting listening...')
-
- // Don't start if explicitly paused
- if (pausedStateRef.current || !conversationActiveRef.current) {
- console.log('[VoiceManager] Conversation paused or stopped, not starting')
- return
- }
-
- // Don't start if audio is playing
- if (audioRef.current && !audioRef.current.paused) {
- console.log('[VoiceManager] Audio playing, waiting...')
- return
- }
-
- // Initialize if needed
- if (!recognitionRef.current) {
- if (!initializeSpeechRecognition()) {
- return
- }
- }
-
- // Check if already listening
- if (status === 'listening') {
- console.log('[VoiceManager] Already listening')
- return
- }
-
- try {
- // Request microphone permission
- if (navigator.mediaDevices?.getUserMedia) {
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
- console.log('[VoiceManager] Microphone permission granted')
- // Stop the stream immediately after permission check
- stream.getTracks().forEach(track => track.stop())
- } catch (micError: any) {
- console.error('[VoiceManager] Microphone permission denied:', micError)
- setStatus('permission-required')
- setErrorMessage('Tap to allow microphone access')
- setIsAutoplayBlocked(true)
- return
- }
- }
-
- setStatus('listening')
- setTranscript('')
- setErrorMessage(null)
- recognitionRef.current.start()
- console.log('[VoiceManager] Listening started successfully')
-
- } catch (error: any) {
- console.error('[VoiceManager] Failed to start listening:', error)
- setStatus('error')
- setErrorMessage('Voice recognition failed to start')
- }
- }, [status, initializeSpeechRecognition, isPaused])
-
- // Stop listening
- const stopListening = useCallback(() => {
- if (recognitionRef.current && status === 'listening') {
- try {
- recognitionRef.current.stop()
- setStatus('ready')
- console.log('[VoiceManager] Stopped listening')
- } catch (error) {
- console.error('[VoiceManager] Failed to stop:', error)
- }
- }
- }, [status])
-
- // SIMPLIFIED: Play Bubbe's voice response
- const playBubbeVoice = useCallback(async (text: string) => {
- if (!text) return
-
- // Don't play if paused
- if (isPaused || pausedStateRef.current) {
- console.log('[VoiceManager] Conversation paused, not playing voice')
- return
- }
-
- console.log('[VoiceManager] 🎯 SIMPLIFIED: Playing Bubbe voice:', text.substring(0, 50))
- setStatus('speaking')
-
- try {
- // Get voice from API
- console.log('[VoiceManager] 📡 Fetching audio from API...')
- const response = await fetch('/api/bubbe-voice', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- text,
- mode: 'bubbe',
- voiceId: 'yU6e7uJLZCE8eA26VAqb'
- })
- })
-
- if (!response.ok) {
- throw new Error(`API failed with status ${response.status}`)
- }
-
- console.log('[VoiceManager] 📦 Got response, converting to blob...')
- const blob = await response.blob()
- console.log('[VoiceManager] 📦 Blob size:', blob.size)
-
- if (blob.size === 0) {
- throw new Error('Empty audio blob received')
- }
-
- const audioUrl = URL.createObjectURL(blob)
- console.log('[VoiceManager] 🔗 Created object URL:', audioUrl.substring(0, 50) + '...')
-
- // SIMPLE AUDIO PLAYBACK - NO COMPLEX STUFF
- const audio = new Audio(audioUrl)
- audio.volume = 0.8
- audio.preload = 'auto'
-
- // Store reference
- audioRef.current = audio
-
- // Simple event handlers
- audio.onended = () => {
- console.log('[VoiceManager] ✅ Audio playback finished - RESTARTING LISTENING NOW!')
- setStatus('ready')
- URL.revokeObjectURL(audioUrl)
- audioRef.current = null
-
- // CRITICAL: Always restart listening after Bubbe finishes talking unless explicitly paused
- setTimeout(() => {
- console.log('[VoiceManager] 🔄 FORCING listening restart after audio')
- if (!pausedStateRef.current && conversationActiveRef.current) {
- console.log('[VoiceManager] 📢 Conversation active - restarting listening...')
- startListening()
- } else {
- console.log('[VoiceManager] 🛑 Not restarting - conversation paused or stopped')
- }
- }, 500); // Reduced delay for more responsive conversation
- };
-
- audio.onerror = (e) => {
- console.error('[VoiceManager] ❌ Audio playback error:', e)
- setStatus('ready')
- URL.revokeObjectURL(audioUrl)
- audioRef.current = null
- setErrorMessage('Audio playback failed')
-
- // Still try to start listening
- if (autoStart) {
- setTimeout(() => startListening(), 500);
- }
- };
-
- audio.onplay = () => {
- console.log('[VoiceManager] 🔊 Audio started playing!')
- setIsAutoplayBlocked(false)
- setErrorMessage(null)
- }
-
- // SIMPLE PLAY - NO PROMISES OR COMPLEX HANDLING
- console.log('[VoiceManager] ▶️ Attempting to play audio...')
-
- try {
- // Load the audio first
- audio.load()
-
- // Try to play
- const playPromise = audio.play()
-
- if (playPromise && typeof playPromise.then === 'function') {
- // Modern browsers return a promise
- playPromise
- .then(() => {
- console.log('[VoiceManager] ✅ Audio playing successfully!')
- })
- .catch((playError: any) => {
- console.warn('[VoiceManager] ⚠️ Autoplay blocked:', playError.message)
- setIsAutoplayBlocked(true)
- setStatus('permission-required')
- setErrorMessage('Tap to enable audio')
- })
- } else {
- // Older browsers
- console.log('[VoiceManager] ✅ Audio play() called (legacy mode)')
- }
- } catch (playError: any) {
- console.warn('[VoiceManager] ⚠️ Play failed:', playError.message)
- setIsAutoplayBlocked(true)
- setStatus('permission-required')
- setErrorMessage('Tap to enable audio')
- }
-
- } catch (error: any) {
- console.error('[VoiceManager] ❌ Failed to play voice:', error)
- setStatus('ready')
- setErrorMessage(`Voice error: ${error.message}`)
-
- // Still try to start listening even if voice fails
- if (autoStart) {
- setTimeout(() => startListening(), 1000)
- }
- }
- }, [autoStart, startListening, isPaused])
-
- // Handle Bubbe response
- const lastResponseRef = useRef<string>('')
-
- useEffect(() => {
- console.log('[VoiceManager] 🔊 bubbeResponse changed:', bubbeResponse?.substring(0, 50))
- if (bubbeResponse && bubbeResponse.trim() && bubbeResponse !== lastResponseRef.current) {
- console.log('[VoiceManager] 🎵 New response detected, playing voice!')
- lastResponseRef.current = bubbeResponse
- playBubbeVoice(bubbeResponse)
- } else {
- if (!bubbeResponse) {
- console.log('[VoiceManager] ⏭️ No bubbeResponse to play')
- } else if (bubbeResponse === lastResponseRef.current) {
- console.log('[VoiceManager] ⏭️ Same response, skipping duplicate')
- }
- }
- }, [bubbeResponse, playBubbeVoice])
-
- // Handle autoplay unlock
- const unlockAutoplay = useCallback(async () => {
- console.log('[VoiceManager] 🔓 Unlocking autoplay...')
-
- try {
- // If we have a saved audio, play it now
- if (audioRef.current) {
- console.log('[VoiceManager] 🎵 Playing saved audio after unlock...')
- try {
- await audioRef.current.play()
- console.log('[VoiceManager] ✅ Saved audio now playing!')
- setIsAutoplayBlocked(false)
- setErrorMessage(null)
- setStatus('speaking')
- return
- } catch (playError: any) {
- console.warn('[VoiceManager] ⚠️ Failed to play saved audio:', playError.message)
- }
- }
-
- // Try playing silent audio to unlock
- const silentAudio = new Audio('data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhAC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7v//////////////////////////////////////////////////////////////////8AAAAATEFNRTMuMTAwBLkAAAAAAAAAABUgJAUHQQAB4AAAA4S0PQ9ZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//tQxAADB8AhSmxhIIEVCSiJrDJMQBEAAAQCCAAAABLA')
- silentAudio.volume = 0.01
-
- await silentAudio.play()
- console.log('[VoiceManager] ✅ Silent audio unlock successful')
-
- setIsAutoplayBlocked(false)
- setErrorMessage(null)
- setStatus('ready')
-
- // Now start the conversation with Bubbe's greeting
- if (autoStart) {
- console.log('[VoiceManager] 🚀 Triggering Bubbe greeting after unlock...')
- setTimeout(() => {
- onTranscript('__voice_init__')
- }, 200)
- }
-
- } catch (error: any) {
- console.error('[VoiceManager] ❌ Failed to unlock autoplay:', error)
-
- // Fallback: just proceed
- setIsAutoplayBlocked(false)
- setErrorMessage('Audio may be limited - continue anyway')
- setStatus('ready')
-
- if (autoStart) {
- setTimeout(() => {
- onTranscript('__voice_init__')
- }, 200)
- }
- }
- }, [autoStart, onTranscript])
-
- // Initialize on mount
- useEffect(() => {
- // Use global flag to prevent duplicate initialization
- if ((window as any).__bubbeAutoStarted) {
- console.log('[VoiceManager] ⚠️ Already auto-started, skipping...')
- return
- }
-
- if (!initializeAttempted.current && autoStart) {
- initializeAttempted.current = true;
- (window as any).__bubbeAutoStarted = true; // Set global flag
-
- console.log('[VoiceManager] ⚡ AUTO-START: Initializing voice chat...')
-
- const initialize = async () => {
- try {
- // Initialize speech recognition
- const initialized = initializeSpeechRecognition()
-
- if (initialized) {
- console.log('[VoiceManager] ✅ Speech recognition initialized')
- setStatus('ready')
-
- // FORCE Bubbe greeting with multiple attempts
- console.log('[VoiceManager] 🚀 FORCING BUBBE GREETING WITH RETRIES!')
-
- // First attempt - immediate
- onTranscript('__voice_init__')
-
- // Second attempt - after 500ms
- setTimeout(() => {
- if (!lastResponseRef.current) {
- console.log('[VoiceManager] 🔄 Retry 1: Triggering greeting...')
- onTranscript('__voice_init__')
- }
- }, 500)
-
- // Third attempt - after 1s
- setTimeout(() => {
- if (!lastResponseRef.current) {
- console.log('[VoiceManager] 🔄 Retry 2: Forcing greeting...')
- onTranscript('__voice_init__')
- }
- }, 1000)
-
- // Fourth attempt - after 2s
- setTimeout(() => {
- if (!lastResponseRef.current) {
- console.log('[VoiceManager] 🔄 Retry 3: Last attempt greeting...')
- onTranscript('__voice_init__')
- }
- }, 2000)
- } else {
- setStatus('error')
- setErrorMessage('Voice recognition not supported')
- }
- } catch (error: any) {
- console.error('[VoiceManager] ❌ Initialization failed:', error)
- setStatus('error')
- setErrorMessage('Failed to initialize voice features')
- }
- }
-
- initialize()
- }
- }, [autoStart, initializeSpeechRecognition, onTranscript])
-
- // Clean up on unmount
- useEffect(() => {
- return () => {
- if (recognitionRef.current) {
- try {
- recognitionRef.current.stop()
- } catch (e) {}
- }
- if (audioRef.current) {
- audioRef.current.pause()
- audioRef.current = null
- }
- }
- }, [])
-
- return (
- <div className="voice-manager-mobile">
- {/* Pause/Resume button - Mobile optimized */}
- <div className="pause-resume-container">
- <button
- onClick={isPaused ? resumeConversation : pauseConversation}
- className={`pause-resume-button ${isPaused ? 'paused' : 'active'}`}
- aria-label={isPaused ? 'Resume conversation' : 'Pause conversation'}
- >
- {isPaused ? (
- <>
- <span className="pause-icon">▶️</span>
- <span className="pause-text">Resume</span>
- </>
- ) : (
- <>
- <span className="pause-icon">⏸️</span>
- <span className="pause-text">Pause</span>
- </>
- )}
- </button>
- </div>
-
- {/* Status indicator - Mobile optimized */}
- <div className="mobile-voice-status">
- <div className="mobile-voice-indicator">
- {isPaused ? (
- <>
- <div className="mobile-status-dot mobile-status-paused" />
- <span className="mobile-status-text">Conversation Paused</span>
- </>
- ) : (
- <>
- {status === 'listening' && (
- <>
- <div className="mobile-status-dot mobile-status-listening" />
- <span className="mobile-status-text">Listening...</span>
- </>
- )}
- {status === 'processing' && (
- <>
- <div className="mobile-status-dot mobile-status-processing" />
- <span className="mobile-status-text">Processing...</span>
- </>
- )}
- {status === 'speaking' && (
- <>
- <div className="mobile-status-dot mobile-status-speaking" />
- <span className="mobile-status-text">Bubbe is talking...</span>
- </>
- )}
- {status === 'ready' && (
- <>
- <div className="mobile-status-dot mobile-status-ready" />
- <span className="mobile-status-text">Ready</span>
- </>
- )}
- {status === 'error' && (
- <>
- <div className="mobile-status-dot mobile-status-error" />
- <span className="mobile-status-text-error">{errorMessage}</span>
- </>
- )}
- </>
- )}
- </div>
- </div>
-
- {/* Transcript display - Mobile optimized */}
- {transcript && (
- <div className="mobile-transcript">
- <p className="mobile-transcript-text">
- <span className="mobile-transcript-label">You:</span> {transcript}
- </p>
- </div>
- )}
-
- {/* Pause/Resume Button */}
- <div className="voice-control-container">
- <button
- onClick={isPaused ? resumeConversation : pauseConversation}
- className="voice-control-button"
- style={{
- minHeight: '44px',
- minWidth: '120px',
- padding: '8px 16px',
- backgroundColor: isPaused ? '#22c55e' : '#ef4444',
- color: 'white',
- border: 'none',
- borderRadius: '8px',
- fontSize: '14px',
- fontWeight: 'bold',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- gap: '6px',
- cursor: 'pointer',
- zIndex: '10'
- }}
- >
- <span style={{ fontSize: '16px' }}>
- {isPaused ? '▶️' : '⏸️'}
- </span>
- <span>{isPaused ? 'Resume' : 'Pause'}</span>
- </button>
- </div>
-
- {/* Mobile Start Overlay - Shows when autoplay is blocked */}
- <MobileStartOverlay
- show={status === 'permission-required' || isAutoplayBlocked}
- onStart={unlockAutoplay}
- />
-
- {/* Debug controls - Hidden on mobile, visible on desktop */}
- {!isMobile && (
- <div className="mobile-debug-controls">
- <button
- onClick={() => startListening()}
- className="mobile-debug-button mobile-debug-start"
- disabled={status === 'listening'}
- >
- 🎤
- </button>
- <button
- onClick={() => stopListening()}
- className="mobile-debug-button mobile-debug-stop"
- disabled={status !== 'listening'}
- >
- ⏹️
- </button>
- <button
- onClick={async () => {
- console.log('🔊 TEST VOICE BUTTON CLICKED!')
- await playBubbeVoice('Oy vey, you want to test my voice? What am I, a machine? Fine, here is your test, schmuck!')
- }}
- className="mobile-debug-button mobile-debug-test"
- >
- 🔊
- </button>
- </div>
- )}
- </div>
- )
-}
\ No newline at end of file
← f3aff35 refactor(bubbe): typed SpeechRecognition shim + drop dead wi
·
back to Dear Bubbe Nextjs
·
fix(bubbe/mobile): create SpeechRecognition once + latest-re 557b99d →