1// Import dependencies removed - causing conflicts 2 3class VoiceAssistantWidget { 4 constructor(options = {}) { 5 this.containerId = options.containerId || 'voice-assistant-widget'; 6 this.renderTargetId = options.renderTargetId || null; // New option for custom container 7 this.serverUrl = options.serverUrl || 'api.fonada.ai'; // Default to kb.fonada.ai 8 this.theme = options.theme || 'light'; 9 this.position = options.position || 'bottom-right'; 10 this.platform = options.platform || 'uat'; // Platform: uat or production 11 12 // Store user-provided config or use defaults 13 this.config = options.config 14 15 // Audio handling variables 16 this.socket = null; 17 this.isRecording = false; 18 this.outputSampleRate = 24000; 19 this.inputSampleRate = 16000; 20 this.config.input_sample_rate = this.inputSampleRate; 21 this.config.speech_sample_rate = this.outputSampleRate; 22 this.audioContext = null; 23 this.micStream = null; 24 this.sourceNode = null; 25 this.processor = null; 26 this.bufferSize = 8192; 27 this.bufferEmpty = false 28 29 // WavTools components 30 this.wavRecorder = null; 31 this.wavPlayer = null; 32 33 // Audio visualization 34 this.audioAnimationFrame = null; 35 this.audioAnalysisLoop = null; 36 37 // Playback completion tracking 38 this.isPlaybackActive = false; 39 this.completionNotificationSent = false; 40 this.pollCount = 0; 41 this.generationComplete = false; // Track if LLM generation is complete 42 43 // UI State variables from both files merged 44 this.state = 'idle'; // idle, requesting-permission, connecting, connected, listening, thinking, speaking, error 45 this.isExpanded = false; 46 47 // Notify initial state 48 setTimeout(() => { 49 if (window.widgetStateManager) { 50 window.widgetStateManager.updateState({ 51 state: 'idle', 52 isActive: false 53 }); 54 } 55 }, 100); 56 this.isConnecting = false; 57 this.isConnected = false; 58 this.clientId = ''; 59 this.error = ''; 60 this.displayText = '⨠Begin Conversation'; 61 this.isSpeaking = false; 62 this.activeAudioSources = new Set(); 63 this.speakingTimeout = null; 64 this.lastStateChange = 0; 65 this.hasPermission = false; 66 this.toasts = []; 67 this.lastErrorToast = 0; 68 this._isThinking = false; // from my-widget 69 this.micAnalyser = null; 70 71 // Dispatch state changes globally 72 this.dispatchStateChange = () => { 73 const event = new CustomEvent('widgetStateChange', { 74 detail: { 75 state: this.state, 76 isActive: this.isConnected || this.isConnecting 77 } 78 }); 79 window.dispatchEvent(event); 80 }; 81 82 // DOM elements 83 this.elements = {}; 84 85 this.isInitialized = false; 86 this.init(); 87 } 88 89 init() { 90 if (this.isInitialized) return; 91 92 // DEBUG: Log what's happening to navbar 93 // console.log('Widget init starting - checking navbar visibility'); 94 const navbar = document.querySelector('nav'); 95 if (navbar) { 96 // // console.log('Navbar found before widget creation:', { 97 // display: getComputedStyle(navbar).display, 98 // visibility: getComputedStyle(navbar).visibility, 99 // opacity: getComputedStyle(navbar).opacity, 100 // zIndex: getComputedStyle(navbar).zIndex 101 // }); 102 } 103 104 this.createWidgetContainer(); 105 this.initializeVoiceAssistant(); 106 this.addEventListeners(); 107 108 this.isInitialized = true; 109 110 // // DEBUG: Check navbar after widget creation 111 // setTimeout(() => { 112 // const navbarAfter = document.querySelector('nav'); 113 // if (navbarAfter) { 114 // // // console.log('Navbar after widget creation:', { 115 // // display: getComputedStyle(navbarAfter).display, 116 // // visibility: getComputedStyle(navbarAfter).visibility, 117 // // opacity: getComputedStyle(navbarAfter).opacity, 118 // // zIndex: getComputedStyle(navbarAfter).zIndex 119 // // }); 120 // // } 121 // }, 100); 122 } 123 124 // ... (Keep the Toast notification system from the original widget.js) 125 showToast(type, title, message, duration = 5000) { 126 if (type === 'error') { 127 const now = Date.now(); 128 const timeSinceLastError = now - this.lastErrorToast; 129 const hasExistingError = this.toasts.some(toast => toast.type === 'error'); 130 if (hasExistingError || timeSinceLastError < 3000) { 131 // console.log('Error toast already exists or cooldown active, not showing duplicate'); 132 return; 133 } 134 this.lastErrorToast = now; 135 duration = 8000; 136 } 137 const id = Math.random().toString(36).substr(2, 9); 138 const toast = { id, type, title, message, duration }; 139 this.toasts.push(toast); 140 this.renderToast(toast); 141 if (duration > 0) { 142 setTimeout(() => this.removeToast(id), duration); 143 } 144 } 145 146 removeToast(id) { 147 this.toasts = this.toasts.filter(t => t.id !== id); 148 const toastElement = document.getElementById(`toast-${id}`); 149 if (toastElement) { 150 toastElement.style.animation = 'toastExit 0.3s ease-out forwards'; 151 setTimeout(() => toastElement.remove(), 300); 152 } 153 } 154 155 renderToast(toast) { 156 const toastContainer = document.getElementById('toast-container') || this.createToastContainer(); 157 const toastElement = document.createElement('div'); 158 toastElement.id = `toast-${toast.id}`; 159 toastElement.className = `toast toast-${toast.type}`; 160 const iconMap = { success: 'â', error: 'â ', warning: 'â ', info: 'â¹' }; 161 toastElement.innerHTML = ` 162 <div class="toast-icon">${iconMap[toast.type]}</div> 163 <div class="toast-content"> 164 <div class="toast-title">${toast.title}</div> 165 <div class="toast-message">${toast.message}</div> 166 </div> 167 <button class="toast-close" onclick="window.voiceWidgetInstance.removeToast('${toast.id}')">Ã</button> 168 `; 169 toastContainer.appendChild(toastElement); 170 requestAnimationFrame(() => { 171 toastElement.style.animation = 'toastEnter 0.3s ease-out forwards'; 172 }); 173 } 174 175 createToastContainer() { 176 const container = document.createElement('div'); 177 container.id = 'toast-container'; 178 container.className = 'toast-container'; 179 // Mount toasts directly to body - NO WRAPPER 180 document.body.appendChild(container); 181 return container; 182 } 183 184 185 // Update display text 186 updateDisplayText(newText) { 187 this.displayText = newText; 188 if (this.elements.statusText) { 189 this.elements.statusText.textContent = newText; 190 } 191 if (this.elements.hoverMessage) { 192 // Update hover message only when not connected/connecting 193 if (!this.isConnected && !this.isConnecting) { 194 this.elements.hoverMessage.textContent = newText; 195 } 196 } 197 } 198 199 200 setState(newState, forceUpdate = false) { 201 const now = Date.now(); 202 const timeSinceLastChange = now - this.lastStateChange; 203 204 if (forceUpdate || timeSinceLastChange > 100) { 205 this.state = newState; 206 this._isThinking = (newState === 'thinking'); 207 this.lastStateChange = now; 208 this.updateUI(); 209 210 // Notify global state manager if available 211 this.dispatchStateChange(); 212 } 213 } 214 215 // Merged and adapted updateUI from my-widget.js 216 updateUI() { 217 if (!this.elements.mainWidget || !this.elements.statusLabel) return; 218 219 // Remove all state classes 220 this.elements.mainWidget.classList.remove('listening', 'speaking', 'thinking'); 221 222 switch (this.state) { 223 case 'listening': 224 this.elements.mainWidget.classList.add('listening'); 225 this.elements.statusLabel.textContent = 'Listening...'; 226 break; 227 case 'speaking': 228 this.elements.mainWidget.classList.add('speaking'); 229 this.elements.statusLabel.textContent = 'Talk to Interrupt'; 230 break; 231 case 'thinking': 232 this.elements.mainWidget.classList.add('thinking'); 233 this.elements.statusLabel.textContent = 'Thinking...'; 234 break; 235 case 'idle': 236 default: 237 this.elements.statusLabel.textContent = 'Begin Conversation'; 238 break; 239 } 240 } 241 242 243 showListeningAnimation() { 244 this.hideAllAnimations(); 245 const { circularEffect, middleCircle, innerCircle, listeningRing, listeningAnim1, listeningAnim2, listeningAnim3, listeningAnim4 }
245 = this.elements; 246 if(circularEffect) circularEffect.className = 'circular-effect listening'; 247 if(middleCircle) middleCircle.className = 'middle-circle listening'; 248 if(innerCircle) innerCircle.className = 'inner-circle listening'; 249 } 250 251 showSpeakingAnimation() { 252 this.hideAllAnimations(); 253 const { circularEffect, middleCircle, innerCircle, speakingRing } = this.elements; 254 if(circularEffect) circularEffect.className = 'circular-effect speaking'; 255 if(middleCircle) middleCircle.className = 'middle-circle speaking'; 256 if(innerCircle) innerCircle.className = 'inner-circle speaking'; 257 } 258 259 showThinkingAnimation() { 260 this.hideAllAnimations(); 261 const { circularEffect, middleCircle, innerCircle } = this.elements; 262 if(circularEffect) circularEffect.className = 'circular-effect thinking'; 263 if(middleCircle) middleCircle.className = 'middle-circle thinking'; 264 if(innerCircle) innerCircle.className = 'inner-circle thinking'; 265 } 266 267 hideAllAnimations() { 268 const { circularEffect, middleCircle, innerCircle, listeningRing, speakingRing } = this.elements; 269 if(circularEffect) circularEffect.className = 'circular-effect'; 270 if(middleCircle) middleCircle.className = 'middle-circle'; 271 if(innerCircle) innerCircle.className = 'inner-circle'; 272 } 273 274 // Audio Visualizer Methods 275 showAudioVisualizer(state) { 276 if (!this.elements.audioVisualizer) return; 277 278 const visualizer = this.elements.audioVisualizer; 279 const bars = visualizer.querySelectorAll('.visualizer-bar'); 280 281 // Show the visualizer 282 visualizer.classList.add('active'); 283 284 // Clear previous state classes 285 bars.forEach(bar => { 286 bar.classList.remove('listening', 'speaking', 'thinking'); 287 }); 288 289 // Add new state class to all bars 290 bars.forEach((bar, index) => { 291 bar.classList.add(state); 292 // Add staggered delay for wave effect 293 bar.style.animationDelay = `${index * 0.05}s`; 294 }); 295 296 // Start real-time audio analysis if available 297 this.startAudioAnalysis(state); 298 } 299 300 hideAudioVisualizer() { 301 if (!this.elements.audioVisualizer) return; 302 303 const visualizer = this.elements.audioVisualizer; 304 const bars = visualizer.querySelectorAll('.visualizer-bar'); 305 306 visualizer.classList.remove('active'); 307 bars.forEach(bar => { 308 bar.classList.remove('listening', 'speaking', 'thinking'); 309 bar.style.height = '4px'; 310 }); 311 312 this.stopAudioAnalysis(); 313 } 314 315 startAudioAnalysis(state) { 316 // Stop any existing analysis 317 this.stopAudioAnalysis(); 318 319 if (!this.elements.audioVisualizer) return; 320 321 const bars = this.elements.audioVisualizer.querySelectorAll('.visualizer-bar'); 322 323 // Create animation loop for audio visualization 324 this.audioAnalysisLoop = () => { 325 if (!this.elements.audioVisualizer?.classList.contains('active')) { 326 return; 327 } 328 329 // Try to get real audio data from wavtools 330 let audioData = this.getRealAudioData(); 331 332 if (!audioData) { 333 // Fallback to simulated data 334 audioData = this.generateSimulatedAudioData(state, bars.length); 335 } 336 337 // Update bar heights with better scaling 338 bars.forEach((bar, index) => { 339 const amplitude = audioData[index] || 0; 340 const height = Math.max(4, amplitude * 40); // Scale to 40px max for better visibility 341 bar.style.height = `${height}px`; 342 343 // Add dynamic opacity based on amplitude 344 bar.style.opacity = Math.max(0.3, 0.7 + (amplitude * 0.3)); 345 }); 346 347 // Continue animation 348 this.audioAnimationFrame = requestAnimationFrame(this.audioAnalysisLoop); 349 }; 350 351 this.audioAnimationFrame = requestAnimationFrame(this.audioAnalysisLoop); 352 } 353 354 stopAudioAnalysis() { 355 if (this.audioAnimationFrame) { 356 cancelAnimationFrame(this.audioAnimationFrame); 357 this.audioAnimationFrame = null; 358 } 359 this.audioAnalysisLoop = null; 360 } 361 362 getRealAudioData() {
363 // Try to get real audio data from wavtools analyzer 364 try { 365 if (this.wavPlayer && this.wavPlayer.analyser) { 366 const analyzer = this.wavPlayer.analyser; 367 const bufferLength = analyzer.frequencyBinCount; 368 const dataArray = new Uint8Array(bufferLength); 369 analyzer.getByteFrequencyData(dataArray); 370 371 // Convert to 20 bars with voice frequency focus 372 const bars = 20; 373 const sampleRate = 44100; // Assume standard sample rate 374 const nyquist = sampleRate / 2; 375 const binWidth = nyquist / bufferLength; 376 377 // Focus on voice frequency range (80Hz - 2000Hz) 378 const voiceStartBin = Math.floor(80 / binWidth); 379 const voiceEndBin = Math.floor(2000 / binWidth); 380 const voiceRange = voiceEndBin - voiceStartBin; 381 const binsPerBar = Math.floor(voiceRange / bars); 382 383 const result = []; 384 385 for (let i = 0; i < bars; i++) { 386 let sum = 0; 387 let count = 0; 388 const startBin = voiceStartBin + (i * binsPerBar); 389 const endBin = Math.min(startBin + binsPerBar, voiceEndBin); 390 391 for (let j = startBin; j < endBin; j++) { 392 sum += dataArray[j]; 393 count++; 394 } 395 396 const average = count > 0 ? sum / count : 0; 397 let normalized = average / 255; 398 399 // Apply logarithmic scaling for better voice response 400 normalized = Math.pow(normalized, 0.6); 401 402 // Boost sensitivity for voice frequencies 403 normalized = Math.min(1, normalized * 2.5); 404 405 result.push(Math.max(0.02, normalized)); 406 } 407 408 return result; 409 } 410 411 if (this.wavRecorder && this.wavRecorder.analyser) { 412 const analyzer = this.wavRecorder.analyser; 413 const bufferLength = analyzer.frequencyBinCount; 414 const dataArray = new Uint8Array(bufferLength); 415 analyzer.getByteFrequencyData(dataArray); 416 417 // Convert to 20 bars with voice frequency focus 418 const bars = 20; 419 const sampleRate = 44100; // Assume standard sample rate 420 const nyquist = sampleRate / 2; 421 const binWidth = nyquist / bufferLength; 422 423 // Focus on voice frequency range (80Hz - 2000Hz) 424 const voiceStartBin = Math.floor(80 / binWidth); 425 const voiceEndBin = Math.floor(2000 / binWidth); 426 const voiceRange = voiceEndBin - voiceStartBin; 427 const binsPerBar = Math.floor(voiceRange / bars); 428 429 const result = []; 430 431 for (let i = 0; i < bars; i++) { 432 let sum = 0; 433 let count = 0; 434 const startBin = voiceStartBin + (i * binsPerBar); 435 const endBin = Math.min(startBin + binsPerBar, voiceEndBin); 436 437 for (let j = startBin; j < endBin; j++) { 438 sum += dataArray[j]; 439 count++; 440 } 441 442 const average = count > 0 ? sum / count : 0; 443 let normalized = average / 255; 444 445 // Apply logarithmic scaling for better voice response 446 normalized = Math.pow(normalized, 0.6); 447 448 // Boost sensitivity for voice frequencies 449 normalized = Math.min(1, normalized * 2.5); 450 451 result.push(Math.max(0.02, normalized)); 452 } 453 454 return result; 455 } 456 } catch (error) { 457 // console.warn('Could not get real audio data:', error); 458 } 459 460 return null; 461 } 462 463 generateSimulatedAudioData(state, barCount) { 464 const time = Date.now(); 465 const data = []; 466 467 for (let i = 0; i < barCount; i++) { 468 let amplitude; 469 470 switch (state) { 471 case 'listening': 472 // Gentle wave pattern for listening 473 amplitude = Math.sin(time * 0.003 + i * 0.5) * 0.3 + 0.4; 474 amplitude += Math.random() * 0.2; // Add some randomness 475 break; 476 477 case 'speaking': 478 // More active pattern for speaking 479 amplitude = Math.sin(time * 0.005 + i * 0.3) * 0.5 + 0.5; 480 amplitude += Math.sin(time * 0.008 + i * 0.8) * 0.3; 481 amplitude += Math.random() * 0.3; 482 break; 483 484 case 'thinking': 485 // Subtle pulsing for thinking 486 amplitude = Math.sin(time * 0.002 + i * 0.2) * 0.2 + 0.3; 487 amplitude += Math.random() * 0.1; 488 break; 489 490 default: 491 amplitude = 0.1; 492 } 493 494 data.push(Math.max(0.1, Math.min(1, amplitude))); 495 } 496 497 return data; 498 } 499 500 501 createWidgetContainer() { 502 // Check if widget already exists 503 const existingWidget = document.getElementById(this.containerId); 504 if (existingWidget) { 505 existingWidget.remove(); 506 } 507 508 // COMPLETELY ISOLATED WIDGET - NO WRAPPERS, NO INTERFERENCE 509 const container = document.createElement('div'); 510 container.id = this.containerId; 511 512 const isInline = !!this.renderTargetId; 513 514 container.className = isInline ? 'voice-assistant-widget-inline' : `voice-assistant-widget ${this.position}`; 515 container.style.position = isInline ? 'relative' : 'fixed'; 516 if (!isInline) { 517 container.style.bottom = '32px'; 518 container.style.right = '32px'; 519 container.style.zIndex = '40'; 520 } 521 container.style.width = '120px'; 522 container.style.height = '120px'; 523 container.style.pointerEvents = 'auto'; 524 525 // Simplified HTML structure with a single icon and status label 526 container.innerHTML = ` 527 <div class="widget-container"> 528 <div class="widget-main" id="${this.containerId}-mainWidget"> 529 <div class="widget-surface"></div> 530 <div class="widget-icon" id="${this.containerId}-mainIcon">
531 <svg class="icon" viewBox="0 0 24 24"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg> 532 </div> 533 <div class="status-text-overlay" id="${this.containerId}-statusText"> 534 <span class="status-label">Begin Conversation</span> 535 </div> 536 </div> 537 </div> 538 `; 539 540 const targetElement = this.renderTargetId ? document.getElementById(this.renderTargetId) : document.body; 541 targetElement.appendChild(container); 542 this.addWidgetStyles(); 543 window.voiceWidgetInstance = this; 544 } 545 546 addWidgetStyles() { 547 const styleSheet = document.createElement('style'); 548 // CSS from my-widget.js 549 styleSheet.textContent = ` 550 .voice-assistant-widget { 551 position: fixed !important; 552 z-index: 40 !important; 553 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 554 } 555 .voice-assistant-widget-inline { 556 position: relative !important; 557 z-index: 10 !important; 558 } 559 .widget-container { position: relative; width: 100%; height: 100%; } 560 .widget-main { 561 width: 135px; 562 height: 135px; 563 padding: 0 6px; 564 cursor: pointer; 565 border-radius: 50%; 566 transition: all 0.3s ease; 567 display: flex; 568 align-items: center; 569 justify-content: center; 570 flex-direction: column; // Arrange icon and text vertically 571 position: relative; 572 /* Match Telephony Bot hero icon: brand gradient + soft brand glow */ 573 background: linear-gradient( 574 to bottom right, 575 rgb(var(--color-brand)), 576 rgb(var(--color-brand)), 577 rgb(var(--color-brand-light)) 578 ); 579 border: none; 580 box-shadow: 0 0 40px color-mix(in srgb, rgb(var(--color-brand)) 20%, transparent); 581 margin-left: -8px; 582 margin-top: -6px; 583 } 584 .widget-main:hover { transform: scale(1.05); } 585 .widget-icon { 586 color: #ffffff; 587 margin-bottom: 8px; 588 transition: all 0.3s ease; 589 filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25)); 590 } 591 .widget-icon .icon { 592 width: 32px; 593 height: 32px; 594 fill: currentColor; 595 } 596 .status-text-overlay { 597 display: flex; 598 flex-direction: column; 599 align-items: center; 600 font-size: 14px; 601 font-weight: 500; 602 color: rgba(255, 255, 255, 0.9); 603 transition: all 0.3s ease; 604 } 605 .status-label { 606 font-size: 14px; 607 } 608 609 /* States */ 610 .widget-main.listening .widget-icon { color: #16a34a; } 611 .widget-main.listening .status-label { color: #16a34a; } 612 .widget-main.speaking .widget-icon { color: #2563eb; } 613 .widget-main.speaking .status-label { color: #2563eb; } 614 .widget-main.thinking .widget-icon { color: #f97316; } 615 .widget-main.thinking .status-label { color: #f97316; } 616 `; 617 document.head.appendChild(styleSheet); 618 } 619 620 initializeVoiceAssistant() { 621 this.elements.mainWidget = document.getElementById(`${this.containerId}-mainWidget`); 622 this.elements.mainIcon = document.getElementById(`${this.containerId}-mainIcon`); 623 this.elements.statusText = document.getElementById(`${this.containerId}-statusText`); 624 this.elements.statusLabel = this.elements.statusText ? this.elements.statusText.querySelector('.status-label') : null; 625 } 626 627 addEventListeners() { 628 this.elements.mainWidget.addEventListener('click', () => { 629 if (this.state === 'idle') { 630 this.startListening(); 631 } else { 632 this.endCall(); 633 } 634 }); 635 } 636 637 showHoverMessage() { 638 if (!this.isConnected && !this.isConnecting) { 639 this.elements.hoverMessage.classList.add('show'); 640 } 641 } 642 643 hideHoverMessage() { 644 if(this.elements.hoverMessage) this.elements.hoverMessage.classList.remove('show'); 645 } 646 647 setExpanded(expanded) { 648 this.isExpanded = expanded; 649 if (this.elements.expandedPanel) { 650 if (expanded) { 651 this.elements.expandedPanel.classList.add('active'); 652 this.hideHoverMessage(); 653 } else { 654 this.elements.expandedPanel.classList.remove('active'); 655 } 656 } 657 658 // Notify global state manager if available 659 if (window.widgetStateManager) { 660 window.widgetStateManager.updateState({ 661 isActive: expanded || this.state !== 'idle' 662 }); 663 } 664 } 665 666 handleWidgetClick() { 667 if (this.isConnecting) return; 668 669 if (!this.isConnected) { 670 this.startListening(); 671 } else { 672 this.setExpanded(!this.isExpanded); 673 } 674 } 675 676 toggleMinimize() { 677 this.setExpanded(!this.isExpanded); 678 } 679 680 setConnected(connected) { 681 this.isConnected = connected; 682 this.updateUI(); 683 this.dispatchStateChange(); 684 } 685 686 687 // Core Logic (requestMicrophonePermission, initializeAudio, startListening, connectWebSocket, etc.) 688 // remains the same as in the original widget.js. I'll include the key methods here for completeness. 689 690 async handleError(errorMessage, details) { 691 // console.error('ð´ Widget Error:', errorMessage, details);
692 this.showToast('error', 'Connection Error', errorMessage); 693 await this.cleanup(); 694 this.error = errorMessage; 695 this.isConnecting = false; 696 this.hasPermission = false; 697 this.isConnected = false; 698 this.setState('error', true); 699 this.dispatchStateChange(); 700 setTimeout(() => { 701 this.setState('idle', true); 702 this.dispatchStateChange(); 703 }, 4000); // Revert to idle after showing error 704 } 705 706 async requestMicrophonePermission() { 707 try { 708 this.setState('requesting-permission'); 709 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 710 throw new Error("MediaDevices API not supported"); 711 } 712 const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: this.inputSampleRate, echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); 713 this.micStream = stream; 714 this.hasPermission = true; 715 return true; 716 } catch (error) { 717 // console.error('ð¤ Microphone permission denied:', error); 718 let errorMessage = 'Microphone access denied. Please allow microphone access to use voice features.'; 719 await this.handleError(errorMessage, error.message); 720 return false; 721 } 722 } 723 724 async initializeAudio() { 725 try { 726 if (!this.micStream) throw new Error("No microphone stream"); 727 this.audioContext = new(window.AudioContext || window.webkitAudioContext)({ sampleRate: this.inputSampleRate }); 728 this.sourceNode = this.audioContext.createMediaStreamSource(this.micStream); 729 730 // Create and connect analyser for mic input 731 this.micAnalyser = this.audioContext.createAnalyser(); 732 this.micAnalyser.fftSize = 256; 733 this.sourceNode.connect(this.micAnalyser); 734 735 this.processor = this.audioContext.createScriptProcessor(this.bufferSize, 1, 1); 736 this.sourceNode.connect(this.processor); 737 this.processor.connect(this.audioContext.destination); 738 this.processor.onaudioprocess = (e) => { 739 if (this.isRecording && this.socket?.readyState === WebSocket.OPEN) { 740 const inputData = e.inputBuffer.getChannelData(0); 741 const pcmData = new Int16Array(inputData.length); 742 for (let i = 0; i < inputData.length; i++) { 743 pcmData[i] = Math.max(-32768, Math.min(32767, Math.round(inputData[i] * 32767))); 744 } 745 try { 746 this.socket.send(pcmData.buffer); 747 } catch (error) { 748 // console.error('Error sending audio:', error); 749 } 750 } 751 }; 752 753 // Initialize and connect wavPlayer only once here 754 this.wavPlayer = new window.WavStreamPlayer({ sampleRate: this.outputSampleRate }); 755 await this.wavPlayer.connect(); 756 // console.log('wavStreamPlayer connected after user interaction'); 757 758 // Setup playback monitoring 759 this.setupPlaybackMonitoring(); 760 } catch (error) { 761 // console.error('ð Error initializing audio:', error); 762 throw error; 763 } 764 } 765 766 async startListening() { 767 if (this.isConnecting || this.isConnected) return; 768 this.isConnecting = true; 769 770 // Reset playback state when starting new session 771 this.isPlaybackActive = false; 772 this.completionNotificationSent = false; 773 this.pollCount = 0; 774 this.generationComplete = false; // Reset generation flag for new session 775 776 try { 777 if (!this.hasPermission) { 778 const permissionGranted = await this.requestMicrophonePermission(); 779 if (!permissionGranted) { 780 this.isConnecting = false; 781 return; 782 } 783 } 784 await this.initializeAudio(); 785 await this.connectWebSocket(); 786 } catch (err) { 787 this.isConnecting = false; 788 const errorMessage = err instanceof Error ? err.message : 'Failed to start voice session'; 789 await this.handleError(errorMessage, String(err)); 790 } 791 } 792 793 async connectWebSocket() { 794 // this.setState('connecting'); 795 let server = this.serverUrl.replace(/^(https?:|wss?:)?\/\//, ''); 796 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 797 const wsUrl = `${protocol}//${server}/`; 798 // console.log('ð Connecting to WebSocket:', wsUrl); 799 // console.log('ð¦ Sending config:', JSON.stringify(this.config, null, 2)); 800 this.socket = new WebSocket(wsUrl); 801 802 const connectionTimeout = setTimeout(() => { 803 if (this.socket?.readyState !== WebSocket.OPEN) { 804 this.handleError('Connection timeout', 'Server took too long to respond'); 805 } 806 }, 10000); 807 808 this.socket.onopen = () => { 809 clearTimeout(connectionTimeout); 810 // console.log('â WebSocket connected!'); 811 const configMessage = { type: 'client_config', config: this.config }; 812 // console.log('ð¤ Sending config to server:', configMessage); 813 this.socket.send(JSON.stringify(configMessage)); 814 }; 815 this.socket.onmessage = async (event) => { 816 if (typeof event.data === 'string') { 817 const message = JSON.parse(event.data); 818 // console.log('ð¥ Received message:', message); 819 this.processControlMessage(message); 820 } else { 821 await this.processAudioChunk(event.data); 822 } 823 }; 824 this.socket.onclose = () => { 825 clearTimeout(connectionTimeout); 826 if(this.isConnected) this.endCall(); 827 }; 828 this.socket.onerror = (error) => { 829 // console.error('ð´ WebSocket error:', error); 830 clearTimeout(connectionTimeout); 831 this.handleError('Connection failed', 'Unable to connect to voice server'); 832 }; 833 } 834 835 processControlMessage(message) { 836 // console.log('Control message:', message); 837 838 // Check for type field first (for end_of_generation, end_of_call, etc.) 839 if (message.type === 'end_of_generation') { 840 // console.log('ð¯ LLM generation completed - playback completion can be sent when audio finishes'); 841 this.generationComplete = true; 842 return; 843 } 844 845 if (message.type === 'end_of_call') { 846 // console.log('ð Call ended by server'); 847 this.updateDisplayText('Call ended'); 848 this.showToast('info', 'Call Ended', 'The conversation has ended.'); 849 this.endCall(); 850 return; 851 } 852 853 switch (message.status) { 854 case 'connected': 855 this.clientId = message.client_id || ''; 856 this.isConnected = true; 857 this.isConnecting = false; 858 this.setState('connected'); 859 this.showToast('success', 'Connected', 'Voice assistant is ready', 3000); 860 break; 861 case 'config_received': 862 // console.log("currently in config_recieved in PCM"); 863 this.isRecording = true; 864 this.setState('listening'); 865 break; 866 case 'transcription': 867 case 'llm_response': 868 this.setState('thinking'); 869 break; 870 case 'tts_streaming': 871 this.isSpeaking = true; 872 this.setState('speaking'); 873 break; 874 case 'tts_end': 875 this.isSpeaking = false; 876 // console.log("currently in tts in PCM"); 877 this.setState('listening'); 878 // DO NOT send completion notification here - audio may still be buffered/playing 879 // Let the client-side detection handle actual playback completion 880 // console.log('Server TTS generation complete, waiting for client playback completion...'); 881 break; 882 case 'error': 883 this.handleError(message.error || 'Server error', 'WebSocket server error'); 884 break; 885 case 'complete': 886 case 'interrupt': 887 this.stopPlayback(); 888 // console.log("currently in interrupt in PCM"); 889 this.setState('listening'); 890 break; 891 } 892 } 893 894 async processAudioChunk(audioData) { 895 try { 896 // Convert audioData to array buffer (same as client.js) 897 const arrayBuffer = audioData instanceof Blob ? await audioData.arrayBuffer() : audioData; 898 const pcmData = new Int16Array(arrayBuffer); 899 900 // Add PCM data to player without reconnecting (same as client.js) 901 if (this.wavPlayer) { 902 const trackId = `track_${Date.now()}`; 903 this.wavPlayer.add16BitPCM(pcmData, trackId); 904 905 // Mark that playback is now active 906 if (!this.isPlaybackActive) { 907 this.isPlaybackActive = true; 908 this.completionNotificationSent = false; // Reset for new playback session 909 this.pollCount = 0; // Reset poll counter for new session 910 // console.log('New playback session started'); 911 912 // Setup stream event listener immediately and retry if needed 913 this.setupStreamEventListener(); 914 915 // Retry stream event listener setup with a fallback to ensure it's attached 916 setTimeout(() => { 917 if (this.isPlaybackActive && this.wavPlayer && this.wavPlayer.stream && this.wavPlayer.stream.port) { 918 this.setupStreamEventListener(); // Retry to ensure listener is attached 919 } 920 }, 50); 921 } 922 923 // Track speaking state - AI is playing audio 924 this.isSpeaking = true; 925 this.setState('speaking'); 926 927 // Reset timeout to detect when audio playback stops 928 if (this.speakingTimeout) clearTimeout(this.speakingTimeout); 929 this.speakingTimeout = setTimeout(() => { 930 if (this.isSpeaking) { 931 this.isSpeaking = false; 932 // // console.log('state of wavPlayer ----->>>>>>',this.wavPlayer) 933 // // console.log("currently in proces audio chunk under reset timeout"); 934 } 935 }, 1000); // 1 second timeout to detect end of audio 936 } 937 } catch (error) { 938 // console.error('Error processing audio chunk:', error); 939 } 940 } 941 942 async stopPlayback() { 943 if (this.wavPlayer) { 944 await this.wavPlayer.interrupt(); 945 } 946 if (this.speakingTimeout) clearTimeout(this.speakingTimeout); 947 this.isSpeaking = false; 948 949 // Reset playback flags since we interrupted 950 this.isPlaybackActive = false; 951 this.bufferEmpty = true; 952 this.completionNotificationSent = false; 953 this.pollCount = 0; 954 this.generationComplete = false; // Reset generation flag on interrupt 955 } 956 957 async endCall() { 958 if (!this.isConnected) return; 959 try { 960 if (this.socket && this.socket.readyState === WebSocket.OPEN) { 961 this.socket.send(JSON.stringify({ type: 'end_turn' })); 962 } 963 await this.cleanup(); 964 this.setState('idle', true); 965 this.dispatchStateChange(); 966 } catch (error) { 967 // console.error('Error ending call:', error); 968 } 969 } 970 971 async cleanup() { 972 this.isRecording = false; 973 await this.stopPlayback(); 974 if (this.processor) this.processor.disconnect(); 975 if (this.sourceNode) this.sourceNode.disconnect(); 976 if (this.micAnalyser) this.micAnalyser.disconnect();
977 if (this.micStream) this.micStream.getTracks().forEach(track => track.stop()); 978 if (this.audioContext && this.audioContext.state !== 'closed') await this.audioContext.close(); 979 if (this.socket) this.socket.close(); 980 981 // Clear speaking timeout 982 if (this.speakingTimeout) { 983 clearTimeout(this.speakingTimeout); 984 this.speakingTimeout = null; 985 } 986 987 this.processor = null; 988 this.sourceNode = null; 989 this.micAnalyser = null; 990 this.micStream = null; 991 this.audioContext = null; 992 this.socket = null; 993 994 this.isConnected = false; 995 this.isConnecting = false; 996 this.hasPermission = false; 997 this.isSpeaking = false; 998 this.generationComplete = false; // Reset generation flag 999 } 1000 1001 // Send WebSocket message when playback completes 1002 sendPlaybackCompletedNotification() { 1003 // Prevent duplicate notifications 1004 if (this.completionNotificationSent) { 1005 return; 1006 } 1007 1008 this.completionNotificationSent = true; 1009 1010 try { 1011 if (this.socket && this.socket.readyState === WebSocket.OPEN) { 1012 const data = { 1013 type: 'playback_completed', 1014 timestamp: new Date().toISOString(), 1015 session_id: 'session_' + Date.now() 1016 }; 1017 1018 this.socket.send(JSON.stringify(data)); 1019 // console.log('Playback completion notification sent via WebSocket:', data); 1020 } 1021 } catch (error) { 1022 // console.error('Error sending playback completion notification via WebSocket:', error); 1023 } 1024 } 1025 1026 // Monitor wavPlayer for playback completion 1027 setupPlaybackMonitoring() { 1028 // Poll to check if playback has stopped with enhanced logic 1029 const checkPlaybackStatus = () => { 1030 if (this.isPlaybackActive) { 1031 this.pollCount++; 1032 1033 // Enhanced completion detection with multiple checks 1034 let playbackStopped = false; 1035 let reason = ''; 1036 1037 // Check 1: Stream is null (primary indicator) 1038 if (this.wavPlayer && this.wavPlayer.stream === null) { 1039 playbackStopped = true; 1040 reason = 'stream_null'; 1041 } 1042 // Check 2: Context is suspended or closed 1043 else if (this.wavPlayer && this.wavPlayer.context && 1044 (this.wavPlayer.context.state === 'suspended' || this.wavPlayer.context.state === 'closed')) { 1045 playbackStopped = true; 1046 reason = 'context_suspended'; 1047 } 1048 // Check 3: No wavPlayer available 1049 else if (!this.wavPlayer) { 1050 playbackStopped = true; 1051 reason = 'no_player'; 1052 } 1053 1054 // Only send completion if BOTH playback stopped AND generation is complete 1055 if (playbackStopped && this.bufferEmpty && this.generationComplete) { 1056 // console.log(`Playback completion detected via polling (reason: ${reason}, poll count: ${this.pollCount}, generation complete: true)`); 1057 this.isPlaybackActive = false; 1058 this.sendPlaybackCompletedNotification(); 1059 this.generationComplete = false; // Reset for next turn 1060 this.setState('listening'); 1061 } else { 1062 if (playbackStopped && this.bufferEmpty && !this.generationComplete) { 1063 // console.log(`Playback stopped but generation not complete yet (reason: ${reason}, poll count: ${this.pollCount}), waiting...`); 1064 } 1065 // Log periodic status for debugging 1066 if (this.pollCount % 25 === 0) { // Every 5 seconds 1067 // console.log(`Playback monitoring: active, poll count: ${this.pollCount}, stream status: ${this.wavPlayer?.stream ? 'active' : 'null'}`); 1068 } 1069 } 1070 } 1071 }; 1072 1073 // Check every 200ms when playback is active - FIXED: Actually start the interval 1074 setInterval(checkPlaybackStatus, 200); 1075 } 1076 1077 // Setup stream event listener when wavPlayer is connected 1078 setupStreamEventListener() { 1079 if (!this.wavPlayer || !this.wavPlayer.stream) { 1080 // console.log('setupStreamEventListener: wavPlayer or stream not available, will retry...'); 1081 return; 1082 } 1083 1084 // Listen for 'stop' events from the stream processor 1085 if (this.wavPlayer.stream.port) { 1086 const originalOnMessage = this.wavPlayer.stream.port.onmessage; 1087 this.wavPlayer.stream.port.onmessage = (e) => { 1088 // Call original handler first 1089 if (originalOnMessage) { 1090 originalOnMessage(e); 1091 } 1092 1093 // Enhanced event handling with logging 1094 const { event, trackId, offset } = e.data || {}; 1095 1096 if (event === 'stop' && this.isPlaybackActive) { 1097 // console.log('Playback completion detected via stream stop event'); 1098 1099 // Add a small buffer delay to ensure all audio has actually finished playing 1100 setTimeout(() => { 1101 if (this.isPlaybackActive) { // Double-check we're still active 1102 this.isPlaybackActive = false; 1103 this.sendPlaybackCompletedNotification(); 1104 this.setState('listening'); 1105 } 1106 }, 100); // 100ms buffer to ensure audio output has completed 1107 1108 } else if (event === 'offset' && trackId) { 1109 // Log offset updates for debugging 1110 const currentTime = offset / (this.wavPlayer.sampleRate || 24000); 1111 // console.log(`Stream offset update: trackId=${trackId}, offset=${offset}, time=${currentTime.toFixed(2)}s`); 1112 } 1113 }; 1114 // console.log('Stream event listener attached successfully'); 1115 } else { 1116 // console.log('setupStreamEventListener: stream.port not available'); 1117 } 1118 } 1119} 1120 1121// Export the widget class 1122window.VoiceAssistantWidget = VoiceAssistantWidget;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.