Spaces:
Sleeping
Sleeping
| """Chat widget HTML interface for ChatCal.ai.""" | |
| from fastapi import APIRouter, Request | |
| from fastapi.responses import HTMLResponse | |
| router = APIRouter() | |
| async def chat_widget(request: Request, email: str = None): | |
| """Embeddable chat widget.""" | |
| # Force HTTPS for production HuggingFace deployment | |
| from app.config import settings | |
| if settings.app_env == "production" and "hf.space" in str(request.url.netloc): | |
| base_url = f"https://{request.url.netloc}" | |
| else: | |
| base_url = f"{request.url.scheme}://{request.url.netloc}" | |
| # Pass the email parameter to the frontend | |
| default_email = email or "" | |
| # Debug logging | |
| print(f"π Chat widget called with email parameter: '{email}' -> defaultEmail: '{default_email}'") | |
| html_content = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>VoiceCal.ai - Calendar Assistant</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| } | |
| .chat-container { | |
| background: white; | |
| border-radius: 20px; | |
| box-shadow: 0 20px 40px rgba(0,0,0,0.1); | |
| width: 100%; | |
| max-width: 800px; | |
| height: 600px; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| .chat-header { | |
| background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); | |
| color: white; | |
| padding: 20px; | |
| text-align: center; | |
| position: relative; | |
| } | |
| .chat-header h1 { | |
| font-size: 24px; | |
| margin-bottom: 5px; | |
| } | |
| .chat-header p { | |
| opacity: 0.9; | |
| font-size: 14px; | |
| } | |
| .status-indicator { | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| width: 12px; | |
| height: 12px; | |
| background: #4CAF50; | |
| border-radius: 50%; | |
| border: 2px solid white; | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| 100% { opacity: 1; } | |
| } | |
| .chat-messages { | |
| flex: 1; | |
| padding: 20px; | |
| overflow-y: auto; | |
| background: #f8f9fa; | |
| } | |
| .message { | |
| margin-bottom: 15px; | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 10px; | |
| } | |
| .message.user { | |
| flex-direction: row-reverse; | |
| } | |
| .message-avatar { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 16px; | |
| flex-shrink: 0; | |
| } | |
| .message.user .message-avatar { | |
| background: #2196F3; | |
| color: white; | |
| } | |
| .message.assistant .message-avatar { | |
| background: #4CAF50; | |
| color: white; | |
| } | |
| .message-content { | |
| max-width: 70%; | |
| padding: 12px 16px; | |
| border-radius: 18px; | |
| line-height: 1.4; | |
| white-space: pre-wrap; | |
| } | |
| .message.user .message-content { | |
| background: #2196F3; | |
| color: white; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .message.assistant .message-content { | |
| background: white; | |
| color: #333; | |
| border: 1px solid #e0e0e0; | |
| border-bottom-left-radius: 4px; | |
| } | |
| .chat-input { | |
| padding: 20px; | |
| background: white; | |
| border-top: 1px solid #e0e0e0; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .chat-input textarea { | |
| flex: 1; | |
| padding: 12px 16px 12px 60px; /* Extra left padding for microphone button */ | |
| border: 2px solid #e0e0e0; | |
| border-radius: 20px; | |
| outline: none; | |
| font-size: 14px; | |
| font-family: inherit; | |
| resize: none; | |
| min-height: 20px; | |
| max-height: 120px; | |
| overflow-y: auto; | |
| line-height: 1.4; | |
| transition: border-color 0.3s; | |
| } | |
| .chat-input textarea:focus { | |
| border-color: #4CAF50; | |
| } | |
| .chat-input button { | |
| width: 40px; | |
| height: 40px; | |
| border: none; | |
| background: #4CAF50; | |
| color: white; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: background-color 0.3s; | |
| } | |
| .chat-input button:hover { | |
| background: #45a049; | |
| } | |
| .chat-input button:disabled { | |
| background: #ccc; | |
| cursor: not-allowed; | |
| } | |
| .typing-indicator { | |
| display: none; | |
| padding: 12px 16px; | |
| background: white; | |
| border: 1px solid #e0e0e0; | |
| border-radius: 18px; | |
| border-bottom-left-radius: 4px; | |
| max-width: 70%; | |
| margin-bottom: 15px; | |
| } | |
| .typing-dots { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| .typing-dots span { | |
| width: 8px; | |
| height: 8px; | |
| background: #999; | |
| border-radius: 50%; | |
| animation: typing 1.4s infinite; | |
| } | |
| .typing-dots span:nth-child(2) { | |
| animation-delay: 0.2s; | |
| } | |
| .typing-dots span:nth-child(3) { | |
| animation-delay: 0.4s; | |
| } | |
| @keyframes typing { | |
| 0%, 60%, 100% { | |
| transform: translateY(0); | |
| opacity: 0.4; | |
| } | |
| 30% { | |
| transform: translateY(-10px); | |
| opacity: 1; | |
| } | |
| } | |
| .welcome-message { | |
| text-align: center; | |
| color: #666; | |
| font-style: italic; | |
| margin: 20px 0; | |
| } | |
| .user-info-display { | |
| background: #f8f9fa; | |
| border: 1px solid #e9ecef; | |
| border-radius: 6px; | |
| padding: 8px 12px; | |
| margin: 10px 0; | |
| font-size: 12px; | |
| color: #495057; | |
| display: none; /* Hidden by default */ | |
| } | |
| .user-info-display.visible { | |
| display: block; | |
| } | |
| .user-info-display strong { | |
| color: #212529; | |
| } | |
| /* Header user info styling - UPDATED */ | |
| .user-info-header { | |
| background: linear-gradient(135deg, #e8f5e9, #f1f8e9); | |
| border: 2px solid #4CAF50; | |
| border-radius: 8px; | |
| padding: 12px 20px; | |
| margin: 10px 0 0 0; | |
| font-size: 14px; | |
| color: #2e7d32; | |
| text-align: center; | |
| display: block; /* ALWAYS VISIBLE FOR TESTING */ | |
| box-shadow: 0 3px 6px rgba(0,0,0,0.15); | |
| font-weight: bold; | |
| } | |
| .user-info-header.visible { | |
| display: block; | |
| } | |
| .user-info-header strong { | |
| color: #1976d2; | |
| } | |
| .quick-actions { | |
| display: flex; | |
| gap: 10px; | |
| margin: 10px 0; | |
| flex-wrap: wrap; | |
| } | |
| .quick-action { | |
| background: #f0f0f0; | |
| color: #555; | |
| padding: 8px 12px; | |
| border-radius: 15px; | |
| border: none; | |
| cursor: pointer; | |
| font-size: 12px; | |
| transition: background-color 0.3s; | |
| } | |
| .quick-action:hover { | |
| background: #e0e0e0; | |
| } | |
| /* STT Quick Action Animation */ | |
| .quick-action.listening { | |
| animation: stt-recording-pulse 1.5s infinite; | |
| } | |
| @keyframes stt-recording-pulse { | |
| 0% { | |
| transform: scale(1); | |
| box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); | |
| } | |
| 50% { | |
| transform: scale(1.05); | |
| box-shadow: 0 0 0 8px rgba(244, 67, 54, 0.1); | |
| } | |
| 100% { | |
| transform: scale(1); | |
| box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); | |
| } | |
| } | |
| /* Position chat input area for button placement */ | |
| .chat-input { | |
| position: relative; | |
| } | |
| /* Floating Record Button - positioned inside text input area */ | |
| .floating-record-btn { | |
| position: absolute; | |
| bottom: 12px; | |
| left: 12px; | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 50%; | |
| background: #4CAF50; | |
| color: white; | |
| border: none; | |
| font-size: 16px; | |
| cursor: pointer; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); | |
| transition: all 0.3s ease; | |
| z-index: 1000; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .floating-record-btn:hover { | |
| background: #45a049; | |
| transform: scale(1.1); | |
| box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); | |
| } | |
| .floating-record-btn.listening { | |
| background: #f44336; | |
| animation: float-recording-pulse 1.5s infinite; | |
| } | |
| .floating-record-btn.connecting { | |
| background: #ff9800; | |
| } | |
| .floating-record-btn.error { | |
| background: #ff5722; | |
| } | |
| @keyframes float-recording-pulse { | |
| 0% { | |
| transform: scale(1); | |
| box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4); | |
| } | |
| 50% { | |
| transform: scale(1.1); | |
| box-shadow: 0 6px 20px rgba(244, 67, 54, 0.6); | |
| } | |
| 100% { | |
| transform: scale(1); | |
| box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4); | |
| } | |
| } | |
| /* Recording notification popup */ | |
| .recording-popup { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| color: white; | |
| padding: 20px; | |
| border-radius: 12px; | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.3); | |
| z-index: 10000; | |
| text-align: center; | |
| animation: popupSlideIn 0.3s ease-out; | |
| } | |
| .popup-content .recording-icon { | |
| font-size: 32px; | |
| margin-bottom: 10px; | |
| animation: pulse 1.5s ease-in-out infinite; | |
| } | |
| .popup-content p { | |
| margin: 5px 0; | |
| font-weight: bold; | |
| font-size: 16px; | |
| } | |
| .popup-content .popup-subtitle { | |
| font-size: 14px; | |
| opacity: 0.9; | |
| font-weight: normal; | |
| } | |
| @keyframes popupSlideIn { | |
| from { | |
| opacity: 0; | |
| transform: translateX(-50%) translateY(-20px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateX(-50%) translateY(0); | |
| } | |
| } | |
| /* Audio visualizer */ | |
| .audio-visualizer { | |
| position: relative; | |
| margin: 15px auto 20px auto; | |
| width: fit-content; | |
| background: rgba(76, 175, 80, 0.9); | |
| padding: 15px; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.2); | |
| z-index: 1000; | |
| backdrop-filter: blur(10px); | |
| } | |
| .audio-visualizer canvas { | |
| display: block; | |
| border-radius: 8px; | |
| background: rgba(0,0,0,0.1); | |
| } | |
| .visualizer-info { | |
| text-align: center; | |
| color: white; | |
| font-size: 12px; | |
| margin-top: 8px; | |
| font-weight: bold; | |
| } | |
| /* Recording button states - CLEAR MESSAGING */ | |
| /* NOT RECORDING - Green (Ready to record) */ | |
| .floating-record-btn.ready-to-record { | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| color: white; | |
| box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); | |
| border: 2px solid #4CAF50; | |
| } | |
| .floating-record-btn.ready-to-record:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 6px 16px rgba(76, 175, 80, 0.5); | |
| } | |
| /* CURRENTLY RECORDING - Red (Click to stop) */ | |
| .floating-record-btn.currently-recording { | |
| background: linear-gradient(135deg, #f44336, #d32f2f); | |
| color: white; | |
| animation: recordingPulse 1.5s ease-in-out infinite; | |
| box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4); | |
| border: 2px solid #f44336; | |
| } | |
| @keyframes recordingPulse { | |
| 0%, 100% { transform: scale(1); opacity: 1; } | |
| 50% { transform: scale(1.08); opacity: 0.9; } | |
| } | |
| /* TTS playing state for microphone button */ | |
| .floating-record-btn.tts-playing { | |
| background: linear-gradient(135deg, #FF9800, #F57C00); | |
| color: white; | |
| animation: ttsPause 1s ease-in-out infinite; | |
| box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4); | |
| } | |
| /* TTS playing with interrupt capability */ | |
| .floating-record-btn.tts-playing-interruptible { | |
| background: linear-gradient(135deg, #2196F3, #1976D2); | |
| color: white; | |
| animation: ttsInterruptible 2s ease-in-out infinite; | |
| box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4); | |
| } | |
| @keyframes ttsInterruptible { | |
| 0%, 100% { | |
| transform: scale(1); | |
| opacity: 1; | |
| box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4); | |
| } | |
| 50% { | |
| transform: scale(1.05); | |
| opacity: 0.9; | |
| box-shadow: 0 6px 16px rgba(33, 150, 243, 0.6); | |
| } | |
| } | |
| @keyframes ttsPause { | |
| 0%, 100% { opacity: 0.7; } | |
| 50% { opacity: 1; } | |
| } | |
| /* Mute/Unmute Toggle Button */ | |
| .mute-toggle-btn { | |
| position: absolute; | |
| right: 80px; /* Position 20px further left from send button */ | |
| top: 50%; | |
| transform: translateY(-50%); | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| color: white; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| font-size: 12px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.1); | |
| z-index: 10; | |
| min-width: 60px; | |
| } | |
| .mute-toggle-btn:hover { | |
| transform: translateY(-50%) scale(1.05); | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.2); | |
| } | |
| /* Unmuted state - Green (ready to mute) */ | |
| .mute-toggle-btn.unmuted { | |
| background: linear-gradient(135deg, #4CAF50, #45a049); | |
| box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3); | |
| } | |
| /* Muted state - Red (ready to unmute) */ | |
| .mute-toggle-btn.muted { | |
| background: linear-gradient(135deg, #f44336, #d32f2f); | |
| box-shadow: 0 2px 8px rgba(244, 67, 54, 0.3); | |
| animation: mutedPulse 2s ease-in-out infinite; | |
| } | |
| @keyframes mutedPulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="chat-container"> | |
| <div class="chat-header"> | |
| <div class="status-indicator"></div> | |
| <h1>π VoiceCal.ai</h1> | |
| <p>Your friendly AI calendar assistant</p> | |
| <!-- User Info Display - UPDATED v1.3.0 - Email Only --> | |
| <div id="userInfoDisplay" class="user-info-header"> | |
| <strong>π§ Your email address:</strong> <span id="userEmailDisplay">Loading...</span> | |
| </div> | |
| </div> | |
| <div class="chat-messages" id="chatMessages"> | |
| <div class="welcome-message"> | |
| π Welcome! I'm VoiceCal, Peter Michael Gits' scheduling assistant.<br> | |
| Just speak into your microphone. | |
| </div> | |
| <div style="text-align: left; background: #e8f5e9; padding: 10px 15px; margin: 15px 15px 80px 15px; border-radius: 8px; border-left: 4px solid #4caf50; font-size: 14px;"> | |
| <p style="margin: 0; font-weight: bold; font-size: 15px;">To book a meeting:</p> | |
| <p style="margin: 5px 0 2px 0; font-size: 13px;">1) Say your name</p> | |
| <p style="margin: 2px 0; font-size: 13px;">2) The date, time, length, and agenda</p> | |
| <p style="margin: 2px 0; font-size: 13px;">3) GoogleMeet conference or phone call</p> | |
| <p style="margin: 2px 0 0 0; font-size: 13px;">4) Your phone number, in case I need to call you</p> | |
| </div> | |
| <div class="quick-actions"> | |
| <button class="quick-action" onclick="sendQuickMessage('Schedule a Google Meet with Peter')">π₯ Google Meet</button> | |
| <button class="quick-action" onclick="sendQuickMessage('Check Peter\\'s availability tomorrow')">π Check availability</button> | |
| <button class="quick-action" onclick="sendQuickMessage('Schedule an in-person meeting')">π€ In-person meeting</button> | |
| <button class="quick-action" onclick="sendQuickMessage('/help')">β Help</button> | |
| </div> | |
| </div> | |
| <div class="typing-indicator" id="typingIndicator"> | |
| <div class="typing-dots"> | |
| <span></span> | |
| <span></span> | |
| <span></span> | |
| </div> | |
| </div> | |
| <!-- Recording notification popup --> | |
| <div id="recordingPopup" class="recording-popup" style="display: none;"> | |
| <div class="popup-content"> | |
| <div class="recording-icon">ποΈ</div> | |
| <p>You are being recorded</p> | |
| <p class="popup-subtitle">Please follow the instructions</p> | |
| </div> | |
| </div> | |
| <div class="chat-input"> | |
| <!-- Record Button positioned relative to input --> | |
| <button id="sttIndicator" class="floating-record-btn ready-to-record" title="Microphone initializing...">ποΈ</button> | |
| <textarea | |
| id="messageInput" | |
| placeholder="Type your message..." | |
| maxlength="1000" | |
| rows="1" | |
| ></textarea> | |
| <!-- Mute/Unmute Toggle Button --> | |
| <button id="muteToggle" class="mute-toggle-btn unmuted" title="Click to MUTE microphone">Mute</button> | |
| <button id="sendButton" type="button"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <!-- Audio visualization (moved below text input) --> | |
| <div id="audioVisualizer" class="audio-visualizer" style="display: none;"> | |
| <canvas id="audioCanvas" width="300" height="60"></canvas> | |
| <div class="visualizer-info">π€ Listening...</div> | |
| </div> | |
| <!-- Version Footer --> | |
| <div style="text-align: center; margin-top: 10px; padding: 5px; color: #999; font-size: 10px; border-top: 1px solid #f0f0f0;"> | |
| VoiceCal.ai v0.3.0 | β‘ Streaming + Interruption | π§ Smart Email Verification | |
| </div> | |
| </div> | |
| <!-- Hidden audio element for TTS playback --> | |
| <audio id="ttsAudioElement" style="display: none;"></audio> | |
| <script> | |
| let sessionId = null; | |
| let isLoading = false; | |
| // Default email from landing page (if provided) | |
| const defaultEmail = '{default_email}'; | |
| console.log('π Default email from landing page:', defaultEmail || 'None provided'); | |
| console.log('π defaultEmail variable type:', typeof defaultEmail); | |
| console.log('π defaultEmail length:', defaultEmail.length); | |
| console.log('π defaultEmail value (raw):', JSON.stringify(defaultEmail)); | |
| console.log('π URL params for debugging:', window.location.search); | |
| // Additional debug - check if email is being passed correctly | |
| if (defaultEmail && defaultEmail.trim() && defaultEmail !== 'None' && defaultEmail !== '') { | |
| console.log('β Email successfully passed from landing page:', defaultEmail); | |
| } else { | |
| console.log('β Email NOT passed from landing page or is empty'); | |
| console.log('π Possible reasons: 1) User bypassed landing page, 2) Email not in URL params, 3) Template substitution failed'); | |
| } | |
| // Check if user came through landing page with email | |
| if (!defaultEmail || defaultEmail.trim() === '') { | |
| console.log('β οΈ No email provided from landing page - user bypassed email form'); | |
| // Add helpful message about providing email | |
| const emailReminder = document.createElement('div'); | |
| emailReminder.innerHTML = ` | |
| <div style="background: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 10px; margin: 10px 0; border-radius: 6px; font-size: 14px;"> | |
| π‘ <strong>Tip:</strong> For faster booking, please provide your email address during our conversation so I can send you calendar invitations. | |
| </div> | |
| `; | |
| document.getElementById('chatMessages').appendChild(emailReminder); | |
| } | |
| const chatMessages = document.getElementById('chatMessages'); | |
| const messageInput = document.getElementById('messageInput'); | |
| const sendButton = document.getElementById('sendButton'); | |
| const typingIndicator = document.getElementById('typingIndicator'); | |
| const sttIndicator = document.getElementById('sttIndicator'); | |
| const muteToggle = document.getElementById('muteToggle'); | |
| // Shared Audio Variables (for both TTS and STT) | |
| let globalAudioContext = null; | |
| let globalMediaStream = null; | |
| let isAudioInitialized = false; | |
| // STT v2 Variables | |
| let sttv2Manager = null; | |
| let silenceTimer = null; | |
| let lastSpeechTime = 0; | |
| let hasReceivedSpeech = false; | |
| // TTS Integration - ChatCal WebRTC TTS Class | |
| class ChatCalTTS { | |
| constructor() { | |
| this.audioContext = null; | |
| this.audioElement = document.getElementById('ttsAudioElement'); | |
| this.webrtcEnabled = false; | |
| this.initializeTTS(); | |
| } | |
| async initializeTTS() { | |
| try { | |
| console.log('π€ Initializing WebRTC TTS for ChatCal...'); | |
| // Request microphone access to enable WebRTC autoplay policies | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| // Create AudioContext | |
| this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| // Resume if suspended | |
| if (this.audioContext.state === 'suspended') { | |
| await this.audioContext.resume(); | |
| } | |
| // Stop microphone (we don't need to record) | |
| stream.getTracks().forEach(track => track.stop()); | |
| this.webrtcEnabled = true; | |
| this.isPlaying = false; // Track if audio is currently playing | |
| this.audioQueue = []; // Queue for pending TTS requests | |
| console.log('β WebRTC TTS enabled for ChatCal'); | |
| } catch (error) { | |
| console.warn('β οΈ TTS initialization failed, continuing without TTS:', error); | |
| this.webrtcEnabled = false; | |
| } | |
| } | |
| async synthesizeAndPlay(text) { | |
| if (!this.webrtcEnabled || !text.trim()) { | |
| return; | |
| } | |
| // Prevent very long TTS (over 500 characters) | |
| if (text.length > 500) { | |
| console.log('π Skipping very long TTS text:', text.length, 'characters'); | |
| return; | |
| } | |
| // Prevent duplicate TTS requests for the same text | |
| if (this.audioQueue.includes(text)) { | |
| console.log('π Skipping duplicate TTS request for:', text.substring(0, 30) + '...'); | |
| return; | |
| } | |
| // Add to queue and process (may preempt current audio) | |
| this.audioQueue.push(text); | |
| console.log(`π Added to TTS queue (${this.audioQueue.length} items). Playing: ${this.isPlaying}`); | |
| this.processAudioQueue(); | |
| } | |
| async processAudioQueue() { | |
| // No longer preempt current audio - let it finish naturally | |
| // If already playing, wait for current audio to complete before starting next item | |
| // If still playing or queue is empty, don't start new playback | |
| if (this.isPlaying || this.audioQueue.length === 0) { | |
| return; | |
| } | |
| // Get next text from queue | |
| const text = this.audioQueue.shift(); | |
| this.isPlaying = true; | |
| try { | |
| console.log('π΅ Synthesizing TTS for:', text.substring(0, 50) + '...'); | |
| // DON'T stop currently playing audio - let it complete naturally | |
| // Only interrupt if user explicitly presses ESC key | |
| console.log('β³ TTS processing (current audio will continue)...'); | |
| // Call our TTS proxy endpoint (no CORS issues) | |
| const startTime = performance.now(); | |
| const response = await fetch('{base_url}/tts/synthesize', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| text: text, | |
| voice: 'expresso/ex03-ex01_happy_001_channel1_334s.wav' | |
| }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`TTS proxy error: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| const totalTime = performance.now() - startTime; | |
| console.log(`π΅ TTS completed in ${totalTime.toFixed(0)}ms:`, result); | |
| if (!result.success || !result.audio_url) { | |
| throw new Error('TTS generation failed'); | |
| } | |
| // Use the audio URL returned by our TTS backend | |
| const fullAudioUrl = result.audio_url; | |
| console.log('π Playing audio:', fullAudioUrl); | |
| // Set up audio element for playback | |
| this.audioElement.src = fullAudioUrl; | |
| this.audioElement.load(); | |
| // Add timeout to prevent queue from getting stuck | |
| const timeoutId = setTimeout(() => { | |
| console.warn('β° TTS playback timeout - resetting queue'); | |
| this.isPlaying = false; | |
| this.processAudioQueue(); | |
| }, 30000); // 30 second timeout | |
| // Add event listeners for when audio finishes | |
| const onAudioEnd = () => { | |
| console.log('π΅ TTS audio finished'); | |
| clearTimeout(timeoutId); | |
| this.isPlaying = false; | |
| this.audioElement.removeEventListener('ended', onAudioEnd); | |
| this.audioElement.removeEventListener('error', onAudioError); | |
| // Resume microphone after TTS completes | |
| resumeMicrophoneAfterTTS(); | |
| // Process next item in queue | |
| this.processAudioQueue(); | |
| }; | |
| const onAudioError = (error) => { | |
| console.warn('π TTS audio error:', error); | |
| clearTimeout(timeoutId); | |
| this.isPlaying = false; | |
| this.audioElement.removeEventListener('ended', onAudioEnd); | |
| this.audioElement.removeEventListener('error', onAudioError); | |
| // Resume microphone after TTS error | |
| resumeMicrophoneAfterTTS(); | |
| // Process next item in queue | |
| this.processAudioQueue(); | |
| }; | |
| this.audioElement.addEventListener('ended', onAudioEnd); | |
| this.audioElement.addEventListener('error', onAudioError); | |
| // Setup TTS with speech interrupt capability | |
| setupTTSWithInterrupt(this.audioElement); | |
| await this.audioElement.play(); | |
| console.log('π΅ TTS audio started successfully'); | |
| } catch (error) { | |
| console.warn('π TTS failed silently:', error); | |
| this.isPlaying = false; | |
| // Process next item in queue | |
| this.processAudioQueue(); | |
| } | |
| } | |
| async waitForTTSResult(eventId) { | |
| try { | |
| console.log('β³ Waiting for TTS queue result:', eventId); | |
| // Poll for the result | |
| const maxAttempts = 20; | |
| for (let attempt = 0; attempt < maxAttempts; attempt++) { | |
| await new Promise(resolve => setTimeout(resolve, 500)); // Wait 500ms | |
| const response = await fetch(`https://pgits-kyutai-tts-service-v3.hf.space/queue/data?event_id=${eventId}`); | |
| if (response.ok) { | |
| const text = await response.text(); | |
| const lines = text.split('\\n'); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| try { | |
| const data = JSON.parse(line.substring(6)); | |
| if (data.msg === 'process_completed' && data.output && data.output.data) { | |
| console.log('β TTS queue completed'); | |
| return data.output.data[0]; | |
| } | |
| } catch (e) { | |
| // Continue polling | |
| } | |
| } | |
| } | |
| } | |
| } | |
| throw new Error('TTS queue timeout'); | |
| } catch (error) { | |
| console.warn('TTS queue polling failed:', error); | |
| return null; | |
| } | |
| } | |
| async playAudioDirectly(audioUrl) { | |
| try { | |
| console.log('π Playing TTS audio directly...'); | |
| // Simple HTML5 audio playback | |
| // Setup TTS with speech interrupt capability | |
| setupTTSWithInterrupt(this.audioElement); | |
| this.audioElement.src = audioUrl; | |
| this.audioElement.load(); | |
| await this.audioElement.play(); | |
| console.log('π΅ TTS audio playing successfully'); | |
| } catch (error) { | |
| console.warn('π Audio playback failed:', error); | |
| } | |
| } | |
| } | |
| // Initialize TTS system | |
| const chatCalTTS = new ChatCalTTS(); | |
| // TTS interrupt functionality disabled per user request | |
| // No keyboard interrupts allowed for voice stream continuity | |
| /* | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Escape') { | |
| if (chatCalTTS.isPlaying) { | |
| console.log('π ESC key pressed - interrupting TTS playback'); | |
| chatCalTTS.audioElement.pause(); | |
| chatCalTTS.audioElement.currentTime = 0; | |
| chatCalTTS.isPlaying = false; | |
| chatCalTTS.audioQueue = []; // Clear any pending audio | |
| console.log('β TTS interrupted and queue cleared'); | |
| } | |
| } | |
| }); | |
| */ | |
| // Shared Audio Initialization (for TTS only now, STT v2 handles its own audio) | |
| async function initializeSharedAudio() { | |
| if (isAudioInitialized) return; | |
| console.log('π€ Initializing shared audio for TTS...'); | |
| // Basic audio context for TTS | |
| globalAudioContext = new AudioContext(); | |
| if (globalAudioContext.state === 'suspended') { | |
| await globalAudioContext.resume(); | |
| } | |
| isAudioInitialized = true; | |
| console.log('β Shared audio initialized for TTS'); | |
| } | |
| // STT Visual State Management | |
| function updateSTTVisualState(state) { | |
| const sttIndicator = document.getElementById('sttIndicator'); | |
| if (!sttIndicator) return; | |
| // Remove all status classes first | |
| sttIndicator.classList.remove('listening', 'connecting', 'error'); | |
| switch (state) { | |
| case 'ready': | |
| sttIndicator.innerHTML = 'ποΈ'; | |
| sttIndicator.title = 'Click to start voice recording'; | |
| break; | |
| case 'connecting': | |
| sttIndicator.innerHTML = 'π'; | |
| sttIndicator.title = 'Connecting to voice service...'; | |
| sttIndicator.classList.add('connecting'); | |
| break; | |
| case 'recording': | |
| sttIndicator.innerHTML = 'βΉοΈ'; | |
| sttIndicator.title = 'Click to stop recording and transcribe'; | |
| sttIndicator.classList.add('listening'); | |
| break; | |
| case 'processing': | |
| sttIndicator.innerHTML = 'β‘'; | |
| sttIndicator.title = 'Processing your speech...'; | |
| sttIndicator.classList.add('connecting'); // Use orange/connecting style | |
| break; | |
| case 'error': | |
| sttIndicator.innerHTML = 'β'; | |
| sttIndicator.title = 'Click to retry voice recording'; | |
| sttIndicator.classList.add('error'); | |
| break; | |
| } | |
| } | |
| // STT v2 Manager Class (adapted from stt-gpu-service-v2/client-stt/v2-audio-client.js) | |
| class STTv2Manager { | |
| constructor() { | |
| this.isRecording = false; | |
| this.mediaRecorder = null; | |
| this.audioChunks = []; | |
| this.serverUrl = 'https://pgits-stt-gpu-service-v2.hf.space'; | |
| this.language = 'en'; | |
| this.modelSize = 'base'; | |
| this.recordingTimer = null; | |
| this.maxRecordingTime = 30000; // 30 seconds max | |
| console.log('π€ STT v2 Manager initialized'); | |
| } | |
| generateSessionHash() { | |
| return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); | |
| } | |
| async toggleRecording() { | |
| if (!this.isRecording) { | |
| await this.startRecording(); | |
| } else { | |
| await this.stopRecording(); | |
| } | |
| } | |
| async startRecording() { | |
| try { | |
| console.log('π€ Starting STT v2 recording...'); | |
| updateSTTVisualState('connecting'); | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| sampleRate: 44100, | |
| channelCount: 1, | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true | |
| } | |
| }); | |
| // Try different formats based on browser support and Groq compatibility | |
| let mimeType; | |
| if (MediaRecorder.isTypeSupported('audio/wav')) { | |
| mimeType = 'audio/wav'; | |
| } else if (MediaRecorder.isTypeSupported('audio/mp4;codecs=aac')) { | |
| mimeType = 'audio/mp4;codecs=aac'; // MP4 with AAC codec (Groq compatible) | |
| } else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) { | |
| mimeType = 'audio/ogg;codecs=opus'; // OGG with Opus (Groq supports ogg) | |
| } else if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { | |
| mimeType = 'audio/webm;codecs=opus'; // WebM fallback | |
| } else { | |
| console.warn('β οΈ No preferred audio format supported, using default'); | |
| mimeType = undefined; // Let MediaRecorder choose | |
| } | |
| console.log(`π€ Using audio format: ${mimeType || 'default'}`); | |
| this.mediaRecorder = new MediaRecorder(stream, { | |
| mimeType: mimeType | |
| }); | |
| this.audioChunks = []; | |
| this.mediaRecorder.ondataavailable = (event) => { | |
| if (event.data.size > 0) { | |
| this.audioChunks.push(event.data); | |
| } | |
| }; | |
| this.mediaRecorder.onstop = () => { | |
| this.processRecording(); | |
| }; | |
| this.mediaRecorder.start(); | |
| this.isRecording = true; | |
| updateSTTVisualState('recording'); | |
| updateMicrophoneButtonState('recording'); | |
| // Auto-stop after max recording time | |
| this.recordingTimer = setTimeout(() => { | |
| if (this.isRecording) { | |
| console.log('β° Auto-stopping recording after 30 seconds'); | |
| this.stopRecording(); | |
| } | |
| }, this.maxRecordingTime); | |
| console.log('β STT v2 recording started (auto-stop in 30s)'); | |
| } catch (error) { | |
| console.error('β STT v2 recording failed:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| } | |
| } | |
| async stopRecording() { | |
| if (this.mediaRecorder && this.isRecording) { | |
| console.log('π Stopping STT v2 recording...'); | |
| // Clear the auto-stop timer | |
| if (this.recordingTimer) { | |
| clearTimeout(this.recordingTimer); | |
| this.recordingTimer = null; | |
| } | |
| this.mediaRecorder.stop(); | |
| this.isRecording = false; | |
| updateSTTVisualState('processing'); | |
| updateMicrophoneButtonState('ready'); | |
| // Stop all tracks | |
| this.mediaRecorder.stream.getTracks().forEach(track => track.stop()); | |
| console.log('β STT v2 recording stopped'); | |
| } | |
| } | |
| async processRecording() { | |
| if (this.audioChunks.length === 0) { | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| console.warn('β οΈ No audio recorded'); | |
| return; | |
| } | |
| try { | |
| console.log('π Processing STT v2 recording...'); | |
| // Create blob from chunks using the same MIME type as recording | |
| let blobType = 'audio/webm;codecs=opus'; // fallback | |
| if (this.mediaRecorder && this.mediaRecorder.mimeType) { | |
| blobType = this.mediaRecorder.mimeType; | |
| } | |
| const audioBlob = new Blob(this.audioChunks, { type: blobType }); | |
| console.log(`π¦ Audio blob created: ${audioBlob.size} bytes, type: ${blobType}`); | |
| // Send audio blob directly to transcription service (skip base64 conversion) | |
| await this.transcribeAudio(audioBlob, blobType); | |
| } catch (error) { | |
| console.error('β STT v2 processing failed:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| } | |
| } | |
| async blobToBase64(blob) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => { | |
| const result = reader.result; | |
| // Extract base64 part from data URL | |
| const base64 = result.split(',')[1]; | |
| resolve(base64); | |
| }; | |
| reader.onerror = reject; | |
| reader.readAsDataURL(blob); | |
| }); | |
| } | |
| base64ToBlob(base64, mimeType = 'audio/webm') { | |
| // Decode base64 to binary | |
| const byteCharacters = atob(base64); | |
| const byteNumbers = new Array(byteCharacters.length); | |
| for (let i = 0; i < byteCharacters.length; i++) { | |
| byteNumbers[i] = byteCharacters.charCodeAt(i); | |
| } | |
| const byteArray = new Uint8Array(byteNumbers); | |
| // Create blob | |
| return new Blob([byteArray], { type: mimeType }); | |
| } | |
| async transcribeAudio(audioBlob, blobType = 'audio/webm;codecs=opus') { | |
| console.log(`π€ Sending to Groq STT service: /api/stt/transcribe`); | |
| try { | |
| const startTime = Date.now(); | |
| // Determine appropriate filename based on MIME type | |
| let filename = 'audio.webm'; // default | |
| if (blobType.includes('wav')) { | |
| filename = 'audio.wav'; | |
| } else if (blobType.includes('mp4')) { | |
| filename = 'audio.mp4'; | |
| } else if (blobType.includes('ogg')) { | |
| filename = 'audio.ogg'; | |
| } else if (blobType.includes('mp3')) { | |
| filename = 'audio.mp3'; | |
| } else if (blobType.includes('opus') && !blobType.includes('ogg')) { | |
| filename = 'audio.opus'; // Only for pure opus, not ogg+opus | |
| } | |
| console.log(`π€ Using filename: ${filename} for MIME type: ${blobType}`); | |
| const formData = new FormData(); | |
| formData.append('file', audioBlob, filename); | |
| const response = await fetch('/api/stt/transcribe', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Groq STT request failed: ${response.status}`); | |
| } | |
| const responseData = await response.json(); | |
| console.log('π¨ Groq STT response:', responseData); | |
| const result = responseData.text; | |
| if (result && result.trim()) { | |
| const processingTime = (Date.now() - startTime) / 1000; | |
| console.log(`β Groq STT transcription successful (${processingTime.toFixed(2)}s): "${result.substring(0, 100)}"`); | |
| // Add transcription to message input | |
| this.addTranscriptionToInput(result); | |
| updateSTTVisualState('ready'); | |
| } else { | |
| console.warn('β οΈ Empty transcription result'); | |
| updateSTTVisualState('ready'); | |
| } | |
| } catch (error) { | |
| console.error('β Groq STT transcription failed:', error); | |
| updateSTTVisualState('error'); | |
| setTimeout(() => updateSTTVisualState('ready'), 3000); | |
| } | |
| } | |
| addTranscriptionToInput(transcription) { | |
| const currentValue = messageInput.value; | |
| let newText = transcription.trim(); | |
| // SMART EMAIL PARSING: Convert spoken email patterns to proper email format | |
| newText = this.parseSpokenEmail(newText); | |
| // Add transcription to message input | |
| if (currentValue && !currentValue.endsWith(' ')) { | |
| messageInput.value = currentValue + ' ' + newText; | |
| } else { | |
| messageInput.value = currentValue + newText; | |
| } | |
| // Move cursor to end | |
| messageInput.setSelectionRange(messageInput.value.length, messageInput.value.length); | |
| // Auto-resize textarea | |
| autoResizeTextarea(); | |
| // Track speech activity for auto-submission | |
| lastSpeechTime = Date.now(); | |
| hasReceivedSpeech = true; | |
| // UNIFIED TIMER: Always start 2.5 second timer after ANY transcription | |
| console.log('β±οΈ Starting 2.5 second timer after transcription...'); | |
| // Clear any existing timer first | |
| if (silenceTimer) { | |
| clearTimeout(silenceTimer); | |
| } | |
| // Email is provided from landing page, no need to detect it in speech | |
| // Start 2.5 second timer for ALL transcriptions (not just emails) | |
| silenceTimer = setTimeout(() => { | |
| if (hasReceivedSpeech && messageInput.value.trim()) { | |
| console.log('β±οΈ 2.5 seconds completed after transcription, auto-submitting...'); | |
| submitMessage(); | |
| } | |
| }, 2500); | |
| } | |
| // Email verification methods removed - email now provided from landing page | |
| parseSpokenEmail(text) { | |
| // Convert common spoken email patterns to proper email format | |
| let processed = text; | |
| // Pattern 1: "pgits at gmail dot com" -> "pgits@gmail.com" | |
| processed = processed.replace(/\b(\w+)\s+at\s+(\w+)\s+dot\s+(\w+)\b/gi, '$1@$2.$3'); | |
| // Pattern 2a: "pgitsatgmail.com" (catch this specific pattern first) | |
| processed = processed.replace(/(\w+)at(\w+)\.com/gi, '$1@$2.com'); | |
| // Pattern 2b: "pgitsatgmail.org" and other domains | |
| processed = processed.replace(/(\w+)at(\w+)\.(\w+)/gi, '$1@$2.$3'); | |
| // Pattern 3: "pgits at gmail.com" -> "pgits@gmail.com" | |
| processed = processed.replace(/\b(\w+)\s+at\s+(\w+\.\w+)\b/gi, '$1@$2'); | |
| // Pattern 4: "pgitsatgmaildotcom" -> "pgits@gmail.com" (everything run together) | |
| processed = processed.replace(/\b(\w+)at(\w+)dot(\w+)\b/gi, '$1@$2.$3'); | |
| // Pattern 5: "petergetsgitusat gmail.com" -> "petergetsgitus@gmail.com" (space before at) | |
| processed = processed.replace(/\b(\w+)\s*at\s+(\w+\.\w+)\b/gi, '$1@$2'); | |
| // Pattern 6: "petergetsgitusat gmail dot com" -> "petergetsgitus@gmail.com" | |
| processed = processed.replace(/\b(\w+)\s*at\s+(\w+)\s+dot\s+(\w+)\b/gi, '$1@$2.$3'); | |
| // Pattern 7: Handle multiple dots - "john at company dot co dot uk" -> "john@company.co.uk" | |
| processed = processed.replace(/\b(\w+)\s+at\s+([\w\s]+?)\s+dot\s+([\w\s]+)\b/gi, (match, username, domain, tld) => { | |
| // Replace spaces and 'dot' with actual dots in domain part | |
| const cleanDomain = domain.replace(/\s+dot\s+/g, '.').replace(/\s+/g, ''); | |
| const cleanTld = tld.replace(/\s+dot\s+/g, '.').replace(/\s+/g, ''); | |
| return `${username}@${cleanDomain}.${cleanTld}`; | |
| }); | |
| // Log the conversion if any changes were made | |
| if (processed !== text) { | |
| console.log(`π§ Email pattern converted: "${text}" -> "${processed}"`); | |
| } | |
| return processed; | |
| } | |
| } | |
| // Auto-submission function | |
| function submitMessage() { | |
| const message = messageInput.value.trim(); | |
| if (message && !isLoading) { | |
| // Clear the speech tracking | |
| hasReceivedSpeech = false; | |
| if (silenceTimer) { | |
| clearTimeout(silenceTimer); | |
| silenceTimer = null; | |
| } | |
| // Submit the message (using existing sendMessage logic) | |
| sendMessage(message); | |
| } | |
| } | |
| // Initialize session | |
| async function initializeSession() { | |
| try { | |
| const sessionCreatePayload = { | |
| user_data: { | |
| email: defaultEmail || null | |
| } | |
| }; | |
| console.log('π§ About to create session with payload:', JSON.stringify(sessionCreatePayload, null, 2)); | |
| const response = await fetch('{base_url}/sessions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(sessionCreatePayload) | |
| }); | |
| console.log('π§ Session creation response status:', response.status); | |
| console.log('π§ Creating session with email:', defaultEmail || 'null'); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| sessionId = data.session_id; | |
| } | |
| } catch (error) { | |
| console.error('Failed to initialize session:', error); | |
| } | |
| } | |
| async function addMessage(content, isUser = false) { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'message-avatar'; | |
| avatar.textContent = isUser ? 'π€' : 'π€'; | |
| const messageContent = document.createElement('div'); | |
| messageContent.className = 'message-content'; | |
| // For assistant messages, start TTS FIRST, then display with delay | |
| if (!isUser && content && content.trim()) { | |
| // Create temporary element to extract text content | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = content; | |
| let textContent = tempDiv.textContent || tempDiv.innerText; | |
| if (textContent && textContent.trim()) { | |
| // Remove "assistant:" prefix if present | |
| textContent = textContent.replace(/^assistant:\s*/i, '').trim(); | |
| // Start TTS synthesis immediately (non-blocking) | |
| chatCalTTS.synthesizeAndPlay(textContent); | |
| // Wait 500ms before displaying the text | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| } | |
| // Now render the HTML content | |
| messageContent.innerHTML = content; | |
| } else { | |
| // For user messages, just set text content immediately | |
| messageContent.textContent = content; | |
| } | |
| messageDiv.appendChild(avatar); | |
| messageDiv.appendChild(messageContent); | |
| // Simply append to chatMessages instead of insertBefore | |
| chatMessages.appendChild(messageDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| function showTyping() { | |
| if (typingIndicator) { | |
| typingIndicator.style.display = 'block'; | |
| } | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| function hideTyping() { | |
| if (typingIndicator) { | |
| typingIndicator.style.display = 'none'; | |
| } | |
| } | |
| async function sendMessage(message = null) { | |
| const text = message || messageInput.value.trim(); | |
| if (!text || isLoading) { | |
| return; | |
| } | |
| // Ensure we have a session before sending | |
| if (!sessionId) { | |
| await initializeSession(); | |
| if (!sessionId) { | |
| await addMessage('Sorry, I had trouble connecting. Please try again!'); | |
| return; | |
| } | |
| } | |
| // Add user message | |
| await addMessage(text, true); | |
| messageInput.value = ''; | |
| // Show loading state | |
| isLoading = true; | |
| sendButton.disabled = true; | |
| showTyping(); | |
| try { | |
| const response = await fetch('{base_url}/chat', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| message: text, | |
| session_id: sessionId | |
| }) | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| sessionId = data.session_id; // Update session ID | |
| await addMessage(data.response); | |
| // TTS integration - play the response (remove assistant prefix) | |
| if (chatCalTTS && chatCalTTS.webrtcEnabled && data.response) { | |
| let cleanResponse = data.response.replace(/^assistant:\s*/i, '').trim(); | |
| chatCalTTS.synthesizeAndPlay(cleanResponse); | |
| } | |
| } else { | |
| const error = await response.json(); | |
| await addMessage(`Sorry, I encountered an error: ${error.message || 'Unknown error'}`); | |
| } | |
| } catch (error) { | |
| console.error('Chat error:', error); | |
| await addMessage('Sorry, I had trouble connecting. Please try again!'); | |
| } finally { | |
| isLoading = false; | |
| sendButton.disabled = false; | |
| hideTyping(); | |
| // Clear any pending speech timers to allow fresh voice input | |
| hasReceivedSpeech = false; | |
| if (silenceTimer) { | |
| clearTimeout(silenceTimer); | |
| silenceTimer = null; | |
| } | |
| } | |
| } | |
| function sendQuickMessage(message) { | |
| messageInput.value = message; | |
| sendMessage(); | |
| } | |
| // Event listeners | |
| messageInput.addEventListener('keypress', function(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| // Auto-resize textarea as content grows | |
| function autoResizeTextarea() { | |
| messageInput.style.height = 'auto'; | |
| const newHeight = Math.min(messageInput.scrollHeight, 120); // Max height 120px | |
| messageInput.style.height = newHeight + 'px'; | |
| } | |
| // Enhanced input handling with typing delay for STT | |
| let typingTimer = null; | |
| let lastTypingTime = 0; | |
| let lastMouseMoveTime = 0; | |
| let mouseTimer = null; | |
| messageInput.addEventListener('input', function() { | |
| autoResizeTextarea(); | |
| // Track typing activity to delay STT auto-submission | |
| lastTypingTime = Date.now(); | |
| // Mouse movement tracking for editing detection | |
| lastMouseMoveTime = Date.now(); | |
| // If user is typing, clear any existing silence timer to prevent premature submission | |
| if (silenceTimer) { | |
| clearTimeout(silenceTimer); | |
| silenceTimer = null; | |
| } | |
| // Clear existing typing timer | |
| if (typingTimer) { | |
| clearTimeout(typingTimer); | |
| } | |
| // Set new typing timer - if user stops typing for 2.5 seconds, check for STT auto-submission | |
| typingTimer = setTimeout(() => { | |
| // Only check for STT auto-submission if user has stopped typing and we have speech input | |
| if (hasReceivedSpeech && messageInput.value.trim() && (Date.now() - lastTypingTime) >= 2500) { | |
| console.log('π User stopped typing, checking for STT auto-submission...'); | |
| // Additional delay to ensure user is done typing (2.5 seconds after last keystroke) | |
| silenceTimer = setTimeout(() => { | |
| if (hasReceivedSpeech && messageInput.value.trim()) { | |
| console.log('π STT auto-submitting after typing pause...'); | |
| submitMessage(); | |
| } | |
| }, 2500); | |
| } | |
| }, 2500); | |
| }); | |
| messageInput.addEventListener('paste', () => setTimeout(autoResizeTextarea, 0)); | |
| // Add click listener to send button | |
| if (sendButton) { | |
| sendButton.addEventListener('click', function(e) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| }); | |
| } | |
| // Mouse movement detection for editing detection | |
| document.addEventListener('mousemove', function() { | |
| lastMouseMoveTime = Date.now(); | |
| // Reset timer when user moves mouse (indicates they might be editing) | |
| resetAutoSubmitTimer(); | |
| }); | |
| // Keyboard activity detection for editing detection | |
| messageInput.addEventListener('keydown', function() { | |
| // Reset timer when user types (indicates they are editing) | |
| resetAutoSubmitTimer(); | |
| }); | |
| messageInput.addEventListener('input', function() { | |
| // Reset timer when input content changes | |
| resetAutoSubmitTimer(); | |
| }); | |
| // Function to reset the auto-submit timer | |
| function resetAutoSubmitTimer() { | |
| if (silenceTimer && hasReceivedSpeech) { | |
| console.log('β¨οΈ User activity detected, resetting 2.5 second timer...'); | |
| clearTimeout(silenceTimer); | |
| // Restart the 2.5 second timer | |
| silenceTimer = setTimeout(() => { | |
| if (hasReceivedSpeech && messageInput.value.trim()) { | |
| console.log('β±οΈ 2.5 seconds completed after user activity, auto-submitting...'); | |
| submitMessage(); | |
| } | |
| }, 2500); | |
| } | |
| } | |
| // Initialize when page loads | |
| // STT v2 indicator click to toggle recording | |
| // Transcribe/Submit button - stops recording and processes speech | |
| sttIndicator.addEventListener('click', () => { | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| console.log('π― User clicked to TRANSCRIBE current recording'); | |
| // Stop recording and process the current audio | |
| sttv2Manager.stopRecording().catch(error => { | |
| console.error('Failed to transcribe recording:', error); | |
| updateMicrophoneButtonState('error'); | |
| }); | |
| } else { | |
| console.log('βΉοΈ Microphone button is for visual indication only - use Mute/Unmute to control recording'); | |
| } | |
| }); | |
| // Mute/Unmute toggle functionality | |
| if (muteToggle) { | |
| muteToggle.addEventListener('click', () => { | |
| toggleMicrophone(); | |
| }); | |
| } | |
| function toggleMicrophone() { | |
| if (isMicrophoneMuted) { | |
| // Currently muted -> UNMUTE | |
| unmuteMicrophone(); | |
| } else { | |
| // Currently unmuted -> MUTE | |
| muteMicrophone(); | |
| } | |
| } | |
| function muteMicrophone() { | |
| console.log('π User clicked MUTE - stopping recording'); | |
| isMicrophoneMuted = true; | |
| // Stop current recording | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| sttv2Manager.stopRecording(); | |
| } | |
| // Hide audio visualizer | |
| const visualizer = document.getElementById('audioVisualizer'); | |
| if (visualizer) { | |
| visualizer.style.display = 'none'; | |
| } | |
| // Update mute button | |
| updateMuteButtonState('muted'); | |
| // Update microphone indicator | |
| updateMicrophoneButtonState('ready'); | |
| } | |
| function unmuteMicrophone() { | |
| console.log('ποΈ User clicked UNMUTE - starting recording'); | |
| isMicrophoneMuted = false; | |
| // Start recording if not currently playing TTS | |
| if (sttv2Manager && !isTTSPlaying && !recordingPausedForTTS) { | |
| sttv2Manager.startRecording().then(() => { | |
| setupAudioVisualization(); | |
| }); | |
| } | |
| // Update mute button | |
| updateMuteButtonState('unmuted'); | |
| // Update microphone indicator | |
| updateMicrophoneButtonState('recording'); | |
| } | |
| function updateMuteButtonState(state) { | |
| if (!muteToggle) return; | |
| muteToggle.classList.remove('muted', 'unmuted'); | |
| if (state === 'muted') { | |
| muteToggle.classList.add('muted'); | |
| muteToggle.textContent = 'Unmute'; | |
| muteToggle.title = 'Click to UNMUTE microphone'; | |
| } else { | |
| muteToggle.classList.add('unmuted'); | |
| muteToggle.textContent = 'Mute'; | |
| muteToggle.title = 'Click to MUTE microphone'; | |
| } | |
| } | |
| // Clear microphone button state management | |
| function updateMicrophoneButtonState(state) { | |
| const sttIndicator = document.getElementById('sttIndicator'); | |
| if (!sttIndicator) return; | |
| // Clear all state classes | |
| sttIndicator.classList.remove('ready-to-record', 'currently-recording', 'tts-playing', 'tts-playing-interruptible', 'connecting', 'error'); | |
| switch (state) { | |
| case 'ready': | |
| // Green - Ready state (visual indicator) | |
| sttIndicator.classList.add('ready-to-record'); | |
| sttIndicator.innerHTML = 'ποΈ'; | |
| sttIndicator.title = 'Microphone ready (use Mute/Unmute button to control)'; | |
| break; | |
| case 'recording': | |
| // Red pulsing - Currently recording (visual + transcribe) | |
| sttIndicator.classList.add('currently-recording'); | |
| sttIndicator.innerHTML = 'ποΈ'; | |
| sttIndicator.title = 'RECORDING - Click to transcribe current speech'; | |
| break; | |
| case 'tts-playing': | |
| // Orange - Paused for TTS | |
| sttIndicator.classList.add('tts-playing'); | |
| sttIndicator.innerHTML = 'π'; | |
| sttIndicator.title = 'Microphone paused - AI is speaking'; | |
| break; | |
| case 'tts-playing-interruptible': | |
| // Blue - Recording during TTS (interruptible) | |
| sttIndicator.classList.add('tts-playing-interruptible'); | |
| sttIndicator.innerHTML = 'π€'; | |
| sttIndicator.title = 'Listening during TTS - Speak to interrupt'; | |
| break; | |
| case 'connecting': | |
| // Blue - Connecting | |
| sttIndicator.classList.add('connecting'); | |
| sttIndicator.innerHTML = 'π'; | |
| sttIndicator.title = 'Connecting to microphone...'; | |
| break; | |
| case 'error': | |
| // Red - Error state | |
| sttIndicator.classList.add('error'); | |
| sttIndicator.innerHTML = 'β'; | |
| sttIndicator.title = 'Microphone error - Click to retry'; | |
| break; | |
| } | |
| } | |
| // Update user info display | |
| function updateUserInfoDisplay(name, email) { | |
| const userInfoDisplay = document.getElementById('userInfoDisplay'); | |
| const userEmailDisplay = document.getElementById('userEmailDisplay'); | |
| console.log('π§ Updating user email display:', { email }); | |
| if (email && email !== "Not provided" && email !== null && email !== undefined && email.trim() !== "") { | |
| userEmailDisplay.textContent = email; | |
| userInfoDisplay.classList.add('visible'); | |
| console.log('π§ Email display made visible with:', email); | |
| } else { | |
| console.log('π§ No valid email to display, hiding panel'); | |
| userInfoDisplay.classList.remove('visible'); | |
| } | |
| } | |
| // Extract user info from agent responses (if present in system messages) | |
| function extractUserInfoFromResponse(response) { | |
| // Look for user info pattern in responses | |
| const nameMatch = response.match(/Name:\s*([^β’|*]+)/i); | |
| const emailMatch = response.match(/Email:\s*([^β’|*]+)/i); | |
| if (nameMatch || emailMatch) { | |
| const name = nameMatch ? nameMatch[1].trim() : null; | |
| const email = emailMatch ? emailMatch[1].trim() : null; | |
| updateUserInfoDisplay(name, email); | |
| } | |
| } | |
| // Check session for existing user info and display it | |
| async function checkAndDisplaySessionUserInfo() { | |
| try { | |
| // First, check URL parameters for email | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const emailFromURL = urlParams.get('email'); | |
| if (emailFromURL) { | |
| console.log('π§ Found email in URL parameters:', emailFromURL); | |
| updateUserInfoDisplay(null, emailFromURL); | |
| return; | |
| } | |
| // Then check session data | |
| const response = await fetch('/api/session-info', { | |
| method: 'GET', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| credentials: 'include' | |
| }); | |
| if (response.ok) { | |
| const sessionData = await response.json(); | |
| console.log('π§ Session data received:', sessionData); | |
| // Try multiple possible locations for email in session data | |
| let email = null; | |
| if (sessionData.user_data) { | |
| // Check direct email field | |
| email = sessionData.user_data.email || | |
| sessionData.user_data.userEmail || | |
| (sessionData.user_data.user_info && sessionData.user_data.user_info.email); | |
| } | |
| if (email) { | |
| console.log('π§ Found email in session data:', email); | |
| updateUserInfoDisplay(null, email); | |
| } else { | |
| console.log('π§ No email found in session data'); | |
| } | |
| } | |
| } catch (error) { | |
| console.log('Session user info check failed:', error); | |
| } | |
| } | |
| // Initialize session and STT v2 | |
| async function initAndStartSTT() { | |
| await initializeSession(); | |
| // Force update email display for testing | |
| setTimeout(() => { | |
| const emailDisplay = document.getElementById('userEmailDisplay'); | |
| if (emailDisplay && emailDisplay.textContent === 'Loading...') { | |
| emailDisplay.textContent = 'Email provided from landing page'; | |
| console.log('π§ Email display updated - using landing page email'); | |
| } | |
| }, 1000); | |
| await checkAndDisplaySessionUserInfo(); | |
| // Initialize STT v2 Manager | |
| try { | |
| sttv2Manager = new STTv2Manager(); | |
| updateSTTVisualState('ready'); | |
| console.log('β STT v2 Manager initialized and ready'); | |
| } catch (error) { | |
| console.warn('STT v2 initialization failed:', error); | |
| updateSTTVisualState('error'); | |
| // STT failure is not critical, user can still type | |
| } | |
| } | |
| // Cleanup on page unload | |
| window.addEventListener('beforeunload', () => { | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| sttv2Manager.stopRecording(); | |
| } | |
| }); | |
| // Booking success celebration and redirect | |
| function checkForBookingSuccess() { | |
| const successMarker = document.getElementById('booking-success'); | |
| if (successMarker) { | |
| console.log('π Booking successful! Starting celebration...'); | |
| showFireworksAndRedirect(); | |
| return; | |
| } | |
| // Fallback: Check for booking success text patterns | |
| const messages = document.querySelectorAll('.message.assistant'); | |
| const lastMessage = messages[messages.length - 1]; | |
| if (lastMessage) { | |
| const messageText = lastMessage.textContent || lastMessage.innerHTML; | |
| // Look for booking confirmation patterns | |
| const bookingPatterns = [ | |
| /Meeting confirmed/i, | |
| /β .*Meeting/i, | |
| /Meeting ID:/i, | |
| /Google Calendar ID:/i, | |
| /Meeting booked/i, | |
| /All set!/i | |
| ]; | |
| for (const pattern of bookingPatterns) { | |
| if (pattern.test(messageText)) { | |
| console.log('π Booking detected via text pattern! Starting celebration...'); | |
| showFireworksAndRedirect(); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| function showFireworksAndRedirect() { | |
| // Create fireworks overlay | |
| const fireworksOverlay = document.createElement('div'); | |
| fireworksOverlay.innerHTML = ` | |
| <div style=" | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(45deg, #1e3c72, #2a5298); | |
| z-index: 10000; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| animation: fadeIn 0.5s ease-in; | |
| "> | |
| <div style=" | |
| text-align: center; | |
| color: white; | |
| font-size: 48px; | |
| margin-bottom: 20px; | |
| animation: bounce 1s infinite; | |
| "> | |
| π Meeting Booked! π | |
| </div> | |
| <div style=" | |
| text-align: center; | |
| color: white; | |
| font-size: 24px; | |
| margin-bottom: 40px; | |
| "> | |
| Your appointment with Peter is confirmed! | |
| </div> | |
| <div style="font-size: 80px; animation: fireworks 2s ease-in-out infinite;"> | |
| β¨ π β¨ π β¨ π β¨ | |
| </div> | |
| <div style=" | |
| text-align: center; | |
| color: #ccc; | |
| font-size: 16px; | |
| margin-top: 40px; | |
| "> | |
| Redirecting to home page in 3 seconds... | |
| </div> | |
| </div> | |
| `; | |
| // Add CSS animations | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| @keyframes bounce { | |
| 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } | |
| 40% { transform: translateY(-20px); } | |
| 60% { transform: translateY(-10px); } | |
| } | |
| @keyframes fireworks { | |
| 0% { transform: scale(1) rotate(0deg); } | |
| 50% { transform: scale(1.1) rotate(180deg); } | |
| 100% { transform: scale(1) rotate(360deg); } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| document.body.appendChild(fireworksOverlay); | |
| // Redirect after 3 seconds | |
| setTimeout(() => { | |
| window.location.href = '/'; | |
| }, 3000); | |
| } | |
| // Monitor for booking success and extract user info after each message | |
| const originalAddMessage = addMessage; | |
| addMessage = function(message, isUser = false) { | |
| originalAddMessage(message, isUser); | |
| if (!isUser) { | |
| // Check for booking success after adding assistant message | |
| setTimeout(checkForBookingSuccess, 100); | |
| // Extract user info if present in response | |
| extractUserInfoFromResponse(message); | |
| } | |
| }; | |
| // Auto-start recording and show popup notification | |
| async function autoStartRecording() { | |
| try { | |
| // Show recording popup for 2 seconds | |
| showRecordingPopup(); | |
| // Auto-start recording after a short delay | |
| setTimeout(async () => { | |
| if (sttv2Manager && !sttv2Manager.isRecording) { | |
| await sttv2Manager.startRecording(); | |
| setupAudioVisualization(); | |
| } | |
| }, 500); | |
| } catch (error) { | |
| console.error('Auto-start recording failed:', error); | |
| } | |
| } | |
| // Show recording notification popup | |
| function showRecordingPopup() { | |
| const popup = document.getElementById('recordingPopup'); | |
| popup.style.display = 'block'; | |
| // Hide popup after 2 seconds | |
| setTimeout(() => { | |
| popup.style.display = 'none'; | |
| }, 2000); | |
| } | |
| // Audio visualization setup | |
| let audioContext = null; | |
| let analyser = null; | |
| let dataArray = null; | |
| let animationId = null; | |
| async function setupAudioVisualization() { | |
| try { | |
| const visualizer = document.getElementById('audioVisualizer'); | |
| const canvas = document.getElementById('audioCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // Show visualizer | |
| visualizer.style.display = 'block'; | |
| // Set up audio context for visualization | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| // Get microphone stream | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| // Create analyser | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 256; | |
| source.connect(analyser); | |
| const bufferLength = analyser.frequencyBinCount; | |
| dataArray = new Uint8Array(bufferLength); | |
| // Start visualization | |
| drawWaveform(canvas, ctx); | |
| } catch (error) { | |
| console.error('Audio visualization setup failed:', error); | |
| } | |
| } | |
| // Draw waveform visualization | |
| function drawWaveform(canvas, ctx) { | |
| if (!analyser || !dataArray) return; | |
| animationId = requestAnimationFrame(() => drawWaveform(canvas, ctx)); | |
| analyser.getByteFrequencyData(dataArray); | |
| // Clear canvas | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Draw waveform bars | |
| const barWidth = (canvas.width / dataArray.length) * 2.5; | |
| let barHeight; | |
| let x = 0; | |
| for (let i = 0; i < dataArray.length; i++) { | |
| barHeight = (dataArray[i] / 255) * canvas.height * 0.8; | |
| // Create gradient for bars | |
| const gradient = ctx.createLinearGradient(0, canvas.height, 0, canvas.height - barHeight); | |
| gradient.addColorStop(0, '#4CAF50'); | |
| gradient.addColorStop(1, '#81C784'); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); | |
| x += barWidth + 1; | |
| } | |
| } | |
| // Enhanced STT initialization with auto-start continuous recording | |
| async function initAndStartSTTWithAutoStart() { | |
| // Initialize STT as before | |
| await initAndStartSTT(); | |
| // Enable continuous listening | |
| enableContinuousListening(); | |
| // Auto-start recording after initialization | |
| setTimeout(() => { | |
| autoStartRecording(); | |
| }, 1000); | |
| } | |
| // Continuous listening - restart recording after processing | |
| function enableContinuousListening() { | |
| if (sttv2Manager) { | |
| // Override the original stopRecording to restart automatically | |
| const originalStopRecording = sttv2Manager.stopRecording.bind(sttv2Manager); | |
| sttv2Manager.stopRecording = async function() { | |
| await originalStopRecording(); | |
| // Auto-restart recording for continuous listening (core functionality) | |
| // But not if TTS is playing OR microphone is muted by user | |
| setTimeout(async () => { | |
| if (!this.isRecording && !isTTSPlaying && !recordingPausedForTTS && !isMicrophoneMuted) { | |
| console.log('π Auto-restarting recording for continuous listening'); | |
| await this.startRecording(); | |
| setupAudioVisualization(); | |
| } | |
| }, 1000); | |
| }; | |
| } | |
| } | |
| // TTS-aware microphone control with interrupt capability | |
| let isTTSPlaying = false; | |
| let recordingPausedForTTS = false; | |
| let ttsInterruptible = false; | |
| let currentTTSAudio = null; | |
| // Enhanced speech activity detection during TTS | |
| let speechDetectionThreshold = 0.15; // Higher threshold - requires actual speaking (15% of max volume) | |
| let backgroundNoiseLevel = 0.05; // Baseline noise level to ignore | |
| let speechDetectionCount = 0; | |
| let speechDetectionRequired = 5; // More consecutive detections needed (reduces false positives) | |
| let recentAudioLevels = []; // Track recent audio levels for noise filtering | |
| const audioLevelHistorySize = 10; // Keep last 10 measurements | |
| // Mute state management | |
| let isMicrophoneMuted = false; | |
| function setupTTSWithInterrupt(audioElement) { | |
| console.log('π€ Setting up TTS with speech interruption capability'); | |
| isTTSPlaying = true; | |
| ttsInterruptible = true; | |
| currentTTSAudio = audioElement; | |
| speechDetectionCount = 0; | |
| recentAudioLevels = []; // Clear audio history for fresh detection | |
| backgroundNoiseLevel = 0.05; // Reset to default noise level | |
| // Keep microphone recording during TTS for interrupt detection | |
| if (sttv2Manager && sttv2Manager.isRecording) { | |
| console.log('π€ Continuing recording during TTS for interrupt detection'); | |
| // Set up speech activity monitoring | |
| setupSpeechInterruptDetection(); | |
| } else if (sttv2Manager && !isMicrophoneMuted) { | |
| // Start recording if not already active | |
| sttv2Manager.startRecording().then(() => { | |
| setupSpeechInterruptDetection(); | |
| setupAudioVisualization(); | |
| }); | |
| } | |
| updateMicrophoneButtonState('tts-playing-interruptible'); | |
| } | |
| function setupSpeechInterruptDetection() { | |
| console.log('π£οΈ Setting up speech interrupt detection during TTS'); | |
| if (!audioContext || !analyser) { | |
| console.log('β οΈ Audio context not available for interrupt detection'); | |
| return; | |
| } | |
| const dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| function detectSpeechActivity() { | |
| if (!ttsInterruptible || !isTTSPlaying) { | |
| return; // Stop monitoring when TTS ends or is interrupted | |
| } | |
| analyser.getByteFrequencyData(dataArray); | |
| // Calculate average volume | |
| let sum = 0; | |
| for (let i = 0; i < dataArray.length; i++) { | |
| sum += dataArray[i]; | |
| } | |
| const average = sum / dataArray.length / 255; // Normalize to 0-1 | |
| // Track recent audio levels for adaptive noise filtering | |
| recentAudioLevels.push(average); | |
| if (recentAudioLevels.length > audioLevelHistorySize) { | |
| recentAudioLevels.shift(); | |
| } | |
| // Calculate dynamic background noise level | |
| if (recentAudioLevels.length >= 5) { | |
| const sortedLevels = [...recentAudioLevels].sort((a, b) => a - b); | |
| const medianLevel = sortedLevels[Math.floor(sortedLevels.length / 2)]; | |
| backgroundNoiseLevel = Math.max(0.02, medianLevel * 1.5); // Dynamic noise floor | |
| } | |
| // Enhanced speech detection with multiple criteria | |
| const isAboveBaselineThreshold = average > speechDetectionThreshold; | |
| const isAboveNoiseFloor = average > (backgroundNoiseLevel + 0.03); // 3% above noise floor | |
| const isPeakDetection = average > (Math.max(...recentAudioLevels.slice(-3)) * 0.8); // Recent peak detection | |
| if (isAboveBaselineThreshold && isAboveNoiseFloor && isPeakDetection) { | |
| speechDetectionCount++; | |
| console.log(`π£οΈ Strong speech detected during TTS (${speechDetectionCount}/${speechDetectionRequired}) - level: ${average.toFixed(3)}, noise: ${backgroundNoiseLevel.toFixed(3)}`); | |
| if (speechDetectionCount >= speechDetectionRequired) { | |
| console.log('π Confirmed user speech detected - interrupting TTS!'); | |
| interruptTTS(); | |
| return; | |
| } | |
| } else { | |
| // More aggressive decay to prevent false positives from sustained background noise | |
| speechDetectionCount = Math.max(0, speechDetectionCount - 2); | |
| // Debug log for borderline cases | |
| if (average > 0.05) { | |
| console.log(`π Audio detected but not speech - level: ${average.toFixed(3)}, noise: ${backgroundNoiseLevel.toFixed(3)}, above_threshold: ${isAboveBaselineThreshold}, above_noise: ${isAboveNoiseFloor}, is_peak: ${isPeakDetection}`); | |
| } | |
| } | |
| // Continue monitoring | |
| requestAnimationFrame(detectSpeechActivity); | |
| } | |
| // Start monitoring | |
| requestAnimationFrame(detectSpeechActivity); | |
| } | |
| function interruptTTS() { | |
| console.log('π Interrupting TTS due to user speech'); | |
| ttsInterruptible = false; | |
| speechDetectionCount = 0; | |
| // Stop current TTS audio | |
| if (currentTTSAudio) { | |
| currentTTSAudio.pause(); | |
| currentTTSAudio.currentTime = 0; | |
| console.log('π TTS audio stopped'); | |
| } | |
| // Clear TTS queue if using WebRTC TTS | |
| if (typeof chatCalTTS !== 'undefined' && chatCalTTS) { | |
| chatCalTTS.stop(); | |
| console.log('π WebRTC TTS stopped'); | |
| } | |
| resumeMicrophoneAfterTTS(); | |
| } | |
| function resumeMicrophoneAfterTTS() { | |
| console.log('ποΈ Resuming normal microphone operation after TTS'); | |
| isTTSPlaying = false; | |
| ttsInterruptible = false; | |
| currentTTSAudio = null; | |
| speechDetectionCount = 0; | |
| recordingPausedForTTS = false; | |
| // Update microphone button to show ready state | |
| updateMicrophoneButtonState('ready'); | |
| // Ensure recording continues (should already be active if interrupt feature worked) | |
| if (sttv2Manager && !sttv2Manager.isRecording && !isMicrophoneMuted) { | |
| setTimeout(async () => { | |
| await sttv2Manager.startRecording(); | |
| setupAudioVisualization(); | |
| }, 100); | |
| } | |
| // Show audio visualizer again | |
| const visualizer = document.getElementById('audioVisualizer'); | |
| if (visualizer && !isMicrophoneMuted) { | |
| visualizer.style.display = 'block'; | |
| } | |
| } | |
| // Replace the original load event listener | |
| window.removeEventListener('load', initAndStartSTT); | |
| window.addEventListener('load', initAndStartSTTWithAutoStart); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| return html_content.replace('{base_url}', base_url).replace('{default_email}', default_email) | |
| async def embeddable_widget(): | |
| """Minimal embeddable widget for other websites.""" | |
| return """ | |
| <div id="chatcal-widget" style=" | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| width: 400px; | |
| height: 500px; | |
| background: white; | |
| border-radius: 10px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.2); | |
| z-index: 9999; | |
| display: none; | |
| "> | |
| <iframe | |
| src="/chat-widget" | |
| width="100%" | |
| height="100%" | |
| frameborder="0" | |
| style="border-radius: 10px;"> | |
| </iframe> | |
| </div> | |
| <button id="chatcal-toggle" style=" | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| width: 60px; | |
| height: 60px; | |
| background: #4CAF50; | |
| color: white; | |
| border: none; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| font-size: 24px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.2); | |
| z-index: 10000; | |
| ">π¬</button> | |
| <script> | |
| document.getElementById('chatcal-toggle').onclick = function() { | |
| const widget = document.getElementById('chatcal-widget'); | |
| const toggle = document.getElementById('chatcal-toggle'); | |
| if (widget.style.display === 'none') { | |
| widget.style.display = 'block'; | |
| toggle.textContent = 'β'; | |
| } else { | |
| widget.style.display = 'none'; | |
| toggle.textContent = 'π¬'; | |
| } | |
| }; | |
| </script> | |
| """ |