voiceCal-ai-v3 / app /api /chat_widget.py
pgits's picture
UX: Show Meet link, scrub TTS noise, fix value strikethrough (v1.7.21)
d03b09a
Raw
History Blame Contribute Delete
132 kB
"""Chat widget HTML interface for ChatCal.ai."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.get("/chat-widget", response_class=HTMLResponse)
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;
}
/* Live booking-field values shown inline next to each checklist label */
.bookingValue {
color: #0d47a1; /* distinct, readable navy */
font-size: 16px; /* ~2 sizes larger than label (13px) */
font-weight: 700;
margin-left: 8px;
font-family: inherit;
letter-spacing: 0.2px;
/* Detach from parent <p>'s text-decoration so when the label
gets struck through after capture, the value stays clean. */
display: inline-block;
text-decoration: none !important;
}
.bookingValue:not(:empty)::before {
content: "→ ";
color: #1976d2;
font-weight: 600;
}
/* Booking confirmation card — shown visibly so user can see the
Google Meet link, meeting time, etc. (voice-only mode hides
normal chat messages). */
.booking-confirmation {
margin: 16px;
padding: 16px 20px;
background: #fff;
border: 2px solid #1976d2;
border-radius: 10px;
font-size: 15px;
line-height: 1.5;
color: #0d47a1;
box-shadow: 0 2px 8px rgba(13,71,161,0.12);
}
.booking-confirmation a {
color: #1976d2;
font-weight: 700;
word-break: break-all;
}
.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;
}
/* Voice-Only Mode - Hide keyboard elements */
#messageInput {
display: none !important;
}
/* Hide sttIndicator initially, but allow it to be shown after greeting */
#sttIndicator {
display: none;
}
#sendButton {
display: none !important;
}
/* Audio visualizer visible in voice-only mode to show mic activity */
/* Adjust chat-input container for voice-only mode */
.chat-input {
justify-content: center;
padding: 15px 20px;
}
.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;
}
@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: 300px; /* Moved down to avoid covering menu items */
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: fixed;
bottom: 140px; /* Position above text input */
right: 20px; /* Position in bottom-right corner */
width: fit-content;
background: transparent;
padding: 20px;
border-radius: 50%;
z-index: 100; /* Lower z-index so it doesn't cover menu items */
}
.audio-visualizer canvas {
display: block;
border-radius: 50%;
background: transparent;
}
.visualizer-info {
text-align: center;
color: rgba(0, 255, 220, 0.9);
font-size: 11px;
margin-top: 10px;
font-weight: 600;
letter-spacing: 0.5px;
}
/* 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: 50%; /* Circular like a microphone */
width: 60px;
height: 60px;
font-size: 10px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.mute-toggle-btn:hover {
transform: translateY(-50%) scale(1.1);
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
}
.mute-toggle-btn svg {
width: 24px;
height: 24px;
fill: white;
}
.mute-toggle-btn .btn-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Unmuted state - Green (ready to mute) */
.mute-toggle-btn.unmuted {
background: linear-gradient(135deg, #4CAF50, #45a049);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
}
/* Muted state - Red (ready to unmute) */
.mute-toggle-btn.muted {
background: linear-gradient(135deg, #f44336, #d32f2f);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
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 180px 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, tell me:</p>
<p id="menuItem1" style="margin: 5px 0 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">1) Your name <span class="bookingValue" id="bookingValueName"></span></p>
<p id="menuItem2" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">2) The date <span class="bookingValue" id="bookingValueDate"></span></p>
<p id="menuItem3" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">3) The time <span class="bookingValue" id="bookingValueTime"></span></p>
<p id="menuItem4" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">4) The length (duration) <span class="bookingValue" id="bookingValueDuration"></span></p>
<p id="menuItem5" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">5) The agenda (meeting topic) <span class="bookingValue" id="bookingValueTopic"></span></p>
<p id="menuItem6" style="margin: 2px 0; font-size: 13px; color: #4caf50; font-weight: bold;">6) GoogleMeet or phone call <span class="bookingValue" id="bookingValueFormat"></span></p>
<p id="menuItem7" style="margin: 2px 0 0 0; font-size: 13px; color: #4caf50; font-weight: bold;">7) Your phone number <span class="bookingValue" id="bookingValuePhone"></span></p>
</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">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span class="btn-label">MUTE</span>
</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="200" height="200"></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;
let audioUnlocked = false; // iOS requires audio unlock on first user gesture
// Unlock audio playback on first user interaction (critical for iOS Safari).
// Must happen in the same synchronous call stack as the gesture.
function unlockAudio() {
if (audioUnlocked) return;
audioUnlocked = true;
console.log('🔓 Unlocking audio on user gesture...');
// Create shared AudioContext during gesture
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Resume must start in the gesture call stack
audioContext.resume().then(() => {
console.log('🔓 AudioContext resumed:', audioContext.state);
});
// Play a tiny silent buffer — this permanently unlocks the context on iOS
try {
const silentBuffer = audioContext.createBuffer(1, 1, 22050);
const src = audioContext.createBufferSource();
src.buffer = silentBuffer;
src.connect(audioContext.destination);
src.start(0);
console.log('🔓 Audio unlocked successfully');
} catch (e) {
console.warn('🔓 Audio unlock failed:', e);
}
document.removeEventListener('touchend', unlockAudio, true);
document.removeEventListener('click', unlockAudio, true);
}
document.addEventListener('touchend', unlockAudio, true);
document.addEventListener('click', unlockAudio, true);
// Greeting state - prevents visualizer from showing during initial greeting
let greetingComplete = 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 TTS for ChatCal...');
// TTS does not require microphone permission.
// Enable immediately; AudioContext will be created/resumed on first
// user gesture (mic click or send button) to satisfy browser autoplay policy.
this.webrtcEnabled = true;
this.isPlaying = false;
this.audioQueue = [];
console.log('✅ TTS enabled for ChatCal');
} catch (error) {
console.warn('⚠️ TTS initialization failed, continuing without TTS:', error);
this.webrtcEnabled = false;
}
}
async ensureAudioContext() {
// Use the shared global audioContext — the same one that
// setupTTSVisualization routes the audio element through via
// createMediaElementSource. If that context is suspended,
// audio is routed into it but never output → silence.
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
// Keep this.audioContext pointing to the same shared instance
this.audioContext = audioContext;
}
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;
}
// Filter text for TTS (remove URLs, shorten meeting IDs)
const ttsText = this.filterTextForTTS(text);
console.log('🔇 Original text length:', text.length, '| Filtered for TTS:', ttsText.length);
// Prevent duplicate TTS requests for the same text
if (this.audioQueue.includes(ttsText)) {
console.log('🔄 Skipping duplicate TTS request for:', ttsText.substring(0, 30) + '...');
return;
}
// Add to queue and process (may preempt current audio)
this.audioQueue.push(ttsText);
console.log(`📝 Added to TTS queue (${this.audioQueue.length} items). Playing: ${this.isPlaying}`);
this.processAudioQueue();
}
filterTextForTTS(text) {
let filtered = text;
// Remove URLs (http/https links)
const urlRegex = /https?:\/\/[^\s<>"]+/gi;
const urls = filtered.match(urlRegex);
if (urls) {
console.log('🔇 Filtering', urls.length, 'URL(s) from TTS:', urls);
filtered = filtered.replace(urlRegex, 'link in your email');
}
// Shorten meeting IDs (format: MMDD-HHMM-DURm) to last 5 characters
const meetingIdRegex = /\b\d{4}-\d{4}-\d+m\b/g;
const meetingIds = filtered.match(meetingIdRegex);
if (meetingIds) {
console.log('🔇 Shortening', meetingIds.length, 'meeting ID(s) for TTS');
filtered = filtered.replace(meetingIdRegex, (match) => {
const last5 = match.slice(-5);
console.log(`🔇 Meeting ID ${match} → ending in ${last5}`);
return `ending in ${last5}`;
});
}
// Remove HTML tags for cleaner TTS
filtered = filtered.replace(/<[^>]*>/g, '');
// Clean up multiple spaces and normalize
filtered = filtered.replace(/\s+/g, ' ').trim();
return filtered;
}
async processAudioQueue() {
if (this.isPlaying || this.audioQueue.length === 0) {
return;
}
const text = this.audioQueue.shift();
this.isPlaying = true;
try {
console.log('🎵 Synthesizing TTS for:', text.substring(0, 50) + '...');
// Step 1: Call TTS proxy
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: 'hannah' })
});
if (!response.ok) {
throw new Error(`TTS proxy error: ${response.status}`);
}
const result = await response.json();
console.log(`🎵 TTS synthesis done in ${(performance.now() - startTime).toFixed(0)}ms`);
if (!result.success || !result.audio_url) {
throw new Error('TTS generation failed');
}
// Step 2: Fetch the WAV as raw bytes (bypass HTMLAudioElement entirely)
console.log('🔊 Fetching audio:', result.audio_url);
const audioResponse = await fetch(result.audio_url);
const arrayBuffer = await audioResponse.arrayBuffer();
console.log('🔊 Audio fetched:', arrayBuffer.byteLength, 'bytes');
// Step 3: Ensure AudioContext is running (unlocked on first tap)
await this.ensureAudioContext();
console.log('🔊 AudioContext state:', audioContext.state);
// Step 4: Decode and play via Web Audio API AudioBufferSourceNode.
// This bypasses HTMLAudioElement autoplay restrictions on iOS Safari.
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
// Notify TTS-aware mic control
if (typeof setupTTSWithInterrupt === 'function') {
setupTTSWithInterrupt(null);
}
// Timeout guard
const timeoutId = setTimeout(() => {
console.warn('⏰ TTS playback timeout');
this.isPlaying = false;
resumeMicrophoneAfterTTS();
this.processAudioQueue();
}, 30000);
source.onended = () => {
console.log('🎵 TTS audio finished');
clearTimeout(timeoutId);
this.isPlaying = false;
resumeMicrophoneAfterTTS();
this.processAudioQueue();
};
source.start(0);
console.log('🎵 TTS playing via Web Audio API');
} catch (error) {
console.warn('🔇 TTS failed:', error.message || error);
this.isPlaying = false;
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);
// Setup TTS audio visualization (outer circle) - only runs once
setupTTSVisualization(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
// Background noise filtering - tuned for faster response
this.audioContext = null;
this.audioAnalyser = null;
this.noiseThreshold = -45; // dB threshold - ignore audio below this level (less strict)
this.minVolumeForSpeech = 0.015; // Minimum volume level (0-1 scale, lowered for sensitivity)
console.log('🎤 STT v2 Manager initialized with noise filtering');
}
generateSessionHash() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
// Get current audio level (0-1 scale) for noise detection
getAudioLevel() {
if (!this.audioAnalyser) return 0;
const bufferLength = this.audioAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
this.audioAnalyser.getByteTimeDomainData(dataArray);
// Calculate RMS (Root Mean Square) for audio level
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
const normalized = (dataArray[i] - 128) / 128; // Normalize to -1 to 1
sum += normalized * normalized;
}
const rms = Math.sqrt(sum / bufferLength);
return rms;
}
// Check if current audio contains actual speech (vs background noise)
hasSignificantAudio() {
const level = this.getAudioLevel();
const isSpeech = level > this.minVolumeForSpeech;
if (!isSpeech) {
console.log('🔇 Audio level too low (' + level.toFixed(4) + '), ignoring background noise');
}
return isSpeech;
}
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, // Browser-level noise suppression
autoGainControl: true
}
});
// Re-enabled: Audio analyzer for better VAD (Voice Activity Detection)
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const source = this.audioContext.createMediaStreamSource(stream);
this.audioAnalyser = this.audioContext.createAnalyser();
this.audioAnalyser.fftSize = 2048;
this.audioAnalyser.smoothingTimeConstant = 0.3; // Fast response to audio changes
source.connect(this.audioAnalyser);
console.log('🔊 Audio level monitoring enabled (threshold: ' + this.minVolumeForSpeech + ')');
// 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;
}
// TEMPORARILY DISABLED: Noise filtering was blocking valid speech
// TODO: Re-implement with proper threshold calibration
// if (!this.hasSignificantAudio()) {
// console.log('🔇 Skipping transcription - audio below noise threshold');
// updateSTTVisualState('ready');
// 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 1.0 second timer after ANY transcription
console.log('⏱️ Starting 1.0 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 1.0 second timer for ALL transcriptions (reduced from 2.0s for faster response)
silenceTimer = setTimeout(() => {
if (hasReceivedSpeech && messageInput.value.trim()) {
console.log('⏱️ 1.0 second completed after transcription, auto-submitting...');
submitMessage();
}
}, 1000);
}
// 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) {
// Check if booking was just completed and user is saying goodbye/thanks
// If so, immediately redirect instead of processing the message
if (bookingDetected) {
console.log('🛑 Booking complete — blocking input, triggering celebration');
messageInput.value = '';
if (!waitingForTTSCompletion) {
showFireworksAndRedirect();
}
return; // Don't send any message after booking
}
// 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) {
// VOICE-ONLY MODE: Normal assistant messages are hidden — TTS only.
// EXCEPTION: booking confirmations are rendered visibly so the user
// can see the Google Meet link, meeting time, IDs, etc.
if (!isUser && content && content.trim()) {
// Create hidden element for booking success detection FIRST
// This must be added to DOM before checkForBookingSuccess() runs
const hiddenDiv = document.createElement('div');
hiddenDiv.innerHTML = content;
hiddenDiv.style.display = 'none';
chatMessages.appendChild(hiddenDiv);
// Render visibly if this looks like a booking confirmation
// (has Meet link, meeting ID, or success marker).
const isBookingConfirmation =
/booking-success/i.test(content) ||
/meet\.google\.com/i.test(content) ||
/Meeting confirmed/i.test(content) ||
/Meeting ID:/i.test(content) ||
/Google Calendar ID:/i.test(content);
if (isBookingConfirmation) {
const card = document.createElement('div');
card.className = 'booking-confirmation';
// Strip the hidden marker so its raw text never shows
let visibleHtml = content.replace(
/<div[^>]*id=["']booking-success["'][^>]*>[\s\S]*?<\/div>/gi,
''
);
card.innerHTML = visibleHtml;
chatMessages.appendChild(card);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Create temporary element to extract text content for TTS
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
let textContent = tempDiv.textContent || tempDiv.innerText;
if (textContent && textContent.trim()) {
// Strip "assistant:" prefix
textContent = textContent.replace(/^assistant:\s*/i, '').trim();
// Scrub the hidden booking marker so TTS doesn't say
// "BOOKING COMPLETE" out loud after every booking.
textContent = textContent.replace(/BOOKING_COMPLETE/gi, '').trim();
// Scrub long meeting IDs/URLs from TTS (they're shown on screen).
textContent = textContent.replace(/https?:\/\/meet\.google\.com\/\S+/gi, '');
textContent = textContent.replace(/Meeting ID:\s*\S+/gi, '');
textContent = textContent.replace(/Google Calendar ID:\s*\S+/gi, '');
textContent = textContent.replace(/\s+/g, ' ').trim();
if (textContent) {
chatCalTTS.synthesizeAndPlay(textContent);
await new Promise(resolve => setTimeout(resolve, 500));
}
}
}
// User messages: completely skip (no display, no processing)
// In voice-only mode, user doesn't need to see their transcribed text
}
function showTyping() {
if (typingIndicator) {
typingIndicator.style.display = 'block';
}
// Removed auto-scroll - let user control scroll position
// chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Live booking-field panel updater.
// Backend sends ChatResponse.user_info each turn. We paste each non-null
// value next to its checklist label so the user sees what was captured
// without TTS having to read it back.
function updateBookingValues(info) {
if (!info) return;
const fmtPhone = (p) => {
if (!p) return '';
const d = String(p).replace(/\D/g, '');
if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`;
if (d.length === 11 && d[0] === '1') return `+1 (${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`;
return p;
};
const fmtDur = (d) => d ? `${d} min` : '';
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val) el.textContent = val;
};
set('bookingValueName', info.name);
set('bookingValueDate', info.date);
set('bookingValueTime', info.time);
set('bookingValueDuration', fmtDur(info.duration_minutes));
set('bookingValueTopic', info.topic);
set('bookingValuePhone', fmtPhone(info.phone));
// bookingValueFormat is filled by existing phone/google-meet detection
// elsewhere in this widget; leave it alone here.
}
function hideTyping() {
if (typingIndicator) {
typingIndicator.style.display = 'none';
}
}
async function sendMessage(message = null) {
// Block ALL messages after booking — one meeting per session
if (bookingDetected) {
console.log('🛑 Booking already completed — ignoring new message');
return;
}
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 is handled inside addMessage() - no need to call here
// Live booking-field panel: paste captured values inline
if (data.user_info) { updateBookingValues(data.user_info); }
// FORCE update menu colors after response
console.log('🔄 Forcing menu color update after chat response...');
setTimeout(updateMenuItemColors, 1000);
} 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;
}
}
}
// 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.0 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) >= 1000) {
console.log('🔇 User stopped typing, checking for STT auto-submission...');
// Additional delay to ensure user is done typing (1.0 second after last keystroke)
silenceTimer = setTimeout(() => {
if (hasReceivedSpeech && messageInput.value.trim()) {
console.log('🔇 STT auto-submitting after typing pause...');
submitMessage();
}
}, 1000);
}
}, 2000);
});
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 1.0 second timer...');
clearTimeout(silenceTimer);
// Restart the 1.0 second timer
silenceTimer = setTimeout(() => {
if (hasReceivedSpeech && messageInput.value.trim()) {
console.log('⏱️ 1.0 second completed after user activity, auto-submitting...');
submitMessage();
}
}, 1000);
}
}
// 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();
}
// Keep visualizer visible (TTS outer circle can still show when assistant speaks)
// Don't hide it - just stop recording the microphone
console.log('🎨 Keeping visualizer visible for TTS outer circle');
// 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;
const btnLabel = muteToggle.querySelector('.btn-label');
if (!btnLabel) return;
muteToggle.classList.remove('muted', 'unmuted');
if (state === 'muted') {
muteToggle.classList.add('muted');
btnLabel.textContent = 'UNMUTE';
muteToggle.title = 'Click to UNMUTE microphone';
} else {
muteToggle.classList.add('unmuted');
btnLabel.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
let bookingDetected = false;
let waitingForTTSCompletion = false;
function checkForBookingSuccess() {
const successMarker = document.getElementById('booking-success');
let isBooking = false;
if (successMarker) {
console.log('🎉 Booking successful detected via marker!');
isBooking = true;
}
// Fallback: Check for booking success text patterns
if (!isBooking) {
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,
/MEETING BOOKED SUCCESSFULLY/i,
/All set!/i
];
for (const pattern of bookingPatterns) {
if (pattern.test(messageText)) {
console.log('🎉 Booking detected via text pattern!');
isBooking = true;
break;
}
}
}
}
// If booking detected, check TTS status
if (isBooking && !bookingDetected) {
bookingDetected = true;
// Stop STT immediately to prevent new input after booking
if (sttv2Manager && sttv2Manager.isRecording) {
console.log('🛑 Stopping STT immediately after booking detection');
sttv2Manager.stopRecording();
}
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
messageInput.value = '';
// Check if TTS is currently playing
if (chatCalTTS && chatCalTTS.isPlaying) {
console.log('🎵 TTS is playing confirmation... waiting for completion before celebration');
waitingForTTSCompletion = true;
// Set up listener for TTS completion
const checkTTSComplete = setInterval(() => {
if (!chatCalTTS.isPlaying && chatCalTTS.audioQueue.length === 0) {
console.log('✅ TTS completed! Now showing celebration...');
clearInterval(checkTTSComplete);
waitingForTTSCompletion = false;
showFireworksAndRedirect();
} else {
console.log('⏳ Still waiting for TTS... Playing:', chatCalTTS.isPlaying, 'Queue:', chatCalTTS.audioQueue.length);
}
}, 500); // Check every 500ms
// Safety timeout: show celebration after 15 seconds max
setTimeout(() => {
if (waitingForTTSCompletion) {
console.log('⚠️ TTS timeout - showing celebration anyway');
clearInterval(checkTTSComplete);
waitingForTTSCompletion = false;
showFireworksAndRedirect();
}
}, 15000);
} else {
// TTS not playing, show celebration immediately
console.log('🎉 No TTS playing, showing celebration now!');
showFireworksAndRedirect();
}
}
}
async function showFireworksAndRedirect() {
console.log('🎆 Starting celebration and session reset...');
// IMPORTANT: Stop STT recording immediately so user's "thanks" doesn't start new session
if (sttv2Manager && sttv2Manager.isRecording) {
console.log('🛑 Stopping STT recording to prevent additional input...');
try {
await sttv2Manager.stopRecording();
} catch (error) {
console.error('❌ Error stopping STT:', error);
}
}
// Reset session before showing celebration
try {
const resetResponse = await fetch('/api/session/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: currentSessionId })
});
if (resetResponse.ok) {
console.log('✅ Session reset successful');
} else {
console.warn('⚠️ Session reset failed, but continuing with celebration');
}
} catch (error) {
console.error('❌ Session reset error:', error);
}
// 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(() => {
console.log('🔄 Redirecting to home page...');
window.location.href = '/';
}, 3000);
}
// Monitor for booking success and extract user info after each message
const originalAddMessage = addMessage;
addMessage = async function(message, isUser = false) {
console.log('📬 addMessage called:', { isUser, messageLength: message?.length });
await originalAddMessage(message, isUser);
if (!isUser) {
console.log('📨 Assistant message received, checking for updates...');
// Check for booking success after adding assistant message
setTimeout(checkForBookingSuccess, 100);
// Extract user info if present in response
extractUserInfoFromResponse(message);
// Update menu item colors to show collection progress
// Increased delay to ensure backend has saved session data
console.log('⏱️ Scheduling color update in 500ms...');
setTimeout(updateMenuItemColors, 500);
} else {
console.log('👤 User message, skipping color update');
}
};
// Auto-start recording and show popup notification
async function autoStartRecording() {
try {
// Show recording popup for 2 seconds
showRecordingPopup();
// Play welcome greeting via TTS
const greetingMessage = "[cheerful] Hello! This is Jessica. Peter Gits's friendly, and [whisper] <chuckle> may I say dynamic [cheerful], Personal Voice Calendar assistant. [excited] I can book a GoogleMeet conference call! Or just schedule a callback. [professional] What day and time works for you?";
console.log('🎙️ Playing welcome greeting...');
chatCalTTS.synthesizeAndPlay(greetingMessage);
// Auto-start recording AFTER greeting completes
// Greeting is ~8 seconds, wait 9 seconds to ensure it finishes
// This prevents the visualizer from showing during TTS playback
setTimeout(async () => {
if (sttv2Manager && !sttv2Manager.isRecording) {
console.log('🎙️ Greeting complete, starting microphone...');
// Mark greeting as complete
greetingComplete = true;
// Show the visualizer and mic button now that greeting is done
const visualizer = document.getElementById('audioVisualizer');
const sttButton = document.getElementById('sttIndicator');
if (visualizer) {
visualizer.style.display = 'block';
console.log('👁️ Showing audio visualizer after greeting');
}
if (sttButton) {
sttButton.style.display = 'block';
console.log('👁️ Showing microphone button after greeting');
}
await sttv2Manager.startRecording();
setupAudioVisualization();
console.log('🎤 Microphone enabled - ready for your request');
}
}, 9000);
} 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; // User microphone analyser
let dataArray = null; // User audio data
let ttsAnalyser = null; // TTS audio analyser (outer circle)
let ttsDataArray = null; // TTS audio data
let animationId = null;
async function setupAudioVisualization() {
try {
const visualizer = document.getElementById('audioVisualizer');
const canvas = document.getElementById('audioCanvas');
const ctx = canvas.getContext('2d');
// Only show visualizer if greeting is complete
if (greetingComplete) {
visualizer.style.display = 'block';
} else {
console.log('🎤 Greeting in progress - keeping visualizer hidden');
}
// Set up audio context for visualization
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Get microphone stream (inner circle)
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioContext.createMediaStreamSource(stream);
// Create analyser for user microphone
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);
}
}
// Setup TTS audio visualization (outer circle)
// NOTE: Can only be called ONCE per audio element (Web Audio API limitation)
let ttsVisualizationSetup = false;
function setupTTSVisualization(audioElement) {
// Show visualizer for TTS playback
const visualizer = document.getElementById('audioVisualizer');
const canvas = document.getElementById('audioCanvas');
const ctx = canvas ? canvas.getContext('2d') : null;
// Only show visualizer if greeting is complete
if (visualizer && greetingComplete) {
visualizer.style.display = 'block';
console.log('🎨 Visualizer shown for TTS playback');
} else if (visualizer) {
console.log('🎤 Greeting in progress - keeping visualizer hidden during TTS');
}
if (ttsVisualizationSetup) {
console.log('🎨 TTS visualization already set up');
// Animation loop should already be running
return;
}
try {
console.log('🎨 Setting up TTS audio visualization (outer circle)');
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Create analyser for TTS audio (only once)
ttsAnalyser = audioContext.createAnalyser();
ttsAnalyser.fftSize = 256;
const bufferLength = ttsAnalyser.frequencyBinCount;
ttsDataArray = new Uint8Array(bufferLength);
// Connect TTS audio element to analyser (can only be done once!)
const ttsSource = audioContext.createMediaElementSource(audioElement);
ttsSource.connect(ttsAnalyser);
ttsSource.connect(audioContext.destination); // Also connect to speakers
ttsVisualizationSetup = true;
console.log('✅ TTS visualization connected (outer circle will show when TTS plays)');
// Start animation loop if not already running
if (!animationId && canvas && ctx) {
console.log('🎬 Starting animation loop for TTS visualization');
drawWaveform(canvas, ctx);
}
} catch (error) {
console.error('❌ TTS visualization setup failed:', error);
console.error('Error details:', error.message);
}
}
// Draw circular waveform visualization (inspired by unmute.sh)
// Inner circle: User microphone (cyan)
// Outer circle: TTS assistant voice (white/blue)
let rotationAngle = 0;
function drawWaveform(canvas, ctx) {
// Continue if at least one analyser is active (mic OR tts)
if ((!analyser || !dataArray) && (!ttsAnalyser || !ttsDataArray)) {
return; // Exit only if both are unavailable
}
animationId = requestAnimationFrame(() => drawWaveform(canvas, ctx));
// Get audio data for both sources (if available)
if (analyser && dataArray) {
analyser.getByteFrequencyData(dataArray);
}
if (ttsAnalyser && ttsDataArray) {
ttsAnalyser.getByteFrequencyData(ttsDataArray);
}
// Clear canvas with transparent background
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Circle parameters
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const innerRadius = Math.min(canvas.width, canvas.height) / 2 - 70; // User mic (smaller)
const outerRadius = Math.min(canvas.width, canvas.height) / 2 - 30; // TTS (larger)
// OUTER CIRCLE: TTS Assistant Voice (white/light blue)
if (ttsAnalyser && ttsDataArray) {
// Draw outer base circle
ctx.strokeStyle = 'rgba(220, 230, 255, 0.7)'; // Light blue/white
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(centerX, centerY, outerRadius, 0, Math.PI * 2);
ctx.stroke();
// Draw TTS waveform radiating outward
const ttsSliceAngle = (Math.PI * 2) / ttsDataArray.length;
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; // White for TTS
for (let i = 0; i < ttsDataArray.length; i++) {
const amplitude = ttsDataArray[i] / 255;
const waveHeight = amplitude * 25; // Spike length
// Calculate angle (with rotation)
const angle = ttsSliceAngle * i + rotationAngle;
// Start point on outer circle edge
const x1 = centerX + Math.cos(angle) * outerRadius;
const y1 = centerY + Math.sin(angle) * outerRadius;
// End point extending outward
const x2 = centerX + Math.cos(angle) * (outerRadius + waveHeight);
const y2 = centerY + Math.sin(angle) * (outerRadius + waveHeight);
// Draw waveform line
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
// INNER CIRCLE: User Microphone (cyan)
// Only draw if microphone analyser is active
if (analyser && dataArray) {
// Draw inner base circle
ctx.strokeStyle = 'rgba(0, 230, 200, 0.8)'; // Cyan/turquoise
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
ctx.stroke();
// Draw user waveform radiating outward
const userSliceAngle = (Math.PI * 2) / dataArray.length;
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)'; // Bright cyan for user
for (let i = 0; i < dataArray.length; i++) {
const amplitude = dataArray[i] / 255;
const waveHeight = amplitude * 25; // Spike length
// Calculate angle (with rotation)
const angle = userSliceAngle * i + rotationAngle;
// Start point on inner circle edge
const x1 = centerX + Math.cos(angle) * innerRadius;
const y1 = centerY + Math.sin(angle) * innerRadius;
// End point extending outward
const x2 = centerX + Math.cos(angle) * (innerRadius + waveHeight);
const y2 = centerY + Math.sin(angle) * (innerRadius + waveHeight);
// Draw waveform line
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
// Rotate slowly for dynamic effect
rotationAngle += 0.01;
}
// 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.25; // Higher threshold - requires actual speaking (25% of max volume)
let backgroundNoiseLevel = 0.05; // Baseline noise level to ignore
let speechDetectionCount = 0;
let speechDetectionRequired = 7; // More consecutive detections needed (reduces false positives from breathing)
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.05); // 5% 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);
// Update menu items based on collected user information
// GREEN = still needed | BLACK = already captured
async function updateMenuItemColors() {
try {
if (!sessionId) {
console.warn('⚠️ No session ID available for color update');
return;
}
console.log('🔍 Fetching session-info for color update... Session:', sessionId);
const response = await fetch(`/api/session-info?session_id=${sessionId}`);
if (!response.ok) {
console.warn('❌ Failed to fetch session-info:', response.status);
return;
}
const data = await response.json();
console.log('📦 Full session data:', JSON.stringify(data, null, 2));
const userInfo = data.user_data?.user_info || {};
console.log('🎨 Updating menu colors with user info:', JSON.stringify(userInfo, null, 2));
// Menu item 1: Name
const menuItem1 = document.getElementById('menuItem1');
if (menuItem1) {
if (userInfo.name) {
// Already captured - Black with strikethrough
menuItem1.style.color = '#333';
menuItem1.style.textDecoration = 'line-through';
menuItem1.style.opacity = '0.6';
} else {
// Still needed - Green and bold
menuItem1.style.color = '#4caf50';
menuItem1.style.textDecoration = 'none';
menuItem1.style.opacity = '1';
menuItem1.style.fontWeight = 'bold';
}
}
// Menu item 2: Date
const menuItem2 = document.getElementById('menuItem2');
if (menuItem2) {
if (userInfo.date_string) {
menuItem2.style.color = '#333';
menuItem2.style.textDecoration = 'line-through';
menuItem2.style.opacity = '0.6';
} else {
menuItem2.style.color = '#4caf50';
menuItem2.style.textDecoration = 'none';
menuItem2.style.opacity = '1';
menuItem2.style.fontWeight = 'bold';
}
}
// Menu item 3: Time
const menuItem3 = document.getElementById('menuItem3');
if (menuItem3) {
if (userInfo.time_string) {
menuItem3.style.color = '#333';
menuItem3.style.textDecoration = 'line-through';
menuItem3.style.opacity = '0.6';
} else {
menuItem3.style.color = '#4caf50';
menuItem3.style.textDecoration = 'none';
menuItem3.style.opacity = '1';
menuItem3.style.fontWeight = 'bold';
}
}
// Menu item 4: Length (duration)
const menuItem4 = document.getElementById('menuItem4');
if (menuItem4) {
if (userInfo.duration_minutes) {
menuItem4.style.color = '#333';
menuItem4.style.textDecoration = 'line-through';
menuItem4.style.opacity = '0.6';
} else {
menuItem4.style.color = '#4caf50';
menuItem4.style.textDecoration = 'none';
menuItem4.style.opacity = '1';
menuItem4.style.fontWeight = 'bold';
}
}
// Menu item 5: Agenda (topic)
const menuItem5 = document.getElementById('menuItem5');
if (menuItem5) {
if (userInfo.topic) {
menuItem5.style.color = '#333';
menuItem5.style.textDecoration = 'line-through';
menuItem5.style.opacity = '0.6';
} else {
menuItem5.style.color = '#4caf50';
menuItem5.style.textDecoration = 'none';
menuItem5.style.opacity = '1';
menuItem5.style.fontWeight = 'bold';
}
}
// Menu item 6: GoogleMeet or phone call
const menuItem6 = document.getElementById('menuItem6');
if (menuItem6) {
if (userInfo.preferences?.google_meet || userInfo.preferences?.phone_call) {
menuItem6.style.color = '#333';
menuItem6.style.textDecoration = 'line-through';
menuItem6.style.opacity = '0.6';
} else {
menuItem6.style.color = '#4caf50';
menuItem6.style.textDecoration = 'none';
menuItem6.style.opacity = '1';
menuItem6.style.fontWeight = 'bold';
}
}
// Menu item 7: Phone number
const menuItem7 = document.getElementById('menuItem7');
if (menuItem7) {
if (userInfo.phone) {
menuItem7.style.color = '#333';
menuItem7.style.textDecoration = 'line-through';
menuItem7.style.opacity = '0.6';
} else {
menuItem7.style.color = '#4caf50';
menuItem7.style.textDecoration = 'none';
menuItem7.style.opacity = '1';
menuItem7.style.fontWeight = 'bold';
}
}
} catch (error) {
console.error('Error updating menu colors:', error);
}
}
// Initial update on page load
window.addEventListener('load', () => {
setTimeout(updateMenuItemColors, 500);
});
</script>
</body>
</html>
"""
return html_content.replace('{base_url}', base_url).replace('{default_email}', default_email)
@router.get("/widget", response_class=HTMLResponse)
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>
"""