PageSourceSearch

https://fonadalabs.ai/widget.js

js fonadalabs.ai collected 2026-10-05 13:46:24 UTC 46,933 bytes, 1,122 lines download raw bytes

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.